diff --git a/frontend/src/utils/strategyDescriptionNarrative.ts b/frontend/src/utils/strategyDescriptionNarrative.ts index b1ae836..13c0981 100644 --- a/frontend/src/utils/strategyDescriptionNarrative.ts +++ b/frontend/src/utils/strategyDescriptionNarrative.ts @@ -215,16 +215,97 @@ function describeCondition( return `${left}에 대해 「${label}」 조건이 성립하는 경우`; } -function describeNode( +function describeConditionNode( node: LogicNode, signalType: 'buy' | 'sell', def: DefType, - depth = 0, +): string { + if (node.type !== 'CONDITION' || !node.condition) return ''; + return describeCondition(normalizeCompositeCondition(node.condition), signalType, def); +} + +/** AND 그룹 — 자식이 모두 단일 조건이면 묶음으로, 아니면 하위 트리 재귀 */ +function describeAndGroup( + children: LogicNode[], + signalType: 'buy' | 'sell', + def: DefType, + indent: string, + options?: { showCountHeader?: boolean }, +): string[] { + if (children.length === 0) return [`${indent}(비어 있는 AND 그룹)`]; + if (children.length === 1) { + return describeLogicTree(children[0], signalType, def, indent, false); + } + + const allSimple = children.every(c => c.type === 'CONDITION'); + const lines: string[] = []; + + if (options?.showCountHeader !== false && allSimple) { + lines.push(`${indent}(아래 ${children.length}개 조건을 모두 만족)`); + } else if (!allSimple) { + lines.push(`${indent}다음을 모두 동시에 만족:`); + } + + if (allSimple) { + for (const child of children) { + const text = describeConditionNode(child, signalType, def); + if (text) lines.push(`${indent}• ${text}`); + } + return lines; + } + + for (const child of children) { + lines.push(...describeLogicTree(child, signalType, def, indent + ' ', false)); + } + return lines; +} + +/** OR 그룹 — 각 자식을 [경로 N]으로 구분, AND 자식은 묶음으로 표현 */ +function describeOrGroup( + children: LogicNode[], + signalType: 'buy' | 'sell', + def: DefType, + indent: string, + atRoot: boolean, +): string[] { + if (children.length === 0) return [`${indent}(비어 있는 OR 그룹)`]; + if (children.length === 1) { + return describeLogicTree(children[0], signalType, def, indent, atRoot); + } + + const lines: string[] = []; + if (atRoot) { + lines.push('다음 경로 중 하나가 충족되면 됩니다.'); + } else { + lines.push(`${indent}다음 중 하나:`); + } + + children.forEach((child, idx) => { + if (idx > 0) lines.push(`${indent}또는`); + lines.push(`${indent}[경로 ${idx + 1}]`); + + if (child.type === 'AND') { + lines.push(...describeAndGroup(child.children ?? [], signalType, def, indent + ' ')); + } else if (child.type === 'CONDITION') { + const text = describeConditionNode(child, signalType, def); + if (text) lines.push(`${indent} • ${text}`); + } else { + lines.push(...describeLogicTree(child, signalType, def, indent + ' ', false)); + } + }); + return lines; +} + +function describeLogicTree( + node: LogicNode, + signalType: 'buy' | 'sell', + def: DefType, + indent = '', + atRoot = false, ): string[] { if (node.type === 'CONDITION' && node.condition) { - const c = normalizeCompositeCondition(node.condition); - const line = describeCondition(c, signalType, def); - return [depth > 0 ? `• ${line}` : line]; + const line = describeCondition(normalizeCompositeCondition(node.condition), signalType, def); + return [`${indent}• ${line}`]; } if (node.type === 'TIMEFRAME') { @@ -235,47 +316,66 @@ function describeNode( 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 (!inner) return [`${indent}[${tf}] 연결된 조건이 없습니다.`]; + const innerLines = describeLogicTree(inner, signalType, def, indent + ' ', false); + return [`${indent}[${tf} 기준]`, ...innerLines]; } if (node.type === 'AND') { const children = node.children ?? []; - if (children.length === 0) return ['(비어 있는 AND 그룹)']; + if (children.length === 0) return [`${indent}(비어 있는 AND 그룹)`]; + if (children.length === 1) { + return describeLogicTree(children[0], signalType, def, indent, atRoot); + } + + const hasOrChild = children.some(c => c.type === 'OR'); + if (hasOrChild) { + const lines: string[] = []; + if (atRoot) lines.push('다음 조건을 모두 동시에 만족해야 합니다.'); + else lines.push(`${indent}다음을 모두 동시에 만족:`); + for (const child of children) { + lines.push(...describeLogicTree(child, signalType, def, indent + ' ', false)); + } + return lines; + } + const lines: string[] = []; - if (depth === 0 && children.length > 1) { + if (atRoot) { lines.push('다음 조건을 모두 동시에 만족해야 합니다.'); } - children.forEach(child => { - lines.push(...describeNode(child, signalType, def, depth + 1)); - }); + lines.push(...describeAndGroup(children, signalType, def, indent, { + showCountHeader: !atRoot, + })); 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; + return describeOrGroup(node.children ?? [], signalType, def, indent, atRoot); } 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*/, '')}`)]; + if (!child) return [`${indent}(비어 있는 NOT 그룹)`]; + const sub = describeLogicTree(child, signalType, def, indent + ' ', false); + return [ + `${indent}다음 조건이 성립하지 않을 때:`, + ...sub.map(l => `${indent} (부정) ${l.replace(/^[\s•]+/, '')}`), + ]; } return []; } +function describeNode( + node: LogicNode, + signalType: 'buy' | 'sell', + def: DefType, + depth = 0, +): string[] { + const indent = depth > 0 ? ' '.repeat(depth) : ''; + return describeLogicTree(node, signalType, def, indent, depth === 0); +} + function describeSignalBranches( editorState: EditorConditionState | undefined, fallbackRoot: LogicNode | null, @@ -306,7 +406,7 @@ function describeSignalBranches( ? `${tf} 각 시간봉 마감 시점마다 아래 조건을 독립적으로 평가합니다.` : `${tf} 차트를 기준으로 아래 조건을 평가합니다.`, ); - bullets.push(...describeNode(root!, signalType, def)); + bullets.push(...describeLogicTree(root!, signalType, def, '', true)); return { hasContent: true, paragraphs, bullets }; } @@ -321,9 +421,7 @@ function describeSignalBranches( 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}`, - )); + bullets.push(...describeLogicTree(branch.root, signalType, def, ' ', false)); } });