import { coerceFiniteNumber } from './safeFormat'; import { formatIndicatorValue, type VirtualConditionRow, } from './virtualStrategyConditions'; export type ConditionStatus = 'match' | 'pending' | 'nomatch' | 'unknown'; export interface ConditionMetric { row: VirtualConditionRow & { currentValue: number | null }; status: ConditionStatus; /** 조건 매칭율 0~100 — 백엔드 Ta4j satisfied 만 반영 */ matchRate: number; /** @deprecated matchRate 와 동일 — 하위 호환 */ progress: number | null; } export type HeatTier = 'nomatch' | 'pending' | 'match'; /** Heatmap 색상 — Mismatch(빨강) · Pending(오렌지) · Match(푸른색) */ export function resolveHeatTier(matchRate: number, status: ConditionStatus): HeatTier { if (status === 'match' || matchRate >= 100) return 'match'; if (status === 'pending') return 'pending'; if (status === 'nomatch') return 'nomatch'; return 'nomatch'; } export type TrafficLightState = 'red' | 'yellow' | 'blue'; /** 조건별 매칭율 (0~100) — 백엔드 live-conditions 의 satisfied 만 사용 */ export function computeConditionMatchRate( row: VirtualConditionRow & { currentValue: number | null }, ): number { if (row.satisfied === true) return 100; if (row.satisfied === false) return 0; return 0; } export function getConditionStatus( _conditionType: string, _current: number | null, _target: number | null, satisfied?: boolean | null, ): ConditionStatus { if (satisfied === true) return 'match'; if (satisfied === false) return 'nomatch'; return 'unknown'; } export function formatStrategyThreshold( conditionType: string, target: number | null, conditionLabel: string, ): string { if (target == null) return '—'; const v = formatIndicatorValue(target); switch (conditionType) { case 'LT': case 'CROSS_DOWN': return `< ${v}`; case 'GT': case 'CROSS_UP': return `> ${v}`; case 'GTE': return `≥ ${v}`; case 'LTE': return `≤ ${v}`; case 'EQ': return `= ${v}`; case 'NEQ': return `≠ ${v}`; default: return `${conditionLabel} ${v}`; } } export function normalizeConditionRow( row: VirtualConditionRow & { currentValue?: unknown }, ): VirtualConditionRow & { currentValue: number | null } { return { ...row, targetValue: coerceFiniteNumber(row.targetValue), currentValue: coerceFiniteNumber(row.currentValue ?? null), }; } export function buildConditionMetrics( rows: Array, ): ConditionMetric[] { return rows.map(raw => { const row = normalizeConditionRow(raw); const matchRate = computeConditionMatchRate(row); return { row, status: getConditionStatus( row.conditionType, row.currentValue, row.targetValue, row.satisfied, ), matchRate, progress: matchRate, }; }); } /** 전체 매매조건 일치율 — 백엔드 matchRate 우선 */ export function computeMatchRate( metrics: ConditionMetric[], backendMatchRate?: number | unknown, ): number { const backend = coerceFiniteNumber(backendMatchRate); if (backend != null) { return Math.round(Math.min(100, Math.max(0, backend))); } const evaluable = metrics.filter(m => m.status !== 'unknown'); if (evaluable.length === 0) return 0; const met = evaluable.filter(m => m.status === 'match').length; return Math.round((met / evaluable.length) * 100); } export function getTrafficLightState(matchRate: number, metrics: ConditionMetric[]): TrafficLightState { if (matchRate >= 100) return 'blue'; const hasPartial = metrics.some(m => m.status === 'match'); if (hasPartial || matchRate >= 40) return 'yellow'; return 'red'; } /** side 카드 헤드라인 — DSL 전체 Rule 충족 시 100% (리프 비율과 구분) */ export function resolveSideHeadlineMatchRate( metrics: ConditionMetric[], overallMet?: boolean | null, ): number { if (overallMet === true) return 100; return computeMatchRate(metrics); } export function resolveSideTrafficState( metrics: ConditionMetric[], overallMet?: boolean | null, ): TrafficLightState { const rate = resolveSideHeadlineMatchRate(metrics, overallMet); if (overallMet === true) return 'blue'; if (overallMet === false && rate === 0) return 'red'; return getTrafficLightState(rate, metrics); } export function formatUpdatedTime(ts: number | undefined): string { if (!ts) return '—'; return new Date(ts).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); } /** 카드 푸터용 전략 조건 한 줄 요약 */ export function formatVirtualConditionBrief( row: VirtualConditionRow & { currentValue?: number | null }, ): string { const sideTag = row.side === 'buy' ? '[매수]' : row.side === 'sell' ? '[매도]' : ''; const threshold = row.thresholdLabel ?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel); return [sideTag, row.displayName, threshold].filter(Boolean).join(' '); } export function formatVirtualCardConditionLines( rows: Array, ): string[] { return rows.map(formatVirtualConditionBrief); }