/** * 전략 편집기 DSL 직렬화 — DB 평가 원천은 buy/sell condition JSON 만. * flow_layout 은 UI(좌표·START 메타); Logic Expression 은 저장하지 않음. */ import { normalizeLogicRootForPersistence } from './strategyConditionNormalize'; import type { LogicNode, ConditionDSL } from './strategyTypes'; import { generateNodeId } from './strategyTypes'; import { subtreeToFlatOrphans } from './strategyFlowLayout'; import { DEFAULT_START_CANDLE, DEFAULT_START_COMBINE_OP, START_NODE_ID, STRATEGY_CANDLE_TYPES, createStartNodeId, defaultStartMeta, getStartCandleTypes, normalizeStartCandleType, type StartCombineOp, type StartNodeMeta, } from './strategyStartNodes'; export type EditorConditionState = { root: LogicNode | null; startMeta: Record; extraStartIds: string[]; extraRoots: Record; /** START 2개 이상일 때 분기 결합 (기본 AND) */ startCombineOp?: StartCombineOp; }; export type { StartCombineOp }; export type ConditionBranch = { candleType: string; candleTypes?: string[]; root: LogicNode | null; }; export type StartSection = { startId: string; candleType: string; candleTypes: string[]; root: LogicNode | null; canDelete: boolean; }; function metaToStartSection( startId: string, meta: Record, root: LogicNode | null, canDelete: boolean, ): StartSection { const candleTypes = getStartCandleTypes(meta[startId]); return { startId, candleType: candleTypes[0], candleTypes, root, canDelete, }; } export function collectStartSections(state: EditorConditionState): StartSection[] { const sections: StartSection[] = [ metaToStartSection(START_NODE_ID, state.startMeta, state.root, false), ]; for (const startId of state.extraStartIds) { sections.push( metaToStartSection(startId, state.startMeta, state.extraRoots[startId] ?? null, true), ); } return sections; } export function hasMultipleStartSections(state: EditorConditionState): boolean { return state.extraStartIds.length >= 1; } export function normalizeStartCombineOp(value: string | undefined | null): StartCombineOp { return value === 'OR' ? 'OR' : DEFAULT_START_COMBINE_OP; } export function updateStartCombineOp( state: EditorConditionState, op: StartCombineOp, ): EditorConditionState { return { ...state, startCombineOp: op }; } export function updateBranchRoot( state: EditorConditionState, startId: string, root: LogicNode | null, ): EditorConditionState { if (startId === START_NODE_ID) { return { ...state, root }; } return { ...state, extraRoots: { ...state.extraRoots, [startId]: root }, }; } /** START 분봉 변경 시 조건 노드의 left/rightCandleType 동기화 */ function syncSubtreeCandleTypes(node: LogicNode | null, candleType: string): LogicNode | null { if (!node) return null; const ct = normalizeStartCandleType(candleType); if (node.type === 'CONDITION' && node.condition) { const cond = node.condition as ConditionDSL; return { ...node, condition: { ...cond, leftCandleType: ct, rightCandleType: ct, }, }; } if (node.children?.length) { return { ...node, children: node.children.map(c => syncSubtreeCandleTypes(c, ct)).filter(Boolean) as LogicNode[], }; } return node; } function collectExplicitCandleTypesFromTree(node: LogicNode | null, out: Set): void { if (!node) return; if (node.type === 'TIMEFRAME') { if (node.candleTypes?.length) { for (const ct of node.candleTypes) out.add(normalizeStartCandleType(ct)); } else if (node.candleType) { out.add(normalizeStartCandleType(node.candleType)); } } if (node.type === 'CONDITION' && node.condition) { const c = node.condition as ConditionDSL; if (c.leftCandleType) out.add(normalizeStartCandleType(c.leftCandleType)); if (c.rightCandleType) out.add(normalizeStartCandleType(c.rightCandleType)); } node.children?.forEach(ch => collectExplicitCandleTypesFromTree(ch, out)); } function inferPrimaryCandleFromTree(root: LogicNode | null): string { const set = new Set(); collectExplicitCandleTypesFromTree(root, set); const non1m = [...set].filter(ct => ct !== '1m'); if (non1m.length >= 1) return non1m[0]; if (set.size >= 1) return [...set][0]; return DEFAULT_START_CANDLE; } export function updateStartCandleTypes( state: EditorConditionState, startId: string, candleTypes: string[], ): EditorConditionState { const types = normalizeCandleTypesList(candleTypes); // 조건 노드 left/rightCandleType 동기화는 "대표 분봉"에만 사용되므로, // multi 분봉에서 stale 1m 이 앞에 오는 경우 1m 로 동기화되지 않도록 non-1m 을 우선한다. const primary = types.find(ct => normalizeStartCandleType(ct) !== '1m') ?? types[0]; const nextMeta = { ...state.startMeta, [startId]: { candleTypes: types, candleType: primary }, }; if (startId === START_NODE_ID) { return { ...state, startMeta: nextMeta, root: syncSubtreeCandleTypes(state.root, primary), }; } return { ...state, startMeta: nextMeta, extraRoots: { ...state.extraRoots, [startId]: syncSubtreeCandleTypes(state.extraRoots[startId] ?? null, primary), }, }; } /** @deprecated 단일 분봉 — updateStartCandleTypes 사용 */ export function updateStartCandleType( state: EditorConditionState, startId: string, candleType: string, ): EditorConditionState { return updateStartCandleTypes(state, startId, [candleType]); } function normalizeCandleTypesList(types: string[]): string[] { const seen = new Set(); const out: string[] = []; for (const ct of STRATEGY_CANDLE_TYPES) { if (types.some(t => normalizeStartCandleType(t) === ct) && !seen.has(ct)) { seen.add(ct); out.push(ct); } } if (out.length === 0) return [DEFAULT_START_CANDLE]; return out; } function readTimeframeCandleTypes(node: LogicNode): string[] { if (node.candleTypes?.length) { return normalizeCandleTypesList(node.candleTypes); } return [normalizeStartCandleType(node.candleType)]; } export function addExtraStartSection(state: EditorConditionState): EditorConditionState { const startId = createStartNodeId(); return { ...state, extraStartIds: [...state.extraStartIds, startId], startMeta: { ...state.startMeta, [startId]: { candleTypes: [DEFAULT_START_CANDLE], candleType: DEFAULT_START_CANDLE }, }, extraRoots: { ...state.extraRoots, [startId]: null }, }; } export function removeStartSection( state: EditorConditionState, startId: string, ): { state: EditorConditionState; orphanedNodes: LogicNode[] } { if (startId === START_NODE_ID) { return { state, orphanedNodes: [] }; } const branch = state.extraRoots[startId]; const nextMeta = { ...state.startMeta }; delete nextMeta[startId]; const nextRoots = { ...state.extraRoots }; delete nextRoots[startId]; return { state: { ...state, startMeta: nextMeta, extraStartIds: state.extraStartIds.filter(id => id !== startId), extraRoots: nextRoots, }, orphanedNodes: branch ? subtreeToFlatOrphans(branch) : [], }; } export function emptyEditorConditionState(): EditorConditionState { return { root: null, startMeta: defaultStartMeta(), extraStartIds: [], extraRoots: {}, startCombineOp: DEFAULT_START_COMBINE_OP, }; } function wrapTimeframe(candleType: string, root: LogicNode): LogicNode { const ct = normalizeStartCandleType(candleType); return { id: generateNodeId(), type: 'TIMEFRAME', candleType: ct, children: [root], }; } function wrapTimeframes(candleTypes: string[], root: LogicNode): LogicNode { const types = normalizeCandleTypesList(candleTypes); return { id: generateNodeId(), type: 'TIMEFRAME', candleType: types[0], candleTypes: types, children: [root], }; } /** 편집기 상태 → 저장/평가용 DSL (TIMEFRAME 래핑) */ export function encodeConditionForSave(state: EditorConditionState): LogicNode | null { const synced = syncEditorStateCandleTypesForSave(state); const branches = collectEditorBranches(synced).filter( (b): b is ConditionBranch & { root: LogicNode } => b.root != null, ); if (branches.length === 0) return null; if (branches.length === 1) { const { candleType, candleTypes, root } = branches[0]; const types = candleTypes?.length ? candleTypes : [candleType]; return wrapTimeframes(types, normalizeLogicRootForPersistence(root)!); } const combineOp = normalizeStartCombineOp(state.startCombineOp); return { id: generateNodeId(), type: combineOp, children: branches.map(b => { const types = b.candleTypes?.length ? b.candleTypes : [b.candleType]; return wrapTimeframes(types, b.root); }), }; } function parseMultiStartWrapper(dsl: LogicNode): EditorConditionState | null { if (dsl.type !== 'AND' && dsl.type !== 'OR') return null; const children = dsl.children ?? []; const allTimeframe = children.length > 0 && children.every(c => c.type === 'TIMEFRAME'); if (!allTimeframe) return null; const startMeta: Record = {}; const extraStartIds: string[] = []; const extraRoots: Record = {}; let primaryRoot: LogicNode | null = null; children.forEach((tf, index) => { const candleTypes = readTimeframeCandleTypes(tf as LogicNode); const candleType = candleTypes[0]; const branchRoot = syncSubtreeCandleTypes(tf.children?.[0] ?? null, candleType); if (index === 0) { startMeta[START_NODE_ID] = { candleTypes, candleType }; primaryRoot = branchRoot; return; } const startId = createStartNodeId(); startMeta[startId] = { candleTypes, candleType }; extraStartIds.push(startId); extraRoots[startId] = branchRoot; }); return { root: primaryRoot, startMeta, extraStartIds, extraRoots, startCombineOp: dsl.type as StartCombineOp, }; } /** DSL → 편집기 상태 (TIMEFRAME / AND|OR+TIMEFRAME 분해) */ export function decodeConditionForEditor(dsl: LogicNode | null): EditorConditionState { if (!dsl) return emptyEditorConditionState(); const multiStart = parseMultiStartWrapper(dsl); if (multiStart) return multiStart; if (dsl.type === 'TIMEFRAME') { const candleTypes = readTimeframeCandleTypes(dsl); const primary = candleTypes[0]; return { root: syncSubtreeCandleTypes(dsl.children?.[0] ?? null, primary), startMeta: { [START_NODE_ID]: { candleTypes, candleType: primary }, }, extraStartIds: [], extraRoots: {}, startCombineOp: DEFAULT_START_COMBINE_OP, }; } const inferred = inferPrimaryCandleFromTree(dsl); return { root: syncSubtreeCandleTypes(dsl, inferred), startMeta: { [START_NODE_ID]: { candleTypes: [inferred], candleType: inferred }, }, extraStartIds: [], extraRoots: {}, startCombineOp: DEFAULT_START_COMBINE_OP, }; } /** Logic Expression / 미리보기용 — START별 평가 분봉과 서브트리 */ export function collectEditorBranches(state: EditorConditionState): ConditionBranch[] { const branches: ConditionBranch[] = []; const primaryTypes = getStartCandleTypes(state.startMeta[START_NODE_ID]); if (state.root) { branches.push({ candleType: primaryTypes[0], candleTypes: primaryTypes.length > 1 ? primaryTypes : undefined, root: state.root, }); } for (const startId of state.extraStartIds) { const root = state.extraRoots[startId] ?? null; if (root) { const types = getStartCandleTypes(state.startMeta[startId]); branches.push({ candleType: types[0], candleTypes: types.length > 1 ? types : undefined, root, }); } } return branches; } /** DSL·UI에서 사용하는 전략 평가 분봉 목록 */ export function collectTimeframesFromEditorState(state: EditorConditionState): string[] { const set = new Set(); for (const section of collectStartSections(state)) { for (const ct of section.candleTypes) set.add(ct); } return [...set]; } /** @deprecated 매수·매도 시간봉은 각각 독립 설정 — updateStartCandleTypes 사용 */ export function syncBuySellPrimaryStartCandleTypes( buy: EditorConditionState, sell: EditorConditionState, candleTypes: string[], ): { buy: EditorConditionState; sell: EditorConditionState } { const types = normalizeCandleTypesList(candleTypes); return { buy: updateStartCandleTypes(buy, START_NODE_ID, types), sell: updateStartCandleTypes(sell, START_NODE_ID, types), }; } /** 저장 직전 — 각 START 분봉을 조건 트리 left/rightCandleType 에 반영 */ export function syncEditorStateCandleTypesForSave(state: EditorConditionState): EditorConditionState { let next = state; for (const section of collectStartSections(state)) { next = updateStartCandleTypes(next, section.startId, section.candleTypes); } return next; } /** 저장 직전 — 각 탭 START 분봉을 해당 조건 트리 left/rightCandleType 에 반영 (매수·매도 독립) */ export function alignBuySellStartCandleTypesForSave( buy: EditorConditionState, sell: EditorConditionState, ): { buy: EditorConditionState; sell: EditorConditionState } { return { buy: syncEditorStateCandleTypesForSave(buy), sell: syncEditorStateCandleTypesForSave(sell), }; } /** 저장된 DSL에서 Logic Expression용 분기 목록 */ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] { return collectEditorBranches(decodeConditionForEditor(dsl)); } /** DB가 1m 기본값만 갖고 편집기 localStorage에 다른 분봉이 있을 때 localStorage 우선 */ /** * 전략 로드 시 startMeta 병합 — flow_layout_json(편집기)이 있으면 DSL TIMEFRAME보다 우선. */ export function mergeStartMetaForLoad( decoded: Record, stored?: Record | null, ): Record { if (!stored || Object.keys(stored).length === 0) { const fromDsl: Record = { ...defaultStartMeta(), ...decoded }; for (const [startId, meta] of Object.entries(decoded)) { const types = normalizeCandleTypesList(getStartCandleTypes(meta)); fromDsl[startId] = { candleTypes: types, candleType: types[0] }; } return fromDsl; } const merged: Record = { ...defaultStartMeta(), ...stored, }; for (const [startId, meta] of Object.entries(decoded)) { if (stored[startId]) { const storedTypes = normalizeCandleTypesList(getStartCandleTypes(stored[startId])); const decodedTypes = normalizeCandleTypesList(getStartCandleTypes(meta)); // flow_layout_json 에 stale 1m 이 남아 있는 경우가 있어, // DSL 에 1m 이 없고 non-1m 이 있으면 DSL 쪽을 우선한다. const shouldPreferDecoded = storedTypes.includes('1m') && storedTypes.some(ct => ct !== '1m') && !decodedTypes.includes('1m') && decodedTypes.some(ct => ct !== '1m'); const types = shouldPreferDecoded ? decodedTypes : storedTypes; merged[startId] = { candleTypes: types, candleType: types[0] }; } else { const types = normalizeCandleTypesList(getStartCandleTypes(meta)); merged[startId] = { candleTypes: types, candleType: types[0] }; } } return merged; }