236 lines
9.0 KiB
TypeScript
236 lines
9.0 KiB
TypeScript
/**
|
|
* LLM AI 검증 — 오해 소지 UI 라벨 대신 실제 DSL·Ta4j 평가 의미를 전달
|
|
*/
|
|
import type { ConditionDSL, LogicNode } from './strategyTypes';
|
|
import { CONDITION_LABEL } from './strategyTypes';
|
|
import { asLogicNode } from './strategyHydrate';
|
|
import { normalizeStartCandleType } from './strategyStartNodes';
|
|
import { formatIndicatorValue } from './virtualStrategyConditions';
|
|
import { isThresholdSymbol } from './thresholdSymbols';
|
|
import type { StrategyEvaluationAiVerifyContext } from './strategyEvaluationAiVerify';
|
|
|
|
const HL_ROLE_KO: Record<string, string> = {
|
|
HL_OVER: '과열선',
|
|
HL_MID: '중앙선',
|
|
HL_UNDER: '침체선',
|
|
};
|
|
|
|
/** Ta4j Rule.isSatisfied 기준 — conditionType별 의미 */
|
|
const EVALUATION_SEMANTICS: Record<string, string> = {
|
|
GT: '현재봉 left > right',
|
|
LT: '현재봉 left < right',
|
|
GTE: '현재봉 left ≥ right',
|
|
LTE: '현재봉 left ≤ right',
|
|
EQ: '현재봉 left = right',
|
|
NEQ: '현재봉 left ≠ right',
|
|
CROSS_UP:
|
|
'직전봉 left≤right 이고 현재봉 left>right (교차 이벤트). 현재값이 임계값보다 커도 이미 돌파된 상태면 미충족.',
|
|
CROSS_DOWN:
|
|
'직전봉 left≥right 이고 현재봉 left<right (교차 이벤트). 현재값이 임계값보다 작지 않으면 미충족.',
|
|
SLOPE_UP: '최근 N봉 기울기 상승',
|
|
SLOPE_DOWN: '최근 N봉 기울기 하락',
|
|
};
|
|
|
|
export interface LlmConditionEvaluationRow {
|
|
id: string;
|
|
side: 'buy' | 'sell';
|
|
timeframe: string;
|
|
indicatorType: string;
|
|
conditionType: string;
|
|
satisfied: boolean | null;
|
|
currentValue: number | null;
|
|
/** leaf condition DSL (strategy JSON 그대로) */
|
|
dslCondition: ConditionDSL;
|
|
/** Ta4j 평가식 요약 — GT/LT와 혼동되지 않도록 conditionType 명시 */
|
|
dslExpression: string;
|
|
/** Ta4j isSatisfied 판정 기준 설명 */
|
|
evaluationSemantics: string;
|
|
/** right operand 해석 (HL_UNDER → 20 등) */
|
|
rightOperand: string;
|
|
}
|
|
|
|
export interface LlmVerifyPayload extends Omit<StrategyEvaluationAiVerifyContext, 'evaluation'> {
|
|
llmGuidance: {
|
|
matchRateNote: string;
|
|
conditionTypeNote: string;
|
|
doNotMisinterpret: string[];
|
|
};
|
|
evaluation: Omit<StrategyEvaluationAiVerifyContext['evaluation'], 'buyConditions' | 'sellConditions'> & {
|
|
buyConditions: LlmConditionEvaluationRow[];
|
|
sellConditions: LlmConditionEvaluationRow[];
|
|
};
|
|
}
|
|
|
|
function walkConditionIndex(
|
|
node: LogicNode | null | undefined,
|
|
timeframe: string,
|
|
side: 'buy' | 'sell',
|
|
out: Map<string, ConditionDSL>,
|
|
seen: Set<string>,
|
|
): void {
|
|
if (!node) return;
|
|
|
|
if (node.type === 'TIMEFRAME') {
|
|
const tf = normalizeStartCandleType(node.candleTypes?.[0] ?? node.candleType ?? timeframe);
|
|
node.children?.forEach(c => walkConditionIndex(c, tf, side, out, seen));
|
|
return;
|
|
}
|
|
|
|
if (node.type === 'NOT') {
|
|
const child = node.children?.[0] ?? (node as LogicNode & { child?: LogicNode }).child;
|
|
if (child) walkConditionIndex(child, timeframe, side, out, seen);
|
|
return;
|
|
}
|
|
|
|
if (node.type === 'AND' || node.type === 'OR') {
|
|
node.children?.forEach(c => walkConditionIndex(c, timeframe, side, out, seen));
|
|
return;
|
|
}
|
|
|
|
if ((!node.type || node.type === 'CONDITION') && node.condition && node.id) {
|
|
const rowId = `${node.id}-${side}`;
|
|
const key = `${rowId}:${JSON.stringify(node.condition)}`;
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
out.set(rowId, node.condition);
|
|
}
|
|
}
|
|
|
|
export function indexStrategyConditions(
|
|
buyRoot: unknown,
|
|
sellRoot: unknown,
|
|
): Map<string, ConditionDSL> {
|
|
const map = new Map<string, ConditionDSL>();
|
|
walkConditionIndex(asLogicNode(buyRoot), '1m', 'buy', map, new Set());
|
|
walkConditionIndex(asLogicNode(sellRoot), '1m', 'sell', map, new Set());
|
|
return map;
|
|
}
|
|
|
|
function formatRightOperand(cond: ConditionDSL, targetValue: number | null): string {
|
|
const rf = cond.rightField ?? '';
|
|
if (!rf || rf === 'NONE') return '—';
|
|
if (rf.startsWith('K_')) return rf;
|
|
if (isThresholdSymbol(rf)) {
|
|
const role = HL_ROLE_KO[rf] ?? rf;
|
|
const v = targetValue != null ? formatIndicatorValue(targetValue) : '?';
|
|
return `${rf}(${v}, ${role})`;
|
|
}
|
|
return rf;
|
|
}
|
|
|
|
/** LLM·로그용 — conditionType을 GT/LT로 축약하지 않음 */
|
|
export function formatConditionDslExpression(
|
|
cond: ConditionDSL,
|
|
timeframe: string,
|
|
targetValue: number | null,
|
|
): string {
|
|
const left = cond.leftField && cond.leftField !== 'none' ? cond.leftField : cond.indicatorType;
|
|
const right = formatRightOperand(cond, targetValue);
|
|
const tf = normalizeStartCandleType(cond.leftCandleType ?? cond.rightCandleType ?? timeframe);
|
|
const ctLabel = CONDITION_LABEL[cond.conditionType] ?? cond.conditionType;
|
|
return `[${tf}] ${left} ${cond.conditionType}(${ctLabel}) ${right}`;
|
|
}
|
|
|
|
export function formatConditionEvaluationSemantics(conditionType: string): string {
|
|
return EVALUATION_SEMANTICS[conditionType]
|
|
?? `Ta4j Rule.isSatisfied — ${CONDITION_LABEL[conditionType] ?? conditionType}`;
|
|
}
|
|
|
|
export function enrichConditionRow(
|
|
row: StrategyEvaluationAiVerifyContext['evaluation']['buyConditions'][number],
|
|
dslById: Map<string, ConditionDSL>,
|
|
): LlmConditionEvaluationRow {
|
|
const dsl = dslById.get(row.id);
|
|
if (!dsl) {
|
|
const fallback: ConditionDSL = {
|
|
indicatorType: row.indicatorType,
|
|
conditionType: row.conditionType,
|
|
leftField: row.indicatorType,
|
|
rightField: row.targetValue != null ? `K_${row.targetValue}` : undefined,
|
|
};
|
|
return {
|
|
id: row.id,
|
|
side: row.side as 'buy' | 'sell',
|
|
timeframe: row.timeframe,
|
|
indicatorType: row.indicatorType,
|
|
conditionType: row.conditionType,
|
|
satisfied: row.satisfied,
|
|
currentValue: row.currentValue,
|
|
dslCondition: fallback,
|
|
dslExpression: formatConditionDslExpression(fallback, row.timeframe, row.targetValue),
|
|
evaluationSemantics: formatConditionEvaluationSemantics(row.conditionType),
|
|
rightOperand: formatRightOperand(fallback, row.targetValue),
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
side: row.side as 'buy' | 'sell',
|
|
timeframe: row.timeframe,
|
|
indicatorType: row.indicatorType,
|
|
conditionType: row.conditionType,
|
|
satisfied: row.satisfied,
|
|
currentValue: row.currentValue,
|
|
dslCondition: dsl,
|
|
dslExpression: formatConditionDslExpression(dsl, row.timeframe, row.targetValue),
|
|
evaluationSemantics: formatConditionEvaluationSemantics(dsl.conditionType),
|
|
rightOperand: formatRightOperand(dsl, row.targetValue),
|
|
};
|
|
}
|
|
|
|
/** UI 컨텍스트 → LLM API 전송용 (오해 유발 thresholdLabel 제외) */
|
|
export function buildLlmVerifyPayload(ctx: StrategyEvaluationAiVerifyContext): LlmVerifyPayload {
|
|
const dslById = indexStrategyConditions(ctx.strategy.buyCondition, ctx.strategy.sellCondition);
|
|
|
|
return {
|
|
meta: ctx.meta,
|
|
strategy: {
|
|
id: ctx.strategy.id,
|
|
name: ctx.strategy.name,
|
|
buyCondition: ctx.strategy.buyCondition,
|
|
sellCondition: ctx.strategy.sellCondition,
|
|
},
|
|
chart: ctx.chart,
|
|
evaluation: {
|
|
loading: ctx.evaluation.loading,
|
|
error: ctx.evaluation.error,
|
|
matchRate: ctx.evaluation.matchRate,
|
|
overallEntryMet: ctx.evaluation.overallEntryMet,
|
|
overallExitMet: ctx.evaluation.overallExitMet,
|
|
buyConditions: ctx.evaluation.buyConditions.map(r => enrichConditionRow(r, dslById)),
|
|
sellConditions: ctx.evaluation.sellConditions.map(r => enrichConditionRow(r, dslById)),
|
|
},
|
|
signals: ctx.signals,
|
|
indicatorParams: ctx.indicatorParams,
|
|
deterministicHints: ctx.deterministicHints,
|
|
llmGuidance: {
|
|
matchRateNote:
|
|
'matchRate는 leaf 조건 satisfied=true 비율(0~100%)입니다. 0%는 모든 leaf가 미충족이라는 뜻이며, Rule↔시그널 불일치를 의미하지 않습니다.',
|
|
conditionTypeNote:
|
|
'CROSS_UP/CROSS_DOWN은 GT/LT(수준 비교)와 다릅니다. 교차는 직전봉·현재봉 2봉 비교 이벤트입니다.',
|
|
doNotMisinterpret: [
|
|
'evaluation.buyConditions/sellConditions의 dslExpression·evaluationSemantics를 기준으로 판단하세요.',
|
|
'thresholdLabel·"> 20" 형태 UI 표기는 LLM 컨텍스트에 포함하지 않았습니다.',
|
|
'deterministicHints.signalMatchesOverallRule이 "대체로 일치"이면 overall Rule과 차트 시그널은 일치합니다.',
|
|
'현재값>임계값인데 CROSS_UP 미충족은 정상일 수 있습니다(이미 돌파 후 유지).',
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
/** 수정 포인트·Cursor 프롬프트용 조건 한 줄 (DSL 기준) */
|
|
export function formatLlmConditionLine(row: LlmConditionEvaluationRow): string {
|
|
const status = row.satisfied === true ? '충족' : row.satisfied === false ? '미충족' : '평가불가';
|
|
const cur = row.currentValue != null ? formatIndicatorValue(row.currentValue) : '-';
|
|
return [
|
|
`- [${row.id}] ${row.dslExpression}`,
|
|
` Ta4j: ${row.evaluationSemantics}`,
|
|
` 결과: ${status} · 현재=${cur} · right=${row.rightOperand}`,
|
|
].join('\n');
|
|
}
|
|
|
|
export function formatLlmConditionLines(rows: LlmConditionEvaluationRow[]): string {
|
|
if (rows.length === 0) return '(조건 없음)';
|
|
return rows.map(formatLlmConditionLine).join('\n');
|
|
}
|