매수, 매도 색상 수정

This commit is contained in:
Macbook
2026-05-31 01:23:47 +09:00
parent ba4ef5e230
commit 9d7dddfa57
24 changed files with 573 additions and 441 deletions
+9 -108
View File
@@ -1,7 +1,6 @@
import { coerceFiniteNumber } from './safeFormat';
import {
formatIndicatorValue,
isConditionMet,
type VirtualConditionRow,
} from './virtualStrategyConditions';
@@ -10,7 +9,7 @@ export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown';
export interface ConditionMetric {
row: VirtualConditionRow & { currentValue: number | null };
status: ConditionStatus;
/** 조건 매칭율 0~100 */
/** 조건 매칭율 0~100 — 백엔드 Ta4j satisfied 만 반영 */
matchRate: number;
/** @deprecated matchRate 와 동일 — 하위 호환 */
progress: number | null;
@@ -23,102 +22,28 @@ export function resolveHeatTier(matchRate: number, status: ConditionStatus): Hea
if (status === 'match' || matchRate >= 100) return 'match';
if (status === 'pending') return 'pending';
if (status === 'nomatch') return 'nomatch';
if (matchRate >= 45) return 'pending';
return 'nomatch';
}
export type TrafficLightState = 'red' | 'yellow' | 'blue';
const PENDING_PROGRESS = 72;
/** 목표값 대비 현재값 접근률 (0~100, 충족 시 100) */
export function computeConditionProgress(
conditionType: string,
current: number | null | unknown,
target: number | null | unknown,
): number | null {
const cur = coerceFiniteNumber(current);
const tgt = coerceFiniteNumber(target);
if (cur == null || tgt == null) return null;
const met = isConditionMet(conditionType, cur, tgt);
if (met === true) return 100;
const absTarget = Math.abs(tgt);
const scale = absTarget > 1e-9 ? absTarget : Math.max(Math.abs(cur), 1);
switch (conditionType) {
case 'LT':
case 'CROSS_DOWN':
case 'LTE': {
if (cur <= tgt) return 100;
const gap = cur - tgt;
return Math.min(99, Math.max(0, 100 - (gap / scale) * 100));
}
case 'GT':
case 'CROSS_UP':
case 'GTE': {
if (cur >= tgt) return 100;
if (tgt <= 0 && cur <= 0) return 0;
return Math.min(99, Math.max(0, (cur / scale) * 100));
}
case 'EQ': {
const diff = Math.abs(cur - tgt);
return Math.min(99, Math.max(0, 100 - (diff / scale) * 100));
}
default:
return Math.min(99, Math.max(0, 100 - (Math.abs(cur - tgt) / scale) * 100));
}
}
const CROSS_CONDITION_TYPES = new Set([
'CROSS_UP', 'CROSS_DOWN', 'SLOPE_UP', 'SLOPE_DOWN',
]);
/** 조건별 매칭율 (0~100) — 백엔드 Ta4j satisfied 우선 */
/** 조건별 매칭율 (0~100) — 백엔드 live-conditions 의 satisfied 만 사용 */
export function computeConditionMatchRate(
row: VirtualConditionRow & { currentValue: number | null },
): number {
if (row.satisfied === true) return 100;
if (row.satisfied === false) {
if (CROSS_CONDITION_TYPES.has(row.conditionType)) return 0;
const progress = computeConditionProgress(
row.conditionType,
row.currentValue,
row.targetValue,
);
if (progress == null) return 0;
return Math.min(99, Math.max(0, Math.round(progress)));
}
if (row.currentValue == null) return 0;
const progress = computeConditionProgress(
row.conditionType,
row.currentValue,
row.targetValue,
);
if (progress == null) return 0;
return Math.min(99, Math.max(0, Math.round(progress)));
if (row.satisfied === false) return 0;
return 0;
}
export function getConditionStatus(
conditionType: string,
current: number | null,
target: number | null,
_conditionType: string,
_current: number | null,
_target: number | null,
satisfied?: boolean | null,
): ConditionStatus {
if (satisfied === true) return 'match';
if (satisfied === false) {
if (CROSS_CONDITION_TYPES.has(conditionType)) return 'nomatch';
const progress = computeConditionProgress(conditionType, current, target);
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
return 'nomatch';
}
const met = isConditionMet(conditionType, current, target);
if (met === true) return 'match';
if (met === false) {
const progress = computeConditionProgress(conditionType, current, target);
if (progress != null && progress >= PENDING_PROGRESS) return 'pending';
return 'nomatch';
}
if (satisfied === false) return 'nomatch';
return 'unknown';
}
@@ -196,35 +121,11 @@ export function computeMatchRate(
export function getTrafficLightState(matchRate: number, metrics: ConditionMetric[]): TrafficLightState {
if (matchRate >= 100) return 'blue';
const hasPending = metrics.some(m => m.status === 'pending');
const hasPartial = metrics.some(m => m.status === 'match');
if (hasPending || hasPartial || matchRate >= 40) return 'yellow';
if (hasPartial || matchRate >= 40) return 'yellow';
return 'red';
}
/** 매수·매도 조건 충족률 기준 타이밍 (TradeAlertModal: 매수=블루, 매도=레드) */
export type VirtualTradeTiming = 'buy' | 'sell' | 'neutral';
function sideMatchRate(metrics: ConditionMetric[], side: 'buy' | 'sell'): number {
const rows = metrics.filter(m => m.row.side === side && m.status !== 'unknown');
if (rows.length === 0) return 0;
const met = rows.filter(m => m.status === 'match').length;
return Math.round((met / rows.length) * 100);
}
export function resolveVirtualTradeTiming(metrics: ConditionMetric[]): VirtualTradeTiming {
const buyRate = sideMatchRate(metrics, 'buy');
const sellRate = sideMatchRate(metrics, 'sell');
if (buyRate === 0 && sellRate === 0) return 'neutral';
if (buyRate >= 100 && buyRate > sellRate) return 'buy';
if (sellRate >= 100 && sellRate > buyRate) return 'sell';
if (buyRate >= 100 && sellRate < 100) return 'buy';
if (sellRate >= 100 && buyRate < 100) return 'sell';
if (buyRate > sellRate && buyRate >= 50) return 'buy';
if (sellRate > buyRate && sellRate >= 50) return 'sell';
return 'neutral';
}
export function formatUpdatedTime(ts: number | undefined): string {
if (!ts) return '—';
return new Date(ts).toLocaleTimeString('ko-KR', {