398 lines
14 KiB
TypeScript
398 lines
14 KiB
TypeScript
/**
|
|
* 전략 조건 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,
|
|
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<string, string> = {
|
|
'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<typeof normalizeCompositeCondition>,
|
|
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(
|
|
cond: ReturnType<typeof normalizeCompositeCondition>,
|
|
signalType: 'buy' | 'sell',
|
|
def: DefType,
|
|
): string {
|
|
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, i) => {
|
|
const sub = describeNode(child, signalType, def, depth + 1);
|
|
if (children.length > 1) lines.push(`${i + 1})`);
|
|
lines.push(...sub);
|
|
});
|
|
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, i) => {
|
|
const sub = describeNode(child, signalType, def, depth + 1);
|
|
if (children.length > 1) lines.push(`${i + 1})`);
|
|
lines.push(...sub);
|
|
});
|
|
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 };
|
|
}
|