/** * 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 = { HL_OVER: '과열선', HL_MID: '중앙선', HL_UNDER: '침체선', }; /** Ta4j Rule.isSatisfied 기준 — conditionType별 의미 */ const EVALUATION_SEMANTICS: Record = { 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 { llmGuidance: { matchRateNote: string; conditionTypeNote: string; doNotMisinterpret: string[]; }; evaluation: Omit & { buyConditions: LlmConditionEvaluationRow[]; sellConditions: LlmConditionEvaluationRow[]; }; } function walkConditionIndex( node: LogicNode | null | undefined, timeframe: string, side: 'buy' | 'sell', out: Map, seen: Set, ): 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 { const map = new Map(); 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, ): 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'); }