308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
/**
|
|
* 전략 평가 — 선택 봉 기준 조건별 충족/미충족 해석
|
|
*/
|
|
import type { StrategyDto } from './backendApi';
|
|
import type { ConditionDSL, LogicNode } from './strategyTypes';
|
|
import {
|
|
formatCandleRangeClause,
|
|
resolveCandleRangeMode,
|
|
candleRangeWindowBars,
|
|
} from './strategyTypes';
|
|
import { asLogicNode } from './strategyHydrate';
|
|
import { normalizeStartCandleType } from './strategyStartNodes';
|
|
import {
|
|
formatIndicatorValue,
|
|
formatVirtualConditionListLabel,
|
|
} from './virtualStrategyConditions';
|
|
import {
|
|
formatStrategyThreshold,
|
|
type ConditionMetric,
|
|
} from './virtualSignalMetrics';
|
|
import { nodeToText, buildDefFromGetParams, type DefType, DEF_DEFAULTS } from './strategyEditorShared';
|
|
|
|
export interface ConditionInterpretContext {
|
|
condition: ConditionDSL;
|
|
summary: string;
|
|
timeframe: string;
|
|
}
|
|
|
|
export interface ConditionInterpretation {
|
|
id: string;
|
|
label: string;
|
|
summary: string;
|
|
status: 'match' | 'nomatch' | 'unknown';
|
|
statusLabel: string;
|
|
reason: string;
|
|
}
|
|
|
|
export interface SideInterpretation {
|
|
side: 'buy' | 'sell';
|
|
sideTitle: string;
|
|
overallMet: boolean | null;
|
|
overallSummary: string;
|
|
conditions: ConditionInterpretation[];
|
|
}
|
|
|
|
function walkContext(
|
|
node: LogicNode | null | undefined,
|
|
timeframe: string,
|
|
side: 'buy' | 'sell',
|
|
out: Map<string, ConditionInterpretContext>,
|
|
seen: Set<string>,
|
|
def: DefType,
|
|
): void {
|
|
if (!node) return;
|
|
|
|
if (node.type === 'TIMEFRAME') {
|
|
const tf = normalizeStartCandleType(node.candleTypes?.[0] ?? node.candleType ?? timeframe);
|
|
node.children?.forEach(c => walkContext(c, tf, side, out, seen, def));
|
|
return;
|
|
}
|
|
|
|
if (node.type === 'NOT') {
|
|
const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
|
|
if (child) walkContext(child, timeframe, side, out, seen, def);
|
|
return;
|
|
}
|
|
|
|
if (node.type === 'AND' || node.type === 'OR') {
|
|
node.children?.forEach(c => walkContext(c, timeframe, side, out, seen, def));
|
|
return;
|
|
}
|
|
|
|
if ((!node.type || node.type === 'CONDITION') && node.condition) {
|
|
const c = node.condition;
|
|
const rowTf = normalizeStartCandleType(c.leftCandleType ?? c.rightCandleType ?? timeframe);
|
|
const rowId = node.id ? `${node.id}-${side}` : `${side}:${rowTf}:${c.indicatorType}:${out.size}`;
|
|
if (seen.has(rowId)) return;
|
|
seen.add(rowId);
|
|
out.set(rowId, {
|
|
condition: c,
|
|
summary: nodeToText(node, def),
|
|
timeframe: rowTf,
|
|
});
|
|
return;
|
|
}
|
|
|
|
node.children?.forEach(c => walkContext(c, timeframe, side, out, seen, def));
|
|
}
|
|
|
|
export function buildConditionContextMap(
|
|
strategy: StrategyDto | null | undefined,
|
|
getParams?: (type: string, defaults?: Record<string, number | string | boolean>) => Record<string, number | string | boolean>,
|
|
): Map<string, ConditionInterpretContext> {
|
|
const def = getParams ? buildDefFromGetParams(getParams) : DEF_DEFAULTS;
|
|
const out = new Map<string, ConditionInterpretContext>();
|
|
const seen = new Set<string>();
|
|
walkContext(asLogicNode(strategy?.buyCondition), '1m', 'buy', out, seen, def);
|
|
walkContext(asLogicNode(strategy?.sellCondition), '1m', 'sell', out, seen, def);
|
|
return out;
|
|
}
|
|
|
|
function rangeNote(cond?: ConditionDSL): string {
|
|
if (!cond) return '';
|
|
const mode = resolveCandleRangeMode(cond);
|
|
if (mode === 'CURRENT') return '';
|
|
const n = candleRangeWindowBars(cond);
|
|
if (mode === 'EXISTS_IN') return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번이라도 성립하면 충족으로 판정합니다. `;
|
|
return `최근 ${n}봉(현재 봉 포함) 안에 해당 조건이 한 번도 없어야 충족으로 판정합니다. `;
|
|
}
|
|
|
|
function comparePhrase(
|
|
conditionType: string,
|
|
current: number | null,
|
|
target: number | null,
|
|
thresholdLabel: string | undefined,
|
|
satisfied: boolean,
|
|
): string {
|
|
const cur = formatIndicatorValue(current);
|
|
const ref = thresholdLabel
|
|
?? (target != null ? formatStrategyThreshold(conditionType, target, '') : '기준값');
|
|
|
|
if (current == null) {
|
|
return satisfied
|
|
? `조건(${ref})을(를) 충족합니다.`
|
|
: `조건(${ref})을(를) 충족하지 못했습니다.`;
|
|
}
|
|
|
|
switch (conditionType) {
|
|
case 'GTE':
|
|
return satisfied
|
|
? `현재값 ${cur} ≥ ${ref.replace(/^≥\s*/, '')} 이므로 충족합니다.`
|
|
: `현재값 ${cur} < ${ref.replace(/^≥\s*/, '')} 이므로 미충족입니다.`;
|
|
case 'LTE':
|
|
return satisfied
|
|
? `현재값 ${cur} ≤ ${ref.replace(/^≤\s*/, '')} 이므로 충족합니다.`
|
|
: `현재값 ${cur} > ${ref.replace(/^≤\s*/, '')} 이므로 미충족입니다.`;
|
|
case 'GT':
|
|
return satisfied
|
|
? `현재값 ${cur} > ${ref.replace(/^>\s*/, '')} 이므로 충족합니다.`
|
|
: `현재값 ${cur} ≤ ${ref.replace(/^>\s*/, '')} 이므로 미충족입니다.`;
|
|
case 'LT':
|
|
return satisfied
|
|
? `현재값 ${cur} < ${ref.replace(/^<\s*/, '')} 이므로 충족합니다.`
|
|
: `현재값 ${cur} ≥ ${ref.replace(/^<\s*/, '')} 이므로 미충족입니다.`;
|
|
case 'EQ':
|
|
return satisfied
|
|
? `현재값 ${cur} = ${ref.replace(/^=\s*/, '')} 이므로 충족합니다.`
|
|
: `현재값 ${cur} ≠ ${ref.replace(/^=\s*/, '')} 이므로 미충족입니다.`;
|
|
default:
|
|
return satisfied
|
|
? `현재값 ${cur}, 기준 ${ref} — 조건 충족.`
|
|
: `현재값 ${cur}, 기준 ${ref} — 조건 미충족.`;
|
|
}
|
|
}
|
|
|
|
function crossPhrase(
|
|
cond: ConditionDSL | undefined,
|
|
conditionLabel: string,
|
|
thresholdLabel: string | undefined,
|
|
satisfied: boolean,
|
|
): string {
|
|
const range = cond ? formatCandleRangeClause(cond).trim() : '';
|
|
const mode = cond ? resolveCandleRangeMode(cond) : 'CURRENT';
|
|
const target = thresholdLabel ?? '비교 대상';
|
|
|
|
if (mode === 'EXISTS_IN') {
|
|
const n = cond ? candleRangeWindowBars(cond) : 0;
|
|
return satisfied
|
|
? `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 확인되어 충족합니다.`
|
|
: `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 없어 미충족입니다.`;
|
|
}
|
|
if (mode === 'NOT_EXISTS_IN') {
|
|
const n = cond ? candleRangeWindowBars(cond) : 0;
|
|
return satisfied
|
|
? `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 없어 충족합니다.`
|
|
: `${range || `최근 ${n}봉 내`} ${conditionLabel}(${target})이(가) 있어 미충족입니다.`;
|
|
}
|
|
return satisfied
|
|
? `현재 봉에서 ${conditionLabel}(${target})이(가) 발생하여 충족합니다.`
|
|
: `현재 봉에서 ${conditionLabel}(${target})이(가) 없어 미충족입니다. (이미 돌파 후 유지 중인 경우에도 현재 봉 기준 미충족일 수 있습니다.)`;
|
|
}
|
|
|
|
function buildReason(
|
|
metric: ConditionMetric,
|
|
ctx?: ConditionInterpretContext,
|
|
): string {
|
|
const { row, status } = metric;
|
|
const cond = ctx?.condition;
|
|
const prefix = rangeNote(cond);
|
|
|
|
if (status === 'unknown') {
|
|
return `${prefix}봉 데이터 또는 지표 값이 부족하여 이 조건을 평가하지 못했습니다.`;
|
|
}
|
|
|
|
const threshold = row.thresholdLabel
|
|
?? formatStrategyThreshold(row.conditionType, row.targetValue, row.conditionLabel);
|
|
|
|
if (row.conditionType === 'CROSS_UP' || row.conditionType === 'CROSS_DOWN') {
|
|
return prefix + crossPhrase(cond, row.conditionLabel, row.thresholdLabel, status === 'match');
|
|
}
|
|
|
|
if (row.conditionType === 'SLOPE_UP' || row.conditionType === 'SLOPE_DOWN') {
|
|
const dir = row.conditionType === 'SLOPE_UP' ? '상승' : '하락';
|
|
return prefix + (status === 'match'
|
|
? `최근 기울기가 ${dir} 추세로 판정되어 충족합니다.`
|
|
: `최근 기울기가 ${dir} 추세가 아니어서 미충족입니다.`);
|
|
}
|
|
|
|
if (row.conditionType === 'HOLD_N_DAYS') {
|
|
const hd = cond?.holdDays ?? 3;
|
|
return prefix + (status === 'match'
|
|
? `조건이 ${hd}봉 연속 유지되어 충족합니다.`
|
|
: `조건이 ${hd}봉 연속 유지되지 않아 미충족입니다.`);
|
|
}
|
|
|
|
return prefix + comparePhrase(
|
|
row.conditionType,
|
|
row.currentValue,
|
|
row.targetValue,
|
|
threshold,
|
|
status === 'match',
|
|
);
|
|
}
|
|
|
|
export function interpretConditionMetric(
|
|
metric: ConditionMetric,
|
|
ctx?: ConditionInterpretContext,
|
|
): ConditionInterpretation {
|
|
const { row, status } = metric;
|
|
const label = formatVirtualConditionListLabel(row);
|
|
const summary = ctx?.summary ?? label;
|
|
|
|
const statusLabel = status === 'match'
|
|
? '충족'
|
|
: status === 'nomatch'
|
|
? '미충족'
|
|
: status === 'pending'
|
|
? '부분 충족'
|
|
: '평가 불가';
|
|
|
|
const normalizedStatus: ConditionInterpretation['status'] = status === 'match'
|
|
? 'match'
|
|
: status === 'nomatch'
|
|
? 'nomatch'
|
|
: 'unknown';
|
|
|
|
return {
|
|
id: row.id,
|
|
label,
|
|
summary,
|
|
status: normalizedStatus,
|
|
statusLabel,
|
|
reason: buildReason(metric, ctx),
|
|
};
|
|
}
|
|
|
|
function buildOverallSummary(
|
|
side: 'buy' | 'sell',
|
|
metrics: ConditionMetric[],
|
|
overallMet: boolean | null | undefined,
|
|
): string {
|
|
const sideKo = side === 'buy' ? '매수' : '매도';
|
|
const evaluable = metrics.filter(m => m.status !== 'unknown');
|
|
const metCount = evaluable.filter(m => m.status === 'match').length;
|
|
|
|
if (evaluable.length === 0) {
|
|
return `${sideKo} 조건을 평가할 수 있는 데이터가 없습니다.`;
|
|
}
|
|
|
|
if (overallMet === true) {
|
|
return `${sideKo} DSL 전체 Rule(AND/OR/NOT 포함)이 충족되었습니다. 아래 ${evaluable.length}개 조건이 동시에 true 입니다.`;
|
|
}
|
|
|
|
if (overallMet === false) {
|
|
if (metCount === 0) {
|
|
return `${sideKo} DSL 전체 Rule이 미충족입니다. ${evaluable.length}개 조건 모두 false 입니다.`;
|
|
}
|
|
return `${sideKo} DSL 전체 Rule이 미충족입니다. ${evaluable.length}개 중 ${metCount}개만 충족 — AND 결합 시 하나라도 false면 전체 false 입니다.`;
|
|
}
|
|
|
|
return `${sideKo} 조건 ${evaluable.length}개 중 ${metCount}개 충족 (리프 조건 기준).`;
|
|
}
|
|
|
|
export function buildSideInterpretation(
|
|
side: 'buy' | 'sell',
|
|
metrics: ConditionMetric[],
|
|
overallMet: boolean | null | undefined,
|
|
contextMap: Map<string, ConditionInterpretContext>,
|
|
): SideInterpretation {
|
|
return {
|
|
side,
|
|
sideTitle: side === 'buy' ? '매수 조건' : '매도 조건',
|
|
overallMet: overallMet ?? null,
|
|
overallSummary: buildOverallSummary(side, metrics, overallMet),
|
|
conditions: metrics.map(m => interpretConditionMetric(m, contextMap.get(m.row.id))),
|
|
};
|
|
}
|
|
|
|
export function formatBarTimeLabel(barTimeSec: number | null | undefined): string {
|
|
if (barTimeSec == null) return '선택 봉';
|
|
return new Date(barTimeSec * 1000).toLocaleString('ko-KR', {
|
|
month: 'numeric',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
}
|