import { isStrategyMissingOnServer, loadStrategy, loadStrategyTimeframes, repairStrategyTimeframes, saveStrategy, type StrategyDto, } from './backendApi'; import { alignBuySellStartCandleTypesForSave, collectDslBranches, collectTimeframesFromEditorState, decodeConditionForEditor, encodeConditionForSave, mergeStartMetaForLoad, normalizeStartCombineOp, syncEditorStateCandleTypesForSave, type EditorConditionState, } from './strategyConditionSerde'; import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNodes'; import type { LogicNode } from './strategyTypes'; import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage'; function collectFromFlowLayout(layout: StrategyFlowLayoutStore | null | undefined): string[] { if (!layout) return []; const set = new Set(); for (const side of ['buy', 'sell'] as const) { const metaMap = layout[side]?.startMeta; if (!metaMap) continue; for (const meta of Object.values(metaMap)) { for (const ct of getStartCandleTypes(meta)) set.add(normalizeStartCandleType(ct)); } } return [...set]; } function collectFromStrategyDto(strategy: StrategyDto | null | undefined): string[] { if (!strategy) return []; const set = new Set(); const buy = strategy.buyCondition as LogicNode | null | undefined; const sell = strategy.sellCondition as LogicNode | null | undefined; for (const b of [ ...collectDslBranches(buy ?? null), ...collectDslBranches(sell ?? null), ]) { const types = b.candleTypes?.length ? b.candleTypes : [b.candleType]; for (const ct of types) set.add(normalizeStartCandleType(ct)); } return [...set]; } /** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 */ export function collectUiEvaluationTimeframes( _strategyId: number, strategy?: StrategyDto | null, editorState?: { buy: EditorConditionState; sell: EditorConditionState }, ): string[] { if (editorState) { const fromEditor = [ ...collectTimeframesFromEditorState(editorState.buy), ...collectTimeframesFromEditorState(editorState.sell), ].map(normalizeStartCandleType); if (fromEditor.length > 0) return [...new Set(fromEditor)]; } const fromLayout = collectFromFlowLayout(strategy?.flowLayout); if (fromLayout.length > 0) return fromLayout; return collectFromStrategyDto(strategy); } function evaluationTimeframesMatch(uiTfs: string[], serverTfs: string[]): boolean { const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf))); const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf))); const missingOnServer = uiTfs.some(tf => !serverSet.has(normalizeStartCandleType(tf))); const extraOnServer = [...serverSet].some(tf => !uiSet.has(tf)); if (!missingOnServer && !extraOnServer) return true; if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) { return true; } return false; } /** * UI 분봉과 서버 DSL 불일치 시 자동 동기화 시도 후, 여전히 다르면 안내. * @returns false — 진행 불가(저장 필요) */ export async function warnStrategyTimeframeMismatch( strategyId: number, strategy?: StrategyDto | null, editorState?: { buy: EditorConditionState; sell: EditorConditionState }, ): Promise { const strat = strategy ?? await loadStrategy(strategyId); const uiTfs = collectUiEvaluationTimeframes(strategyId, strat, editorState); if (uiTfs.length === 0) return true; let serverTfs: string[]; try { serverTfs = await loadStrategyTimeframes(strategyId); } catch { return true; } if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true; const synced = await syncStrategyTimeframesFromLayoutIfNeeded(strategyId, strat); if (synced) { try { serverTfs = await loadStrategyTimeframes(strategyId); if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true; } catch { return true; } } const uiSet = new Set(uiTfs.map(tf => normalizeStartCandleType(tf))); const serverSet = new Set(serverTfs.map(tf => normalizeStartCandleType(tf))); const uiLabel = uiTfs.map(tf => normalizeStartCandleType(tf)).join(', ') || '1m'; const serverLabel = [...serverSet].join(', ') || '1m'; if (uiSet.size === serverSet.size && [...uiSet].every(tf => serverSet.has(tf))) { return true; } window.alert( `전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n` + '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.', ); return false; } function buildEditorStateFromStrategy( strategy: StrategyDto, layout: StrategyFlowLayoutStore | null | undefined, side: 'buy' | 'sell', ): EditorConditionState { const decoded = decodeConditionForEditor( (side === 'buy' ? strategy.buyCondition : strategy.sellCondition) as LogicNode | null, ); const snap = layout?.[side]; const merged: EditorConditionState = { root: decoded.root, startMeta: mergeStartMetaForLoad(decoded.startMeta, snap?.startMeta), extraStartIds: snap?.extraStartIds?.length ? snap.extraStartIds : decoded.extraStartIds, extraRoots: snap?.extraRoots ?? decoded.extraRoots, startCombineOp: normalizeStartCombineOp(snap?.startCombineOp ?? decoded.startCombineOp), }; return syncEditorStateCandleTypesForSave(merged); } /** * DB flow layout의 START 분봉이 서버 DSL과 다르면 자동 동기화. */ export async function syncStrategyTimeframesFromLayoutIfNeeded( strategyId: number, _strategy?: StrategyDto | null, ): Promise { if (isStrategyMissingOnServer(strategyId)) return false; const strat = await loadStrategy(strategyId); if (!strat) return false; const uiTfs = collectUiEvaluationTimeframes(strategyId, strat); if (uiTfs.length === 0) return true; let serverTfs: string[]; try { serverTfs = await loadStrategyTimeframes(strategyId); } catch { return true; } if (evaluationTimeframesMatch(uiTfs, serverTfs)) return true; const layout = strat.flowLayout; if (!layout) { const repaired = await repairStrategyTimeframes(strategyId); if (!repaired) return false; const tfs = await loadStrategyTimeframes(strategyId); return evaluationTimeframesMatch(uiTfs, tfs); } const buyState = buildEditorStateFromStrategy(strat, layout, 'buy'); const sellState = buildEditorStateFromStrategy(strat, layout, 'sell'); await persistStrategyEvaluationTimeframes( strategyId, strat.name, strat.description ?? '', buyState, sellState, layout, ); try { await repairStrategyTimeframes(strategyId); } catch { /* repair optional */ } return true; } /** 편집기 상태(DSL + flow layout)를 DB에 저장 — 실시간 평가 캐시는 서버에서 무효화됨 */ export async function persistStrategyEditorState( strategyId: number, name: string, description: string, buy: EditorConditionState, sell: EditorConditionState, flowLayout: StrategyFlowLayoutStore, ): Promise { const aligned = alignBuySellStartCandleTypesForSave(buy, sell); const encodedBuy = encodeConditionForSave(aligned.buy); const encodedSell = encodeConditionForSave(aligned.sell); const saved = await saveStrategy({ id: strategyId, name, description, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout, enabled: true, }); if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('gc:strategy-saved', { detail: { strategyId, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout }, })); } return saved; } /** @deprecated persistStrategyEditorState 사용 */ export async function persistStrategyEvaluationTimeframes( strategyId: number, name: string, description: string, buy: EditorConditionState, sell: EditorConditionState, flowLayout?: StrategyFlowLayoutStore, ): Promise { if (!flowLayout) return; await persistStrategyEditorState(strategyId, name, description, buy, sell, flowLayout); } export type { StrategyFlowLayoutStore };