173 lines
5.2 KiB
TypeScript
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);
|
|
}
|