Files
goldenChart/frontend/src/utils/virtualStrategyConditions.ts
T
2026-05-25 16:13:25 +09:00

117 lines
3.7 KiB
TypeScript

/**
* 전략 DSL → 가상투자 이퀄라이저 표시용 조건 추출
*/
import type { LogicNode } from './strategyTypes';
import { CONDITION_LABEL } from './strategyTypes';
import type { StrategyDto } from './backendApi';
import { formatIndicatorDisplayLabel } from './indicatorRegistry';
export interface VirtualConditionRow {
id: string;
indicatorType: string;
displayName: string;
conditionType: string;
conditionLabel: string;
targetValue: number | null;
timeframe: string;
side: 'buy' | 'sell';
/** 지표 계산 plot id (RSI, MACD 등) */
plotKey: string;
/** 백엔드 Ta4j Rule.isSatisfied 결과 (있으면 우선) */
satisfied?: boolean | null;
/** 백엔드 임계값 라벨 (MA cross 등) */
thresholdLabel?: string;
}
function walk(
node: LogicNode | null | undefined,
timeframe: string,
side: 'buy' | 'sell',
out: VirtualConditionRow[],
seen: Set<string>,
): void {
if (!node) return;
if (node.type === 'TIMEFRAME') {
const tf = node.candleType ?? timeframe;
node.children?.forEach(c => walk(c, tf, side, out, seen));
return;
}
if (node.type === 'NOT') {
const notChild = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
if (notChild) walk(notChild, timeframe, side, out, seen);
return;
}
if (node.type === 'CONDITION' && node.condition) {
const c = node.condition;
const key = `${side}:${timeframe}:${c.indicatorType}:${c.conditionType}:${c.targetValue ?? ''}:${c.leftField ?? ''}`;
if (seen.has(key)) return;
seen.add(key);
const condLabel = CONDITION_LABEL[c.conditionType] ?? c.conditionType;
const target = coerceFiniteNumber(c.targetValue ?? c.compareValue ?? null);
const plotKey = c.leftField && c.leftField !== 'none' ? c.leftField : c.indicatorType;
out.push({
id: `${node.id}-${side}`,
indicatorType: c.indicatorType,
displayName: formatIndicatorDisplayLabel(c.indicatorType),
conditionType: c.conditionType,
conditionLabel: condLabel,
targetValue: target,
timeframe,
side,
plotKey,
});
return;
}
node.children?.forEach(c => walk(c, timeframe, side, out, seen));
}
/** API·DSL에서 문자열로 올 수 있는 값을 안전하게 숫자로 변환 */
export function coerceFiniteNumber(v: unknown): number | null {
if (v == null || v === '') return null;
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
export function extractVirtualConditions(strategy: StrategyDto | null | undefined): VirtualConditionRow[] {
if (!strategy) return [];
const out: VirtualConditionRow[] = [];
const seen = new Set<string>();
walk(strategy.buyCondition as LogicNode | null, '1m', 'buy', out, seen);
walk(strategy.sellCondition as LogicNode | null, '1m', 'sell', out, seen);
return out;
}
/** 조건 충족 여부 (단순 임계값 비교) */
export function isConditionMet(
conditionType: string,
current: number | null | unknown,
target: number | null | unknown,
): boolean | null {
const cur = coerceFiniteNumber(current);
const tgt = coerceFiniteNumber(target);
if (cur == null || tgt == null) return null;
switch (conditionType) {
case 'GT': case 'CROSS_UP': return cur > tgt;
case 'LT': case 'CROSS_DOWN': return cur < tgt;
case 'GTE': return cur >= tgt;
case 'LTE': return cur <= tgt;
case 'EQ': return Math.abs(cur - tgt) < 1e-6;
case 'NEQ': return Math.abs(cur - tgt) >= 1e-6;
default: return null;
}
}
export function formatIndicatorValue(v: number | null | unknown): string {
const n = coerceFiniteNumber(v);
if (n == null) return '—';
if (Math.abs(n) >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
return n.toFixed(2);
}