|
|
|
@@ -0,0 +1,698 @@
|
|
|
|
|
/**
|
|
|
|
|
* 전략 평가 AI 검증 — LLM 전달용 컨텍스트 구성 및 API 호출
|
|
|
|
|
*/
|
|
|
|
|
import type { BacktestSignal, StrategyDto, StrategyEvaluationAiVerifyResponse } from './backendApi';
|
|
|
|
|
import { fetchStrategyEvaluationAiVerify } from './backendApi';
|
|
|
|
|
import type { OHLCVBar } from '../types';
|
|
|
|
|
import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnapshots';
|
|
|
|
|
import type { BarSignalHighlight } from './strategyEvaluationBarSignals';
|
|
|
|
|
import { resolveBarSignalHighlight } from './strategyEvaluationBarSignals';
|
|
|
|
|
import { formatIndicatorValue } from './virtualStrategyConditions';
|
|
|
|
|
import { formatVirtualConditionListLabel } from './virtualStrategyConditions';
|
|
|
|
|
import { buildConditionMetrics } from './virtualSignalMetrics';
|
|
|
|
|
import { repairUtf8Mojibake } from './textEncoding';
|
|
|
|
|
import { normalizeEpochSecOptional } from './backtestUiUtils';
|
|
|
|
|
|
|
|
|
|
export interface StrategyEvaluationAiVerifyContext {
|
|
|
|
|
meta: {
|
|
|
|
|
generatedAt: string;
|
|
|
|
|
purpose: string;
|
|
|
|
|
};
|
|
|
|
|
strategy: {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
buyCondition: unknown;
|
|
|
|
|
sellCondition: unknown;
|
|
|
|
|
};
|
|
|
|
|
chart: {
|
|
|
|
|
market: string;
|
|
|
|
|
timeframe: string;
|
|
|
|
|
barCount: number;
|
|
|
|
|
evaluationBarCount?: number;
|
|
|
|
|
/** AI 검증 시 서버 Ta4j 재평가용 — 차트와 동일 OHLCV (평가 구간) */
|
|
|
|
|
barsForEvaluation?: Array<{
|
|
|
|
|
time: number;
|
|
|
|
|
open: number;
|
|
|
|
|
high: number;
|
|
|
|
|
low: number;
|
|
|
|
|
close: number;
|
|
|
|
|
volume: number;
|
|
|
|
|
}>;
|
|
|
|
|
selectedBarIndex: number;
|
|
|
|
|
selectedBar: {
|
|
|
|
|
time: number;
|
|
|
|
|
timeLabel: string;
|
|
|
|
|
open: number;
|
|
|
|
|
high: number;
|
|
|
|
|
low: number;
|
|
|
|
|
close: number;
|
|
|
|
|
volume: number;
|
|
|
|
|
} | null;
|
|
|
|
|
};
|
|
|
|
|
evaluation: {
|
|
|
|
|
loading: boolean;
|
|
|
|
|
error: string | null;
|
|
|
|
|
matchRate: number | null;
|
|
|
|
|
overallEntryMet: boolean | null;
|
|
|
|
|
overallExitMet: boolean | null;
|
|
|
|
|
buyConditions: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
side: string;
|
|
|
|
|
timeframe: string;
|
|
|
|
|
indicatorType: string;
|
|
|
|
|
conditionType: string;
|
|
|
|
|
targetValue: number | null;
|
|
|
|
|
currentValue: number | null;
|
|
|
|
|
satisfied: boolean | null;
|
|
|
|
|
thresholdLabel?: string;
|
|
|
|
|
}>;
|
|
|
|
|
sellConditions: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
side: string;
|
|
|
|
|
timeframe: string;
|
|
|
|
|
indicatorType: string;
|
|
|
|
|
conditionType: string;
|
|
|
|
|
targetValue: number | null;
|
|
|
|
|
currentValue: number | null;
|
|
|
|
|
satisfied: boolean | null;
|
|
|
|
|
thresholdLabel?: string;
|
|
|
|
|
}>;
|
|
|
|
|
};
|
|
|
|
|
signals: {
|
|
|
|
|
totalOnChart: number;
|
|
|
|
|
selectedBarHighlight: BarSignalHighlight;
|
|
|
|
|
selectedBarSignals: Array<{
|
|
|
|
|
type: string;
|
|
|
|
|
time: number;
|
|
|
|
|
price: number;
|
|
|
|
|
barIndex: number;
|
|
|
|
|
}>;
|
|
|
|
|
nearbySignals: Array<{
|
|
|
|
|
type: string;
|
|
|
|
|
time: number;
|
|
|
|
|
barIndex: number;
|
|
|
|
|
offsetFromSelected: number;
|
|
|
|
|
}>;
|
|
|
|
|
};
|
|
|
|
|
indicatorParams?: Record<string, Record<string, unknown>> | null;
|
|
|
|
|
deterministicHints: {
|
|
|
|
|
expectedBuySignalAtBar: boolean;
|
|
|
|
|
expectedSellSignalAtBar: boolean;
|
|
|
|
|
signalMatchesOverallRule: string;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type { StrategyEvaluationAiVerifyResponse };
|
|
|
|
|
|
|
|
|
|
export function buildStrategyEvaluationAiVerifyContext(opts: {
|
|
|
|
|
strategy: StrategyDto;
|
|
|
|
|
strategyId: number;
|
|
|
|
|
market: string;
|
|
|
|
|
timeframe: string;
|
|
|
|
|
bars: OHLCVBar[];
|
|
|
|
|
selectedBarIndex: number;
|
|
|
|
|
snapshot: VirtualIndicatorSnapshot | undefined;
|
|
|
|
|
evalLoading: boolean;
|
|
|
|
|
evalError: string | null;
|
|
|
|
|
chartSignals: BacktestSignal[];
|
|
|
|
|
barSignalHighlight: BarSignalHighlight;
|
|
|
|
|
indicatorParams?: Record<string, Record<string, unknown>> | null;
|
|
|
|
|
evaluationBarCount?: number;
|
|
|
|
|
}): StrategyEvaluationAiVerifyContext {
|
|
|
|
|
const {
|
|
|
|
|
strategy,
|
|
|
|
|
strategyId,
|
|
|
|
|
market,
|
|
|
|
|
timeframe,
|
|
|
|
|
bars,
|
|
|
|
|
selectedBarIndex,
|
|
|
|
|
snapshot,
|
|
|
|
|
evalLoading,
|
|
|
|
|
evalError,
|
|
|
|
|
chartSignals,
|
|
|
|
|
barSignalHighlight,
|
|
|
|
|
indicatorParams,
|
|
|
|
|
evaluationBarCount,
|
|
|
|
|
} = opts;
|
|
|
|
|
|
|
|
|
|
const selectedBar = bars[selectedBarIndex] ?? null;
|
|
|
|
|
const barTimeSec = selectedBar?.time ?? null;
|
|
|
|
|
|
|
|
|
|
const buyMetrics = snapshot?.rows
|
|
|
|
|
? buildConditionMetrics(snapshot.rows.filter(r => r.side === 'buy'))
|
|
|
|
|
: [];
|
|
|
|
|
const sellMetrics = snapshot?.rows
|
|
|
|
|
? buildConditionMetrics(snapshot.rows.filter(r => r.side === 'sell'))
|
|
|
|
|
: [];
|
|
|
|
|
|
|
|
|
|
const mapCondition = (m: typeof buyMetrics[number]) => ({
|
|
|
|
|
id: m.row.id,
|
|
|
|
|
label: formatVirtualConditionListLabel(m.row),
|
|
|
|
|
side: m.row.side,
|
|
|
|
|
timeframe: m.row.timeframe,
|
|
|
|
|
indicatorType: m.row.indicatorType,
|
|
|
|
|
conditionType: m.row.conditionType,
|
|
|
|
|
targetValue: m.row.targetValue,
|
|
|
|
|
currentValue: m.row.currentValue,
|
|
|
|
|
satisfied: m.row.satisfied ?? null,
|
|
|
|
|
thresholdLabel: m.row.thresholdLabel,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const selectedBarSignals = chartSignals.filter(s =>
|
|
|
|
|
signalMatchesBar(s, selectedBarIndex, barTimeSec ?? 0),
|
|
|
|
|
).map(s => ({
|
|
|
|
|
type: s.type,
|
|
|
|
|
time: s.time,
|
|
|
|
|
price: s.price,
|
|
|
|
|
barIndex: s.barIndex,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const nearbySignals = chartSignals
|
|
|
|
|
.map(s => ({
|
|
|
|
|
signal: s,
|
|
|
|
|
offset: s.barIndex - selectedBarIndex,
|
|
|
|
|
}))
|
|
|
|
|
.filter(x => Math.abs(x.offset) <= 5 && x.offset !== 0)
|
|
|
|
|
.sort((a, b) => a.offset - b.offset)
|
|
|
|
|
.slice(0, 12)
|
|
|
|
|
.map(x => ({
|
|
|
|
|
type: x.signal.type,
|
|
|
|
|
time: x.signal.time,
|
|
|
|
|
barIndex: x.signal.barIndex,
|
|
|
|
|
offsetFromSelected: x.offset,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const overallEntryMet = snapshot?.overallEntryMet ?? null;
|
|
|
|
|
const overallExitMet = snapshot?.overallExitMet ?? null;
|
|
|
|
|
|
|
|
|
|
const evalCount = evaluationBarCount ?? bars.length;
|
|
|
|
|
const barsForEvaluation = bars.slice(Math.max(0, bars.length - evalCount)).map(b => ({
|
|
|
|
|
time: b.time,
|
|
|
|
|
open: b.open,
|
|
|
|
|
high: b.high,
|
|
|
|
|
low: b.low,
|
|
|
|
|
close: b.close,
|
|
|
|
|
volume: b.volume,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
let signalMatchesOverallRule = '평가 데이터 없음';
|
|
|
|
|
if (overallEntryMet != null || overallExitMet != null) {
|
|
|
|
|
const parts: string[] = [];
|
|
|
|
|
if (overallEntryMet === true && !barSignalHighlight.buy) {
|
|
|
|
|
parts.push('매수 Rule 충족인데 선택 봉 BUY 시그널 없음 — 포지션 모드·중복 방지·스캔 범위 확인 필요');
|
|
|
|
|
}
|
|
|
|
|
if (overallEntryMet === false && barSignalHighlight.buy) {
|
|
|
|
|
parts.push('매수 Rule 미충족인데 선택 봉 BUY 시그널 있음 — 불일치 의심');
|
|
|
|
|
}
|
|
|
|
|
if (overallExitMet === true && !barSignalHighlight.sell) {
|
|
|
|
|
parts.push('매도 Rule 충족인데 선택 봉 SELL 시그널 없음 — 포지션·스캔 범위 확인 필요');
|
|
|
|
|
}
|
|
|
|
|
if (overallExitMet === false && barSignalHighlight.sell) {
|
|
|
|
|
parts.push('매도 Rule 미충족인데 선택 봉 SELL 시그널 있음 — 불일치 의심');
|
|
|
|
|
}
|
|
|
|
|
if (parts.length === 0) {
|
|
|
|
|
parts.push('전체 Rule과 선택 봉 시그널 표시가 대체로 일치');
|
|
|
|
|
}
|
|
|
|
|
signalMatchesOverallRule = parts.join('; ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
meta: {
|
|
|
|
|
generatedAt: new Date().toISOString(),
|
|
|
|
|
purpose: '전략평가 Ta4j Rule 결과와 차트 매매 시그널 일치 여부 검증',
|
|
|
|
|
},
|
|
|
|
|
strategy: {
|
|
|
|
|
id: strategyId,
|
|
|
|
|
name: repairUtf8Mojibake(strategy.name ?? `전략 #${strategyId}`),
|
|
|
|
|
buyCondition: strategy.buyCondition ?? null,
|
|
|
|
|
sellCondition: strategy.sellCondition ?? null,
|
|
|
|
|
},
|
|
|
|
|
chart: {
|
|
|
|
|
market,
|
|
|
|
|
timeframe,
|
|
|
|
|
barCount: bars.length,
|
|
|
|
|
evaluationBarCount,
|
|
|
|
|
barsForEvaluation,
|
|
|
|
|
selectedBarIndex,
|
|
|
|
|
selectedBar: selectedBar
|
|
|
|
|
? {
|
|
|
|
|
time: selectedBar.time,
|
|
|
|
|
timeLabel: new Date(selectedBar.time * 1000).toLocaleString('ko-KR'),
|
|
|
|
|
open: selectedBar.open,
|
|
|
|
|
high: selectedBar.high,
|
|
|
|
|
low: selectedBar.low,
|
|
|
|
|
close: selectedBar.close,
|
|
|
|
|
volume: selectedBar.volume,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
|
|
|
|
},
|
|
|
|
|
evaluation: {
|
|
|
|
|
loading: evalLoading,
|
|
|
|
|
error: evalError,
|
|
|
|
|
matchRate: snapshot?.matchRate ?? null,
|
|
|
|
|
overallEntryMet,
|
|
|
|
|
overallExitMet,
|
|
|
|
|
buyConditions: buyMetrics.map(mapCondition),
|
|
|
|
|
sellConditions: sellMetrics.map(mapCondition),
|
|
|
|
|
},
|
|
|
|
|
signals: {
|
|
|
|
|
totalOnChart: chartSignals.length,
|
|
|
|
|
selectedBarHighlight: barSignalHighlight,
|
|
|
|
|
selectedBarSignals,
|
|
|
|
|
nearbySignals,
|
|
|
|
|
},
|
|
|
|
|
indicatorParams: indicatorParams ?? null,
|
|
|
|
|
deterministicHints: {
|
|
|
|
|
expectedBuySignalAtBar: overallEntryMet === true,
|
|
|
|
|
expectedSellSignalAtBar: overallExitMet === true,
|
|
|
|
|
signalMatchesOverallRule,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function signalMatchesBar(
|
|
|
|
|
signal: BacktestSignal,
|
|
|
|
|
barIndex: number,
|
|
|
|
|
barTimeSec: number,
|
|
|
|
|
): boolean {
|
|
|
|
|
if (signal.barIndex === barIndex) return true;
|
|
|
|
|
const signalTime = normalizeEpochSecOptional(signal.time);
|
|
|
|
|
const barTime = normalizeEpochSecOptional(barTimeSec);
|
|
|
|
|
return signalTime > 0 && barTime > 0 && signalTime === barTime;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function requestStrategyEvaluationAiVerify(
|
|
|
|
|
context: StrategyEvaluationAiVerifyContext,
|
|
|
|
|
): Promise<StrategyEvaluationAiVerifyResponse> {
|
|
|
|
|
return fetchStrategyEvaluationAiVerify(context as unknown as Record<string, unknown>);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** UI 요약 — 컨텍스트 미리보기 */
|
|
|
|
|
export function summarizeVerifyContext(ctx: StrategyEvaluationAiVerifyContext): string {
|
|
|
|
|
const bar = ctx.chart.selectedBar;
|
|
|
|
|
const buyOk = ctx.evaluation.overallEntryMet;
|
|
|
|
|
const sellOk = ctx.evaluation.overallExitMet;
|
|
|
|
|
return [
|
|
|
|
|
bar ? `봉 ${bar.timeLabel}` : '봉 미선택',
|
|
|
|
|
buyOk != null ? `매수 ${buyOk ? '충족' : '미충족'}` : '매수 평가 없음',
|
|
|
|
|
sellOk != null ? `매도 ${sellOk ? '충족' : '미충족'}` : '매도 평가 없음',
|
|
|
|
|
`차트 시그널 ${ctx.signals.totalOnChart}건`,
|
|
|
|
|
ctx.signals.selectedBarSignals.length > 0
|
|
|
|
|
? `선택 봉 시그널 ${ctx.signals.selectedBarSignals.map(s => s.type).join(', ')}`
|
|
|
|
|
: '선택 봉 시그널 없음',
|
|
|
|
|
].join(' · ');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Cursor AI에 붙여넣을 수정 포인트 항목 */
|
|
|
|
|
export interface AiVerifyFixPoint {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
/** 현재 평가 상황 */
|
|
|
|
|
situation: string;
|
|
|
|
|
/** 설정 및 예상 결과 */
|
|
|
|
|
settingsAndExpected: string;
|
|
|
|
|
/** 현재 결과 */
|
|
|
|
|
actualResult: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AiVerifyFixPointsBundle {
|
|
|
|
|
hasIssue: boolean;
|
|
|
|
|
verdictHint: string | null;
|
|
|
|
|
points: AiVerifyFixPoint[];
|
|
|
|
|
cursorPrompt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatConditionLines(
|
|
|
|
|
conditions: StrategyEvaluationAiVerifyContext['evaluation']['buyConditions'],
|
|
|
|
|
): string {
|
|
|
|
|
if (conditions.length === 0) return '(조건 없음)';
|
|
|
|
|
return conditions.map(c => {
|
|
|
|
|
const status = c.satisfied === true ? '충족' : c.satisfied === false ? '미충족' : '평가불가';
|
|
|
|
|
const cur = c.currentValue != null ? String(c.currentValue) : '-';
|
|
|
|
|
const target = c.thresholdLabel ?? (c.targetValue != null ? String(c.targetValue) : '-');
|
|
|
|
|
return `- [${c.id}] ${c.label} (${c.timeframe}/${c.indicatorType}): ${status} · 현재=${cur} · 기준=${target}`;
|
|
|
|
|
}).join('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDslSummary(condition: unknown): string {
|
|
|
|
|
if (condition == null) return '(DSL 없음)';
|
|
|
|
|
try {
|
|
|
|
|
const s = JSON.stringify(condition, null, 2);
|
|
|
|
|
return s.length > 1200 ? `${s.slice(0, 1200)}\n…(생략)` : s;
|
|
|
|
|
} catch {
|
|
|
|
|
return String(condition);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatIndicatorParamsSummary(
|
|
|
|
|
params: Record<string, Record<string, unknown>> | null | undefined,
|
|
|
|
|
): string {
|
|
|
|
|
if (!params || Object.keys(params).length === 0) return '(지표 파라미터 없음)';
|
|
|
|
|
try {
|
|
|
|
|
const s = JSON.stringify(params, null, 2);
|
|
|
|
|
return s.length > 800 ? `${s.slice(0, 800)}\n…(생략)` : s;
|
|
|
|
|
} catch {
|
|
|
|
|
return '(직렬화 실패)';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function chartSituationSummary(ctx: StrategyEvaluationAiVerifyContext): string {
|
|
|
|
|
const bar = ctx.chart.selectedBar;
|
|
|
|
|
const lines = [
|
|
|
|
|
`전략: ${ctx.strategy.name} (#${ctx.strategy.id})`,
|
|
|
|
|
`종목/타임프레임: ${ctx.chart.market} / ${ctx.chart.timeframe}`,
|
|
|
|
|
bar
|
|
|
|
|
? `선택 봉: index=${ctx.chart.selectedBarIndex}, ${bar.timeLabel}, O=${bar.open} H=${bar.high} L=${bar.low} C=${bar.close}`
|
|
|
|
|
: '선택 봉: 없음',
|
|
|
|
|
`평가 범위: 차트 ${ctx.chart.barCount}봉` + (ctx.chart.evaluationBarCount != null
|
|
|
|
|
? ` · Rule 평가 ${ctx.chart.evaluationBarCount}봉`
|
|
|
|
|
: ''),
|
|
|
|
|
ctx.evaluation.matchRate != null ? `조건 일치율: ${ctx.evaluation.matchRate}%` : null,
|
|
|
|
|
ctx.evaluation.error ? `평가 오류: ${ctx.evaluation.error}` : null,
|
|
|
|
|
].filter(Boolean);
|
|
|
|
|
return lines.join('\n');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** LLM 종합 판정에서 로직 문제 여부 추정 */
|
|
|
|
|
export function detectLogicIssueFromAnalysis(analysis: string): boolean {
|
|
|
|
|
const text = analysis.replace(/\r/g, '');
|
|
|
|
|
const verdict = text.match(/##\s*종합\s*판정\s*\n([\s\S]*?)(?=\n##|$)/i);
|
|
|
|
|
if (verdict) {
|
|
|
|
|
const body = verdict[1].trim();
|
|
|
|
|
const head = body.split('\n').slice(0, 4).join(' ');
|
|
|
|
|
if (/오류\s*의심|주의|불일치|로직\s*문제|의심/.test(head)) return true;
|
|
|
|
|
if (/정상/.test(head) && !/주의|오류/.test(head)) return false;
|
|
|
|
|
}
|
|
|
|
|
return /\b(오류\s*의심|불일치|로직\s*문제)\b/.test(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractVerdictHint(analysis: string | null | undefined): string | null {
|
|
|
|
|
if (!analysis) return null;
|
|
|
|
|
const m = analysis.replace(/\r/g, '').match(/##\s*종합\s*판정\s*\n([\s\S]*?)(?=\n##|$)/i);
|
|
|
|
|
if (!m) return null;
|
|
|
|
|
const line = m[1].trim().split('\n').find(l => l.trim())?.trim();
|
|
|
|
|
return line ?? null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushBuySignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
|
|
|
|
const { overallEntryMet, buyConditions } = ctx.evaluation;
|
|
|
|
|
const buyHighlight = ctx.signals.selectedBarHighlight.buy;
|
|
|
|
|
if (overallEntryMet !== true || buyHighlight) return;
|
|
|
|
|
|
|
|
|
|
points.push({
|
|
|
|
|
id: 'buy-rule-no-signal',
|
|
|
|
|
title: '매수 Rule 충족 ↔ 차트 BUY 시그널 없음',
|
|
|
|
|
situation: [
|
|
|
|
|
chartSituationSummary(ctx),
|
|
|
|
|
'',
|
|
|
|
|
'【매수 조건 평가】',
|
|
|
|
|
`overallEntryMet: true (전체 매수 Rule 충족)`,
|
|
|
|
|
formatConditionLines(buyConditions),
|
|
|
|
|
'',
|
|
|
|
|
'【선택 봉 차트 시그널】',
|
|
|
|
|
`BUY: 없음 · SELL: ${ctx.signals.selectedBarHighlight.sell ? '있음' : '없음'}`,
|
|
|
|
|
].join('\n'),
|
|
|
|
|
settingsAndExpected: [
|
|
|
|
|
'전략 매수 DSL:',
|
|
|
|
|
formatDslSummary(ctx.strategy.buyCondition),
|
|
|
|
|
'',
|
|
|
|
|
'지표 파라미터:',
|
|
|
|
|
formatIndicatorParamsSummary(ctx.indicatorParams),
|
|
|
|
|
'',
|
|
|
|
|
'예상 결과:',
|
|
|
|
|
'- Ta4j overallEntryMet=true 이면 해당 봉(또는 Rule 실행 시점)에 BUY 시그널이 차트에 표시되어야 함',
|
|
|
|
|
'- 포지션 모드(LONG_ONLY 등)·이미 보유·중복 방지·스캔 범위(evaluationBarCount)에 따라 시그널이 생략될 수 있음',
|
|
|
|
|
`- deterministicHints.expectedBuySignalAtBar: ${ctx.deterministicHints.expectedBuySignalAtBar}`,
|
|
|
|
|
].join('\n'),
|
|
|
|
|
actualResult: [
|
|
|
|
|
`선택 봉 BUY 시그널: 없음`,
|
|
|
|
|
`overallEntryMet: true`,
|
|
|
|
|
`힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
|
|
|
|
ctx.signals.nearbySignals.length > 0
|
|
|
|
|
? `인접 봉 시그널: ${ctx.signals.nearbySignals.map(s => `${s.type}@${s.offsetFromSelected > 0 ? '+' : ''}${s.offsetFromSelected}`).join(', ')}`
|
|
|
|
|
: '인접 봉(±5) 시그널: 없음',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushSellSignalMismatch(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
|
|
|
|
const { overallExitMet, sellConditions } = ctx.evaluation;
|
|
|
|
|
const sellHighlight = ctx.signals.selectedBarHighlight.sell;
|
|
|
|
|
if (overallExitMet !== true || sellHighlight) return;
|
|
|
|
|
|
|
|
|
|
points.push({
|
|
|
|
|
id: 'sell-rule-no-signal',
|
|
|
|
|
title: '매도 Rule 충족 ↔ 차트 SELL 시그널 없음',
|
|
|
|
|
situation: [
|
|
|
|
|
chartSituationSummary(ctx),
|
|
|
|
|
'',
|
|
|
|
|
'【매도 조건 평가】',
|
|
|
|
|
`overallExitMet: true (전체 매도 Rule 충족)`,
|
|
|
|
|
formatConditionLines(sellConditions),
|
|
|
|
|
'',
|
|
|
|
|
'【선택 봉 차트 시그널】',
|
|
|
|
|
`BUY: ${ctx.signals.selectedBarHighlight.buy ? '있음' : '없음'} · SELL: 없음`,
|
|
|
|
|
].join('\n'),
|
|
|
|
|
settingsAndExpected: [
|
|
|
|
|
'전략 매도 DSL:',
|
|
|
|
|
formatDslSummary(ctx.strategy.sellCondition),
|
|
|
|
|
'',
|
|
|
|
|
'지표 파라미터:',
|
|
|
|
|
formatIndicatorParamsSummary(ctx.indicatorParams),
|
|
|
|
|
'',
|
|
|
|
|
'예상 결과:',
|
|
|
|
|
'- Ta4j overallExitMet=true 이면 해당 봉에 SELL 시그널이 차트에 표시되어야 함',
|
|
|
|
|
'- 미보유·포지션 모드·스캔 범위에 따라 시그널이 생략될 수 있음',
|
|
|
|
|
`- deterministicHints.expectedSellSignalAtBar: ${ctx.deterministicHints.expectedSellSignalAtBar}`,
|
|
|
|
|
].join('\n'),
|
|
|
|
|
actualResult: [
|
|
|
|
|
'선택 봉 SELL 시그널: 없음',
|
|
|
|
|
'overallExitMet: true',
|
|
|
|
|
`힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
|
|
|
|
].join('\n'),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushUnexpectedBuySignal(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
|
|
|
|
const { overallEntryMet, buyConditions } = ctx.evaluation;
|
|
|
|
|
if (overallEntryMet !== false || !ctx.signals.selectedBarHighlight.buy) return;
|
|
|
|
|
|
|
|
|
|
points.push({
|
|
|
|
|
id: 'buy-signal-no-rule',
|
|
|
|
|
title: '매수 Rule 미충족 ↔ 차트 BUY 시그널 있음',
|
|
|
|
|
situation: [
|
|
|
|
|
chartSituationSummary(ctx),
|
|
|
|
|
'',
|
|
|
|
|
'【매수 조건 평가】',
|
|
|
|
|
'overallEntryMet: false',
|
|
|
|
|
formatConditionLines(buyConditions),
|
|
|
|
|
'',
|
|
|
|
|
'【선택 봉 시그널】',
|
|
|
|
|
ctx.signals.selectedBarSignals.filter(s => s.type === 'BUY').map(s =>
|
|
|
|
|
`BUY @ barIndex=${s.barIndex}, price=${s.price}, time=${s.time}`,
|
|
|
|
|
).join('\n') || 'BUY 시그널 있음',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
settingsAndExpected: [
|
|
|
|
|
'전략 매수 DSL:',
|
|
|
|
|
formatDslSummary(ctx.strategy.buyCondition),
|
|
|
|
|
'',
|
|
|
|
|
'예상 결과: overallEntryMet=false 이면 선택 봉에 BUY 시그널이 없어야 함',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
actualResult: [
|
|
|
|
|
'overallEntryMet: false',
|
|
|
|
|
'선택 봉 BUY 시그널: 있음 → Rule 평가와 백테스트 시그널 생성 로직 불일치 의심',
|
|
|
|
|
`힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
|
|
|
|
].join('\n'),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushUnexpectedSellSignal(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
|
|
|
|
const { overallExitMet, sellConditions } = ctx.evaluation;
|
|
|
|
|
if (overallExitMet !== false || !ctx.signals.selectedBarHighlight.sell) return;
|
|
|
|
|
|
|
|
|
|
points.push({
|
|
|
|
|
id: 'sell-signal-no-rule',
|
|
|
|
|
title: '매도 Rule 미충족 ↔ 차트 SELL 시그널 있음',
|
|
|
|
|
situation: [
|
|
|
|
|
chartSituationSummary(ctx),
|
|
|
|
|
'',
|
|
|
|
|
'【매도 조건 평가】',
|
|
|
|
|
'overallExitMet: false',
|
|
|
|
|
formatConditionLines(sellConditions),
|
|
|
|
|
].join('\n'),
|
|
|
|
|
settingsAndExpected: [
|
|
|
|
|
'전략 매도 DSL:',
|
|
|
|
|
formatDslSummary(ctx.strategy.sellCondition),
|
|
|
|
|
'',
|
|
|
|
|
'예상 결과: overallExitMet=false 이면 선택 봉에 SELL 시그널이 없어야 함',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
actualResult: [
|
|
|
|
|
'overallExitMet: false',
|
|
|
|
|
'선택 봉 SELL 시그널: 있음 → Rule 평가와 시그널 생성 불일치 의심',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushFailedConditions(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
|
|
|
|
const failed = [
|
|
|
|
|
...ctx.evaluation.buyConditions.filter(c => c.satisfied === false),
|
|
|
|
|
...ctx.evaluation.sellConditions.filter(c => c.satisfied === false),
|
|
|
|
|
];
|
|
|
|
|
if (failed.length === 0) return;
|
|
|
|
|
|
|
|
|
|
const entryMet = ctx.evaluation.overallEntryMet;
|
|
|
|
|
const exitMet = ctx.evaluation.overallExitMet;
|
|
|
|
|
if (entryMet !== true && exitMet !== true) return;
|
|
|
|
|
|
|
|
|
|
points.push({
|
|
|
|
|
id: 'partial-condition-fail',
|
|
|
|
|
title: '개별 조건 미충족 vs 전체 Rule 충족',
|
|
|
|
|
situation: [
|
|
|
|
|
chartSituationSummary(ctx),
|
|
|
|
|
'',
|
|
|
|
|
`overallEntryMet: ${entryMet} · overallExitMet: ${exitMet}`,
|
|
|
|
|
'',
|
|
|
|
|
'【미충족 개별 조건】',
|
|
|
|
|
formatConditionLines(failed),
|
|
|
|
|
].join('\n'),
|
|
|
|
|
settingsAndExpected: [
|
|
|
|
|
'전략 DSL (매수/매도):',
|
|
|
|
|
formatDslSummary({ buy: ctx.strategy.buyCondition, sell: ctx.strategy.sellCondition }),
|
|
|
|
|
'',
|
|
|
|
|
'예상 결과: AND/OR 그룹 논리에 따라 개별 조건과 overallEntryMet/overallExitMet가 일치해야 함',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
actualResult: [
|
|
|
|
|
'일부 leaf 조건은 satisfied=false 이지만 전체 Rule은 충족으로 표시됨',
|
|
|
|
|
'→ DSL 그룹(AND/OR/NOT) 해석, Ta4j Rule 빌더, 조건 ID 매핑을 확인 필요',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushUnevaluatedConditions(ctx: StrategyEvaluationAiVerifyContext, points: AiVerifyFixPoint[]) {
|
|
|
|
|
const unknown = [
|
|
|
|
|
...ctx.evaluation.buyConditions.filter(c => c.satisfied == null),
|
|
|
|
|
...ctx.evaluation.sellConditions.filter(c => c.satisfied == null),
|
|
|
|
|
];
|
|
|
|
|
if (unknown.length === 0) return;
|
|
|
|
|
|
|
|
|
|
points.push({
|
|
|
|
|
id: 'condition-unevaluated',
|
|
|
|
|
title: '조건 평가 불가 (지표·데이터 누락)',
|
|
|
|
|
situation: [
|
|
|
|
|
chartSituationSummary(ctx),
|
|
|
|
|
'',
|
|
|
|
|
'【평가 불가 조건】',
|
|
|
|
|
formatConditionLines(unknown),
|
|
|
|
|
].join('\n'),
|
|
|
|
|
settingsAndExpected: [
|
|
|
|
|
'지표 파라미터:',
|
|
|
|
|
formatIndicatorParamsSummary(ctx.indicatorParams),
|
|
|
|
|
'',
|
|
|
|
|
'예상 결과: 모든 leaf 조건에 currentValue·satisfied가 채워져야 Rule 판정 가능',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
actualResult: [
|
|
|
|
|
`${unknown.length}개 조건 satisfied=null — 지표 스냅샷·봉 데이터·타임프레임 정렬 문제 가능`,
|
|
|
|
|
ctx.evaluation.error ? `평가 API 오류: ${ctx.evaluation.error}` : '',
|
|
|
|
|
].filter(Boolean).join('\n'),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildCursorFixPrompt(
|
|
|
|
|
ctx: StrategyEvaluationAiVerifyContext,
|
|
|
|
|
points: AiVerifyFixPoint[],
|
|
|
|
|
verdictHint: string | null,
|
|
|
|
|
): string {
|
|
|
|
|
const header = [
|
|
|
|
|
'# GoldenChart 전략평가 — 로직 불일치 수정 요청',
|
|
|
|
|
'',
|
|
|
|
|
'아래 전략평가(Ta4j Rule vs 차트 시그널) 불일치를 분석하고, 코드 수정 포인트와 해결 방안을 제안해 주세요.',
|
|
|
|
|
'',
|
|
|
|
|
verdictHint ? `## AI 종합 판정\n${verdictHint}\n` : '',
|
|
|
|
|
'## 공통 컨텍스트',
|
|
|
|
|
chartSituationSummary(ctx),
|
|
|
|
|
'',
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
const body = points.map((p, i) => [
|
|
|
|
|
`## 수정 포인트 ${i + 1}: ${p.title}`,
|
|
|
|
|
'',
|
|
|
|
|
'### 현재 평가 상황',
|
|
|
|
|
p.situation,
|
|
|
|
|
'',
|
|
|
|
|
'### 설정 및 예상 결과',
|
|
|
|
|
p.settingsAndExpected,
|
|
|
|
|
'',
|
|
|
|
|
'### 현재 결과',
|
|
|
|
|
p.actualResult,
|
|
|
|
|
'',
|
|
|
|
|
].join('\n')).join('\n');
|
|
|
|
|
|
|
|
|
|
const footer = [
|
|
|
|
|
'## 요청',
|
|
|
|
|
'- 불일치 원인을 frontend/backend/Ta4j Rule 빌더 관점에서 추적',
|
|
|
|
|
'- 수정해야 할 파일·함수와 구체적 변경안 제시',
|
|
|
|
|
'- 재현 방법(종목, 타임프레임, 선택 봉 index) 포함',
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
|
|
|
|
return `${header}${body}${footer}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 로직 문제 시 Cursor AI용 수정 포인트 번들 생성 */
|
|
|
|
|
export function buildAiVerifyFixPoints(
|
|
|
|
|
ctx: StrategyEvaluationAiVerifyContext,
|
|
|
|
|
analysis?: string | null,
|
|
|
|
|
): AiVerifyFixPointsBundle | null {
|
|
|
|
|
const points: AiVerifyFixPoint[] = [];
|
|
|
|
|
pushBuySignalMismatch(ctx, points);
|
|
|
|
|
pushUnexpectedBuySignal(ctx, points);
|
|
|
|
|
pushSellSignalMismatch(ctx, points);
|
|
|
|
|
pushUnexpectedSellSignal(ctx, points);
|
|
|
|
|
pushFailedConditions(ctx, points);
|
|
|
|
|
pushUnevaluatedConditions(ctx, points);
|
|
|
|
|
|
|
|
|
|
const verdictHint = extractVerdictHint(analysis);
|
|
|
|
|
const issueFromAnalysis = analysis ? detectLogicIssueFromAnalysis(analysis) : false;
|
|
|
|
|
const issueFromHints = ctx.deterministicHints.signalMatchesOverallRule
|
|
|
|
|
!== '전체 Rule과 선택 봉 시그널 표시가 대체로 일치';
|
|
|
|
|
|
|
|
|
|
if (points.length === 0 && issueFromAnalysis) {
|
|
|
|
|
points.push({
|
|
|
|
|
id: 'llm-verdict-only',
|
|
|
|
|
title: 'AI가 로직 문제로 판단',
|
|
|
|
|
situation: [
|
|
|
|
|
chartSituationSummary(ctx),
|
|
|
|
|
'',
|
|
|
|
|
'【조건 요약】',
|
|
|
|
|
`매수 overallEntryMet: ${ctx.evaluation.overallEntryMet}`,
|
|
|
|
|
formatConditionLines(ctx.evaluation.buyConditions),
|
|
|
|
|
'',
|
|
|
|
|
`매도 overallExitMet: ${ctx.evaluation.overallExitMet}`,
|
|
|
|
|
formatConditionLines(ctx.evaluation.sellConditions),
|
|
|
|
|
'',
|
|
|
|
|
`시그널 힌트: ${ctx.deterministicHints.signalMatchesOverallRule}`,
|
|
|
|
|
].join('\n'),
|
|
|
|
|
settingsAndExpected: [
|
|
|
|
|
'전략 DSL:',
|
|
|
|
|
formatDslSummary({ buy: ctx.strategy.buyCondition, sell: ctx.strategy.sellCondition }),
|
|
|
|
|
'',
|
|
|
|
|
'예상: Ta4j Rule 결과와 차트 시그널·개별 조건 satisfied가 논리적으로 일치',
|
|
|
|
|
].join('\n'),
|
|
|
|
|
actualResult: verdictHint
|
|
|
|
|
? `AI 종합 판정: ${verdictHint}`
|
|
|
|
|
: (analysis?.slice(0, 400) ?? '분석 텍스트 없음'),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasDeterministicIssue = points.some(p => p.id !== 'llm-verdict-only');
|
|
|
|
|
const shouldShow = issueFromAnalysis || issueFromHints || hasDeterministicIssue;
|
|
|
|
|
if (!shouldShow || points.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
hasIssue: true,
|
|
|
|
|
verdictHint,
|
|
|
|
|
points,
|
|
|
|
|
cursorPrompt: buildCursorFixPrompt(ctx, points, verdictHint),
|
|
|
|
|
};
|
|
|
|
|
}
|