Files
goldenChart/frontend/src/utils/virtualSignalMetrics.ts
T
2026-06-12 15:32:00 +09:00

173 lines
5.2 KiB
TypeScript

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<VirtualConditionRow & { currentValue: number | null }>,
): 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<VirtualConditionRow & { currentValue?: number | null }>,
): string[] {
return rows.map(formatVirtualConditionBrief);
}