/** * 전략 조건 DSL → 알기 쉬운 한국어 서술형 설명 */ import type { CandleRangeMode, LogicNode } from './strategyTypes'; import { CONDITION_LABEL } from './strategyTypes'; import { resolveCandleRangeMode, candleRangeWindowBars, formatCandleRangeClause, } from './strategyTypes'; import { collectEditorBranches, normalizeStartCombineOp, type EditorConditionState, } from './strategyConditionSerde'; import { normalizeCompositeCondition } from './compositeIndicators'; import { getFieldOpts, resolveFieldOptionValue, type DefType, } from './strategyEditorShared'; import { getCompositeFieldOpts, getCompositeLeftCandleType, getCompositeRightCandleType, canonicalizeConditionThreshold, parseThresholdField, } from './conditionPeriods'; import { formatStrategyCandleLabel } from './strategyStartNodes'; import { formatStartCandleLabel } from './strategyStartNodes'; export interface StrategyDescriptionInput { name?: string; description?: string; buyEditorState: EditorConditionState; sellEditorState: EditorConditionState; buyCondition: LogicNode | null; sellCondition: LogicNode | null; orphanCount?: number; def: DefType; } export interface StrategyNarrativeSection { title: string; paragraphs: string[]; bullets?: string[]; } export interface StrategyNarrative { intro: string[]; sections: StrategyNarrativeSection[]; footnotes: string[]; } const CANDLE_KO: Record = { '1m': '1분봉', '3m': '3분봉', '5m': '5분봉', '10m': '10분봉', '15m': '15분봉', '30m': '30분봉', '1h': '1시간봉', '4h': '4시간봉', '1d': '일봉', }; function candleKo(ct: string): string { const key = formatStartCandleLabel(ct); return CANDLE_KO[key] ?? `${key} 봉`; } function fieldLabel( indicatorType: string, field: string | undefined, def: DefType, signalType: 'buy' | 'sell', cond?: ReturnType, slot?: 1 | 2, ): string { if (cond?.composite && field && slot) { const compositeOpts = getCompositeFieldOpts(indicatorType, slot, def, cond); const hit = compositeOpts.find(o => o.value === field); if (hit) return hit.label; } const opts = getFieldOpts(indicatorType, signalType, def, cond); const resolved = resolveFieldOptionValue(indicatorType, field); const hit = opts.find(o => o.value === resolved); if (hit && hit.label !== '선택안함') return hit.label; const thresh = field ? parseThresholdField(field) : null; if (thresh != null) return `임계값 ${thresh}`; return field ?? indicatorType; } function describeConditionType( conditionType: string, left: string, right: string, extras: { compareValue?: number; slopePeriod?: number; holdDays?: number; lookbackPeriod?: number; candleRangeMode?: CandleRangeMode; candleRange?: number; }, ): string { const cv = extras.compareValue; const sp = (extras.candleRangeMode === 'EXISTS_IN' || extras.candleRangeMode === 'NOT_EXISTS_IN') ? Math.max(2, extras.candleRange ?? 4) : (extras.slopePeriod ?? 3); const hd = extras.holdDays ?? 3; const lb = extras.lookbackPeriod ?? 20; switch (conditionType) { case 'GT': return `${left}이(가) ${right}보다 큰 경우`; case 'LT': return `${left}이(가) ${right}보다 작은 경우`; case 'GTE': return `${left}이(가) ${right} 이상인 경우`; case 'LTE': return `${left}이(가) ${right} 이하인 경우`; case 'EQ': return `${left}과(와) ${right}이(가) 같은 경우`; case 'NEQ': return `${left}과(와) ${right}이(가) 다른 경우`; case 'CROSS_UP': return `${left}이(가) ${right}을(를) 상향 돌파하는 경우`; case 'CROSS_DOWN': return `${left}이(가) ${right}을(를) 하향 돌파하는 경우`; case 'SLOPE_UP': return `${left}이(가) 최근 ${sp}봉 동안 상승 추세인 경우`; case 'SLOPE_DOWN': return `${left}이(가) 최근 ${sp}봉 동안 하락 추세인 경우`; case 'DIFF_GT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 큰 경우`; case 'DIFF_LT': return `${left}과(와) ${right}의 차이가 ${cv ?? 0}보다 작은 경우`; case 'HOLD_N_DAYS': return `조건이 ${hd}봉 연속 유지되는 경우`; case 'LOWEST_LTE': return `최근 ${lb}봉 동안 ${left}의 최저값이 ${right} 이하인 경우`; case 'LOWEST_GTE': return `최근 ${lb}봉 동안 ${left}의 최저값이 ${right} 이상인 경우`; case 'ABOVE_CLOUD': return '가격이 일목균형표 구름대 위에 있는 경우'; case 'BELOW_CLOUD': return '가격이 일목균형표 구름대 아래에 있는 경우'; case 'IN_CLOUD': return '가격이 일목균형표 구름 안에 있는 경우'; case 'CLOUD_BREAK_UP': return '가격이 구름대를 상향 돌파하는 경우'; case 'CLOUD_BREAK_DOWN': return '가격이 구름대를 하향 돌파하는 경우'; case 'SPAN1_GT_SPAN2': return '선행스팬1이 선행스팬2보다 위에 있는 경우'; case 'SPAN1_LT_SPAN2': return '선행스팬1이 선행스팬2보다 아래에 있는 경우'; case 'SPAN1_CROSS_UP_SPAN2': return '선행스팬1이 선행스팬2를 상향 돌파하는 경우'; case 'SPAN1_CROSS_DOWN_SPAN2': return '선행스팬1이 선행스팬2를 하향 돌파하는 경우'; case 'LAGGING_GT_PRICE': return '후행스팬(현재 종가)이 26봉 전 종가보다 위에 있는 경우'; case 'LAGGING_LT_PRICE': return '후행스팬(현재 종가)이 26봉 전 종가보다 아래에 있는 경우'; case 'LAGGING_ABOVE_PRIOR_CLOUD': return '후행스팬이 26봉 전 구름대(선행스팬)보다 위에 있는 경우'; case 'CHIKOU_CROSS_ABOVE_PRIOR_CLOUD': return '후행스팬이 26봉 전 구름 상단을 상향 돌파하는 순간'; case 'VOLUME_GT_MA_RATIO': return '거래량이 거래량 이동평균 대비 설정 비율 이상인 경우'; default: { const label = CONDITION_LABEL[conditionType] ?? conditionType; if (right && right !== '선택안함') return `${left} — ${label} — ${right}`; return `${left} — ${label}`; } } } function describeCondition( raw: ReturnType, signalType: 'buy' | 'sell', def: DefType, ): string { const cond = canonicalizeConditionThreshold(raw); const ind = cond.indicatorType; const ct = cond.conditionType; if (cond.composite && cond.leftPeriod && cond.rightPeriod) { const left = fieldLabel(ind, cond.leftField, def, signalType, cond, 1); const right = fieldLabel(ind, cond.rightField, def, signalType, cond, 2); const lCt = formatStrategyCandleLabel(getCompositeLeftCandleType(cond)); const rCt = formatStrategyCandleLabel(getCompositeRightCandleType(cond)); const tfNote = lCt !== rCt ? ` (${lCt} 봉의 ${left}과(와) ${rCt} 봉의 ${right} 비교)` : ` (${lCt} 봉)`; const base = describeConditionType(ct, left, right, { compareValue: cond.compareValue, slopePeriod: cond.slopePeriod, holdDays: cond.holdDays, candleRangeMode: cond.candleRangeMode, candleRange: cond.candleRange, }); return base + tfNote; } const left = fieldLabel(ind, cond.leftField, def, signalType, cond); const right = fieldLabel(ind, cond.rightField, def, signalType, cond); if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'IN_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN'].includes(ct)) { return describeConditionType(ct, left, right, cond); } if (right && right !== '선택안함') { const prefix = formatCandleRangeClause(cond); return prefix + describeConditionType(ct, left, right, cond); } if (['SLOPE_UP', 'SLOPE_DOWN'].includes(ct)) { const prefix = formatCandleRangeClause(cond); return prefix + describeConditionType(ct, left, right, cond); } const label = CONDITION_LABEL[ct] ?? ct; return `${left}에 대해 「${label}」 조건이 성립하는 경우`; } function describeNode( node: LogicNode, signalType: 'buy' | 'sell', def: DefType, depth = 0, ): string[] { if (node.type === 'CONDITION' && node.condition) { const c = normalizeCompositeCondition(node.condition); const line = describeCondition(c, signalType, def); return [depth > 0 ? `• ${line}` : line]; } if (node.type === 'TIMEFRAME') { const inner = node.children?.[0]; const types = node.candleTypes?.length ? node.candleTypes : [node.candleType ?? '1m']; const tf = types.length > 1 ? types.map(candleKo).join(', ') : candleKo(types[0]); if (!inner) return [`[${tf}] 연결된 조건이 없습니다.`]; const innerLines = describeNode(inner, signalType, def, depth + 1); return [`[${tf} 기준]`, ...innerLines.map(l => (l.startsWith('•') ? ` ${l}` : ` • ${l}`))]; } if (node.type === 'AND') { const children = node.children ?? []; if (children.length === 0) return ['(비어 있는 AND 그룹)']; const lines: string[] = []; if (depth === 0 && children.length > 1) { lines.push('다음 조건을 모두 동시에 만족해야 합니다.'); } children.forEach(child => { lines.push(...describeNode(child, signalType, def, depth + 1)); }); return lines; } if (node.type === 'OR') { const children = node.children ?? []; if (children.length === 0) return ['(비어 있는 OR 그룹)']; const lines: string[] = []; if (depth === 0 && children.length > 1) { lines.push('다음 조건 중 하나라도 만족하면 됩니다.'); } children.forEach(child => { lines.push(...describeNode(child, signalType, def, depth + 1)); }); return lines; } if (node.type === 'NOT') { const child = node.children?.[0]; if (!child) return ['(비어 있는 NOT 그룹)']; const sub = describeNode(child, signalType, def, depth + 1); return ['다음 조건이 성립하지 않을 때:', ...sub.map(l => ` (부정) ${l.replace(/^•\s*/, '')}`)]; } return []; } function describeSignalBranches( editorState: EditorConditionState | undefined, fallbackRoot: LogicNode | null, signalType: 'buy' | 'sell', def: DefType, ): { hasContent: boolean; paragraphs: string[]; bullets: string[] } { const branches = editorState ? collectEditorBranches(editorState) : fallbackRoot ? [{ candleType: '1m', root: fallbackRoot }] : []; const active = branches.filter(b => b.root); if (active.length === 0) { return { hasContent: false, paragraphs: [], bullets: [] }; } const bullets: string[] = []; const paragraphs: string[] = []; if (active.length === 1) { const { candleType, candleTypes, root } = active[0]; const tf = candleTypes && candleTypes.length > 1 ? candleTypes.map(candleKo).join(', ') : candleKo(candleType); paragraphs.push( candleTypes && candleTypes.length > 1 ? `${tf} 각 시간봉 마감 시점마다 아래 조건을 독립적으로 평가합니다.` : `${tf} 차트를 기준으로 아래 조건을 평가합니다.`, ); bullets.push(...describeNode(root!, signalType, def)); return { hasContent: true, paragraphs, bullets }; } const combineOp = normalizeStartCombineOp(editorState?.startCombineOp); paragraphs.push( combineOp === 'AND' ? '여러 시간봉에 걸친 조건을 모두 충족해야 합니다.' : '여러 시간봉 중 하나의 조건만 충족해도 됩니다.', ); active.forEach((branch, idx) => { const tf = candleKo(branch.candleType); bullets.push(`[시간봉 ${idx + 1}: ${tf}]`); if (branch.root) { bullets.push(...describeNode(branch.root, signalType, def, 1).map(l => l.startsWith('•') ? ` ${l}` : ` • ${l}`, )); } }); return { hasContent: true, paragraphs, bullets }; } function hasSignalContent( editorState: EditorConditionState, fallback: LogicNode | null, ): boolean { if (collectEditorBranches(editorState).some(b => b.root)) return true; return !!fallback; } export function buildStrategyNarrative(input: StrategyDescriptionInput): StrategyNarrative { const { name, description, buyEditorState, sellEditorState, buyCondition, sellCondition, orphanCount = 0, def, } = input; const intro: string[] = []; if (name?.trim()) { intro.push(`「${name.trim()}」 전략의 매수·매도 조건을 사람이 읽기 쉬운 문장으로 풀어 쓴 설명입니다.`); } else { intro.push('현재 편집 중인 전략의 매수·매도 조건을 설명합니다.'); } if (description?.trim()) { intro.push(`메모: ${description.trim()}`); } const sections: StrategyNarrativeSection[] = []; const footnotes: string[] = []; const buy = describeSignalBranches(buyEditorState, buyCondition, 'buy', def); if (hasSignalContent(buyEditorState, buyCondition)) { sections.push({ title: '매수 조건 (진입)', paragraphs: [ '실시간 체크 또는 백테스트에서 아래 매수 조건이 충족되면 매수 신호가 발생합니다.', ...buy.paragraphs, ], bullets: buy.bullets.length > 0 ? buy.bullets : undefined, }); } else { sections.push({ title: '매수 조건 (진입)', paragraphs: ['매수 조건이 아직 설정되지 않았습니다. 매수 탭에서 지표·논리 블록을 연결해 주세요.'], }); } const sell = describeSignalBranches(sellEditorState, sellCondition, 'sell', def); if (hasSignalContent(sellEditorState, sellCondition)) { sections.push({ title: '매도 조건 (청산)', paragraphs: [ '포지션을 보유한 상태에서 아래 매도 조건이 충족되면 매도 신호가 발생합니다. (시그널 모드에 따라 포지션 없이도 표시될 수 있습니다.)', ...sell.paragraphs, ], bullets: sell.bullets.length > 0 ? sell.bullets : undefined, }); } else { sections.push({ title: '매도 조건 (청산)', paragraphs: ['매도 조건이 아직 설정되지 않았습니다. 매도 탭에서 조건을 구성해 주세요.'], }); } if (orphanCount > 0) { footnotes.push( `캔버스에 연결되지 않은 요소 ${orphanCount}개는 실제 매매 판정에 포함되지 않습니다.`, ); } footnotes.push( '각 START 노드에 지정한 시간봉이 실시간 전략 체크·백테스트의 평가 주기가 됩니다.', ); footnotes.push( '지표 기간·임계값은 전략 빌더 우측 「전략 조건 전용 설정」에 표시된 값을 기준으로 합니다.', ); return { intro, sections, footnotes }; }