/** * 전략편집기 — 노드 기반 논리 트리 UI (React Flow) */ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { ReactFlowProvider } from '@xyflow/react'; import DraggableModalFrame from './DraggableModalFrame'; import type { Theme } from '../types/index'; import { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators'; import { fetchBacktestCandleBundle } from '../utils/backtestWarmup'; import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync'; import type { LogicNode } from '../utils/strategyTypes'; import { buildStrategyEditorDefFromSettings, loadStratsLocal, saveStratsLocal, mergeAtRoot, makeNode, updateNode, CondEditor, genId, type StrategyDto, } from '../utils/strategyEditorShared'; import { syncLogicNodeWithGlobalDef, syncLogicRootWithGlobalDef } from '../utils/strategyDefSync'; import { useIndicatorSettings } from '../hooks/useIndicatorSettings'; import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout'; import { decodeConditionForEditor, alignBuySellStartCandleTypesForSave, encodeConditionForSave, mergeStartMetaForLoad, addExtraStartSection, hasMultipleStartSections, normalizeStartCombineOp, updateStartCombineOp, updateStartCandleTypes, type EditorConditionState, } from '../utils/strategyConditionSerde'; import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize'; import { persistStrategyEditorState } from '../utils/strategyTimeframeSync'; import { START_NODE_ID, defaultStartMeta, type StartCombineOp, } from '../utils/strategyStartNodes'; import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab'; import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab'; import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab'; import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage'; import { loadPaletteItems, type PaletteItem, } from '../utils/strategyPaletteStorage'; import { buildCompositePaletteTemplateRows, buildSidewaysPaletteTemplateRows, } from '../utils/strategyEditorTemplatePaletteRows'; import { buildPriceExtremeBreakoutTree, isPriceExtremeBreakoutPairPaletteValue, isPriceExtremeBreakoutPairRoot, findPriceExtremePairRootInForest, patchPriceExtremePairInTree, setPriceExtremePairFilterLevel, inferPriceExtremeFilterLevel, type PriceExtremeFilterLevel, } from '../utils/priceExtremeBreakoutPair'; import { buildInflection33Tree, isInflection33PaletteValue, isInflection33PairRoot, setInflection33PairFilterLevel, patchInflection33PairInTree, inferInflection33FilterLevel, type Inflection33FilterLevel, } from '../utils/inflection33Strategy'; import { buildStableStrategyTree, isStableStrategyPaletteValue, isStableStrategyPairRoot, setStableStrategyPairFilterLevel, patchStableStrategyPairInTree, inferStableStrategyFilterLevel, type StableStrategyFilterLevel, type StableStrategyId, } from '../utils/stableStrategyPairs'; import { buildStochOverboughtPairTree, findStochPairRootInForest, isStochOverboughtPairPaletteValue, isStochOverboughtPairRoot, patchStochPairInTree, setStochPairSecondary, } from '../utils/stochOverboughtPair'; import StochPairNodeSettings from './strategyEditor/StochPairNodeSettings'; import PriceExtremePairNodeSettings from './strategyEditor/PriceExtremePairNodeSettings'; import Inflection33PairNodeSettings from './strategyEditor/Inflection33PairNodeSettings'; import StableStrategyPairNodeSettings from './strategyEditor/StableStrategyPairNodeSettings'; import { emptySignalFlowLayout, buildStrategyFlowLayoutStore, normalizeStrategyFlowLayout, readLegacyFlowLayoutFromLocalStorage, clearLegacyFlowLayoutFromLocalStorage, type SignalFlowLayoutSnapshot, type FlowLayoutChangePayload, type StrategyFlowLayoutStore, } from '../utils/strategyEditorLayoutStorage'; import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview'; import LogicExpressionNarrative from './strategyEditor/LogicExpressionNarrative'; import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas'; import StrategyListEditor from './strategyEditor/StrategyListEditor'; import StartCombineOpControl from './strategyEditor/StartCombineOpControl'; import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks'; import { loadEditorMode, saveEditorMode, type StrategyEditorMode, } from '../utils/strategyEditorModeStorage'; import { getStrategyTemplates, getBuiltinTemplateId, simpleTemplateToNode, type StrategyTemplateDef, } from '../utils/strategyPresets'; import { addUserStrategyTemplate, deleteUserStrategyTemplate, updateUserStrategyTemplate, hideBuiltinTemplate, loadHiddenBuiltinTemplateIds, loadUserStrategyTemplates, type UserStrategyTemplate, } from '../utils/strategyTemplateStorage'; import { buildStrategyExportPayload, buildStrategyListExportPayload, downloadStrategyJson, listImportItemToStrategyDto, parseStrategyImportFile, pickJsonFile, strategyDtoToListExportItem, } from '../utils/strategyImportExport'; import PaletteChip from './strategyEditor/PaletteChip'; import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay'; import { needsPointerPaletteDrag } from '../utils/paletteDragSession'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; import { clampPanelSize, readStoredBool, readStoredSize, storeBool, storeSize, usePanelResize, useRightPanelResize, } from './strategyEditor/usePanelResize'; import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal'; import ReactDOM from 'react-dom'; import { MarketSearchPanel } from './MarketSearchPanel'; import { getKoreanName } from '../utils/marketNameCache'; import type { Timeframe } from '../types'; import { BACKTEST_STRATEGY_TIMEFRAME, buildQuickRunTimeframeSelectOptions, parseBacktestRunTimeframeChoice, resolveBacktestExecTimeframe, type BacktestRunTimeframeChoice, } from '../utils/backtestRunTimeframe'; import '../styles/strategyEditor.css'; import '../styles/strategyEditorTheme.css'; const BACKTEST_FOCUS_KEY = 'backtest_focus_id'; const LEFT_PANEL_MIN = 220; const LEFT_PANEL_MAX = 520; const LEFT_PANEL_DEFAULT = 280; const RIGHT_PANEL_MIN = 380; const RIGHT_PANEL_MAX = 560; const RIGHT_PANEL_DEFAULT = 380; const TERMINAL_MIN = 88; const TERMINAL_MAX = 420; const TERMINAL_DEFAULT = 220; function readRightPanelWidth(): number { return clampPanelSize( readStoredSize('se-right-width', RIGHT_PANEL_DEFAULT), RIGHT_PANEL_MIN, RIGHT_PANEL_MAX, ); } interface Props { theme: Theme; onNavigateToBacktest?: () => void; /** 팝업(임베드) — 전략평가 등 */ variant?: 'page' | 'modal'; onClose?: () => void; initialStrategyId?: number | null; onStrategiesChanged?: () => void; } function toEditorState( root: LogicNode | null, layout: Pick, ): EditorConditionState { return { root, startMeta: layout.startMeta ?? defaultStartMeta(), extraStartIds: layout.extraStartIds ?? [], extraRoots: layout.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(layout.startCombineOp), }; } function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) { return { positions: snap.positions ?? {}, edgeHandles: snap.edgeHandles ?? {}, startMeta: snap.startMeta ?? defaultStartMeta(), extraStartIds: snap.extraStartIds ?? [], extraRoots: snap.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(snap.startCombineOp), }; } function applyFlowLayoutFromStore( stored: StrategyFlowLayoutStore | null, setBuyLayout: React.Dispatch>>, setSellLayout: React.Dispatch>>, setBuyOrphans: React.Dispatch>, setSellOrphans: React.Dispatch>, ) { if (stored) { setBuyLayout(normalizeTabLayoutState(stored.buy)); setSellLayout(normalizeTabLayoutState(stored.sell)); setBuyOrphans(stored.buy.orphans ?? []); setSellOrphans(stored.sell.orphans ?? []); } else { const emptyBuy = emptySignalFlowLayout(); const emptySell = emptySignalFlowLayout(); setBuyLayout(normalizeTabLayoutState(emptyBuy)); setSellLayout(normalizeTabLayoutState(emptySell)); setBuyOrphans([]); setSellOrphans([]); } } type SaveDialogMode = 'update' | 'create' | 'saveAs'; function suggestSaveAsName(baseName: string, existing: StrategyDto[]): string { const trimmed = baseName.trim() || '새 전략'; const names = new Set(existing.map(s => s.name.trim())); let candidate = `${trimmed} (복사)`; let n = 2; while (names.has(candidate)) { candidate = `${trimmed} (복사 ${n})`; n += 1; } return candidate; } function isDuplicateStrategyName( name: string, strategies: StrategyDto[], excludeId?: number | null, ): boolean { const trimmed = name.trim(); return strategies.some(s => s.name.trim() === trimmed && s.id !== excludeId); } export default function StrategyEditorPage({ theme, onNavigateToBacktest, variant = 'page', onClose, initialStrategyId = null, onStrategiesChanged, }: Props) { const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings(); const DEF = useMemo( () => buildStrategyEditorDefFromSettings(getParams, getVisualConfig), [getParams, getVisualConfig, settingsRevision], ); const settingsSyncRef = useRef(-1); const initialStrategyAppliedRef = useRef(false); const notifyStrategiesChanged = useCallback(() => { onStrategiesChanged?.(); }, [onStrategiesChanged]); const [strategies, setStrategies] = useState(() => loadStratsLocal()); const [selectedId, setSelectedId] = useState(null); const [buyCondition, setBuyCondition] = useState(null); const [sellCondition, setSellCondition] = useState(null); const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy'); const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators'); const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary'); const [auxiliaryPalette, setAuxiliaryPalette] = useState(() => loadPaletteItems('auxiliary')); const [compositePalette, setCompositePalette] = useState(() => loadPaletteItems('composite')); const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems()); const [paletteSearch, setPaletteSearch] = useState(''); const [templateSearch, setTemplateSearch] = useState(''); const [userTemplates, setUserTemplates] = useState(() => loadUserStrategyTemplates()); const [hiddenBuiltinTemplateIds, setHiddenBuiltinTemplateIds] = useState( () => loadHiddenBuiltinTemplateIds(), ); const [templateSaveOpen, setTemplateSaveOpen] = useState(false); const [templateModalMode, setTemplateModalMode] = useState<'add' | 'edit'>('add'); const [templateEditId, setTemplateEditId] = useState(null); const [templateSaveUpdateContent, setTemplateSaveUpdateContent] = useState(false); const [templateSaveLabel, setTemplateSaveLabel] = useState(''); const [templateSaveDesc, setTemplateSaveDesc] = useState(''); const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false); const [selectedTemplateKey, setSelectedTemplateKey] = useState(null); const [selectedPaletteKey, setSelectedPaletteKey] = useState(null); const [stratName, setStratName] = useState(''); const [stratDesc, setStratDesc] = useState(''); const [isSaving, setIsSaving] = useState(false); const [saveOpen, setSaveOpen] = useState(false); const [saveMode, setSaveMode] = useState('create'); const [saveDraftName, setSaveDraftName] = useState(''); const [saveDraftDesc, setSaveDraftDesc] = useState(''); const [saveNameError, setSaveNameError] = useState(false); const [saveDuplicateError, setSaveDuplicateError] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [descOpen, setDescOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const [buyOrphans, setBuyOrphans] = useState([]); const [sellOrphans, setSellOrphans] = useState([]); const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null); const [saveToast, setSaveToast] = useState(false); // ── 빠른 백테스팅 실행 ──────────────────────────────────────────────────── const [qbMarket, setQbMarket] = useState('KRW-BTC'); const [qbTimeframe, setQbTimeframe] = useState(BACKTEST_STRATEGY_TIMEFRAME); const [qbLoading, setQbLoading] = useState(false); const [qbError, setQbError] = useState(null); const [qbMarketOpen, setQbMarketOpen] = useState(false); const [qbMarketQ, setQbMarketQ] = useState(''); const qbMarketBtnRef = useRef(null); const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT)); const [rightWidth, setRightWidth] = useState(() => readRightPanelWidth()); const [rightOpen, setRightOpen] = useState(() => readStoredBool('se-right-open', true)); const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT)); const leftWidthRef = useRef(leftWidth); const rightWidthRef = useRef(rightWidth); const terminalHeightRef = useRef(terminalHeight); const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout())); const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout())); const buyLayoutRef = useRef(buyLayout); const sellLayoutRef = useRef(sellLayout); const buyConditionRef = useRef(buyCondition); const sellConditionRef = useRef(sellCondition); const buyOrphansRef = useRef(buyOrphans); const sellOrphansRef = useRef(sellOrphans); const selectedIdRef = useRef(selectedId); const stratNameRef = useRef(stratName); const stratDescRef = useRef(stratDesc); buyLayoutRef.current = buyLayout; sellLayoutRef.current = sellLayout; buyConditionRef.current = buyCondition; sellConditionRef.current = sellCondition; buyOrphansRef.current = buyOrphans; sellOrphansRef.current = sellOrphans; selectedIdRef.current = selectedId; stratNameRef.current = stratName; stratDescRef.current = stratDesc; const layoutRevisionRef = useRef(0); const strategyAutosaveTimerRef = useRef(null); const layoutPersistReadyRef = useRef(false); const STRATEGY_AUTOSAVE_MS = 350; leftWidthRef.current = leftWidth; rightWidthRef.current = rightWidth; terminalHeightRef.current = terminalHeight; const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0'); const [editorMode, setEditorMode] = useState(() => loadEditorMode()); const onLeftSplitter = usePanelResize( 'vertical', setLeftWidth, () => leftWidthRef.current, LEFT_PANEL_MIN, LEFT_PANEL_MAX, v => storeSize('se-left-width', v), ); const onRightSplitter = useRightPanelResize( setRightWidth, () => rightWidthRef.current, RIGHT_PANEL_MIN, RIGHT_PANEL_MAX, v => storeSize('se-right-width', v), ); const toggleRightPanel = useCallback(() => { setRightOpen(prev => { const next = !prev; storeBool('se-right-open', next); return next; }); }, []); const onTerminalSplitter = usePanelResize( 'horizontal', setTerminalHeight, () => terminalHeightRef.current, TERMINAL_MIN, TERMINAL_MAX, v => storeSize('se-terminal-height', v), ); const bodyStyle = useMemo(() => ({ '--se-left-width': `${leftWidth}px`, '--se-right-width': `${rightWidth}px`, '--se-terminal-height': `${terminalHeight}px`, }) as React.CSSProperties, [leftWidth, rightWidth, terminalHeight]); const showSnack = (msg: string, ok = true) => { setSnack({ msg, ok }); setTimeout(() => setSnack(null), 3000); }; const currentRoot = signalTab === 'buy' ? buyCondition : sellCondition; const setCurrentRoot = signalTab === 'buy' ? setBuyCondition : setSellCondition; const currentOrphans = signalTab === 'buy' ? buyOrphans : sellOrphans; const setCurrentOrphans = signalTab === 'buy' ? setBuyOrphans : setSellOrphans; const currentLayout = signalTab === 'buy' ? buyLayout : sellLayout; const setCurrentLayout = signalTab === 'buy' ? setBuyLayout : setSellLayout; const buyEditorState = useMemo( () => toEditorState(buyCondition, buyLayout), [buyCondition, buyLayout], ); const sellEditorState = useMemo( () => toEditorState(sellCondition, sellLayout), [sellCondition, sellLayout], ); const currentEditorState = signalTab === 'buy' ? buyEditorState : sellEditorState; const currentEditorStateRef = useRef(currentEditorState); currentEditorStateRef.current = currentEditorState; const selectedLogicNode = useMemo(() => { if (!selectedNodeId) return null; return findLogicNode( currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {}, ); }, [selectedNodeId, currentRoot, currentOrphans, currentLayout.extraRoots]); const stochPairForest = useMemo( () => [ currentRoot, ...currentOrphans, ...Object.values(currentLayout.extraRoots ?? {}), ], [currentRoot, currentOrphans, currentLayout.extraRoots], ); const orphanTotal = buyOrphans.length + sellOrphans.length; const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft'; const buildCurrentFlowLayout = useCallback( (): StrategyFlowLayoutStore => buildStrategyFlowLayoutStore( buyLayoutRef.current, sellLayoutRef.current, buyOrphansRef.current, sellOrphansRef.current, ), [], ); const persistStrategyToDb = useCallback(async () => { if (selectedId == null || !stratName.trim()) return; const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current); const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current); const aligned = alignBuySellStartCandleTypesForSave(buy, sell); const encodedBuy = encodeConditionForSave(aligned.buy); const encodedSell = encodeConditionForSave(aligned.sell); if (!encodedBuy && !encodedSell) return; const flowLayout = buildCurrentFlowLayout(); const saved = await persistStrategyEditorState( selectedId, stratName, stratDesc, aligned.buy, aligned.sell, flowLayout, ); setStrategies(prev => prev.map(s => s.id === selectedId ? { ...s, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout, updatedAt: saved?.updatedAt ?? s.updatedAt, } : s)); }, [selectedId, stratName, stratDesc, buildCurrentFlowLayout]); /** 전략 전환·새 전략 전 — ref 스냅샷을 지정 id 로 저장 (selectedId 변경 후 빈 DSL 덮어쓰기 방지) */ const persistStrategySnapshot = useCallback(async ( strategyId: number, name: string, description: string, ) => { const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current); const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current); const aligned = alignBuySellStartCandleTypesForSave(buy, sell); const encodedBuy = encodeConditionForSave(aligned.buy); const encodedSell = encodeConditionForSave(aligned.sell); if (!encodedBuy && !encodedSell) return; const flowLayout = buildCurrentFlowLayout(); const saved = await persistStrategyEditorState( strategyId, name, description, aligned.buy, aligned.sell, flowLayout, ); setStrategies(prev => prev.map(s => s.id === strategyId ? { ...s, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout, updatedAt: saved?.updatedAt ?? s.updatedAt, } : s)); }, [buildCurrentFlowLayout]); const flushStrategyPersist = useCallback(() => { if (strategyAutosaveTimerRef.current != null) { window.clearTimeout(strategyAutosaveTimerRef.current); strategyAutosaveTimerRef.current = null; } if (selectedId == null || !stratName.trim()) return; void persistStrategyToDb().catch(err => { console.warn('[StrategyEditor] 전략 DB 저장 실패:', err); }); }, [selectedId, stratName, persistStrategyToDb]); const scheduleStrategyPersist = useCallback(() => { if (selectedId == null || !stratName.trim()) return; if (strategyAutosaveTimerRef.current != null) { window.clearTimeout(strategyAutosaveTimerRef.current); } strategyAutosaveTimerRef.current = window.setTimeout(() => { strategyAutosaveTimerRef.current = null; void persistStrategyToDb().catch(err => { console.warn('[StrategyEditor] 전략 자동저장 실패:', err); }); }, STRATEGY_AUTOSAVE_MS); }, [selectedId, stratName, persistStrategyToDb]); const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => { layoutRevisionRef.current += 1; setLayoutSeedKey(`${strategyKey}:${tab}:${layoutRevisionRef.current}`); }, []); /** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드·캔버스 기준값 동기화 */ useEffect(() => { if (!settingsLoaded) return; if (settingsRevision <= settingsSyncRef.current) return; settingsSyncRef.current = settingsRevision; const syncExtraRoots = ( roots: Record, signalType: 'buy' | 'sell', ) => { const next: Record = {}; for (const [key, node] of Object.entries(roots)) { next[key] = node ? syncLogicNodeWithGlobalDef(node, DEF, signalType) : null; } return next; }; setBuyCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'buy') : prev)); setSellCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'sell') : prev)); setBuyOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'buy'))); setSellOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'sell'))); setBuyLayout(prev => ({ ...prev, extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'buy'), })); setSellLayout(prev => ({ ...prev, extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'), })); bumpLayoutSeed(layoutStrategyKey, signalTab); if (selectedId != null && stratName.trim()) scheduleStrategyPersist(); }, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed, selectedId, stratName, scheduleStrategyPersist]); const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => { applyFlowLayoutFromStore(null, setBuyLayout, setSellLayout, setBuyOrphans, setSellOrphans); bumpLayoutSeed(strategyKey, tab); }, [bumpLayoutSeed]); const handleLayoutChange = useCallback((snapshot: FlowLayoutChangePayload) => { const patch = { positions: snapshot.positions, edgeHandles: snapshot.edgeHandles, }; if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch })); else setSellLayout(prev => ({ ...prev, ...patch })); scheduleStrategyPersist(); }, [scheduleStrategyPersist]); const handleRootChange = useCallback((root: LogicNode | null) => { setCurrentRoot(root); scheduleStrategyPersist(); }, [setCurrentRoot, scheduleStrategyPersist]); const handleOrphansChange = useCallback((orphans: LogicNode[]) => { setCurrentOrphans(orphans); scheduleStrategyPersist(); }, [setCurrentOrphans, scheduleStrategyPersist]); const handleStartMetaChange = useCallback((meta: Record) => { setCurrentLayout(prev => ({ ...prev, startMeta: meta })); scheduleStrategyPersist(); }, [setCurrentLayout, scheduleStrategyPersist]); const handleExtraStartIdsChange = useCallback((ids: string[]) => { setCurrentLayout(prev => ({ ...prev, extraStartIds: ids })); scheduleStrategyPersist(); bumpLayoutSeed(layoutStrategyKey, signalTab); }, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]); const handleExtraRootsChange = useCallback((roots: Record) => { setCurrentLayout(prev => ({ ...prev, extraRoots: roots })); scheduleStrategyPersist(); }, [setCurrentLayout, scheduleStrategyPersist]); const applyStochPairSecondary = useCallback((orNodeId: string, secondary: string) => { if (currentOrphans.some(o => o.id === orNodeId)) { handleOrphansChange(currentOrphans.map(o => ( o.id === orNodeId ? setStochPairSecondary(o, secondary, DEF) : o ))); return; } if (currentRoot) { const patched = patchStochPairInTree(currentRoot, orNodeId, secondary, DEF); if (patched && patched !== currentRoot) { handleRootChange(patched); return; } } for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { if (!branch) continue; const patched = patchStochPairInTree(branch, orNodeId, secondary, DEF); if (patched && patched !== branch) { handleExtraRootsChange({ ...(currentLayout.extraRoots ?? {}), [startId]: patched, }); return; } } }, [ currentRoot, currentOrphans, currentLayout.extraRoots, DEF, handleOrphansChange, handleRootChange, handleExtraRootsChange, ]); const applyPriceExtremeFilterLevel = useCallback((andNodeId: string, filterLevel: PriceExtremeFilterLevel) => { if (currentOrphans.some(o => o.id === andNodeId)) { handleOrphansChange(currentOrphans.map(o => ( o.id === andNodeId ? setPriceExtremePairFilterLevel(o, filterLevel, DEF) : o ))); return; } if (currentRoot) { const patched = patchPriceExtremePairInTree(currentRoot, andNodeId, filterLevel, DEF); if (patched && patched !== currentRoot) { handleRootChange(patched); return; } } for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { if (!branch) continue; const patched = patchPriceExtremePairInTree(branch, andNodeId, filterLevel, DEF); if (patched && patched !== branch) { handleExtraRootsChange({ ...(currentLayout.extraRoots ?? {}), [startId]: patched, }); return; } } }, [ currentRoot, currentOrphans, currentLayout.extraRoots, DEF, handleOrphansChange, handleRootChange, handleExtraRootsChange, ]); const applyInflection33FilterLevel = useCallback((pairNodeId: string, filterLevel: Inflection33FilterLevel) => { if (currentOrphans.some(o => o.id === pairNodeId)) { handleOrphansChange(currentOrphans.map(o => ( o.id === pairNodeId ? setInflection33PairFilterLevel(o, filterLevel, DEF) : o ))); return; } if (currentRoot) { const patched = patchInflection33PairInTree(currentRoot, pairNodeId, filterLevel, DEF); if (patched && patched !== currentRoot) { handleRootChange(patched); return; } } for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { if (!branch) continue; const patched = patchInflection33PairInTree(branch, pairNodeId, filterLevel, DEF); if (patched && patched !== branch) { handleExtraRootsChange({ ...(currentLayout.extraRoots ?? {}), [startId]: patched, }); return; } } }, [ currentRoot, currentOrphans, currentLayout.extraRoots, DEF, handleOrphansChange, handleRootChange, handleExtraRootsChange, ]); const applyStableStrategyFilterLevel = useCallback((pairNodeId: string, filterLevel: StableStrategyFilterLevel) => { if (currentOrphans.some(o => o.id === pairNodeId)) { handleOrphansChange(currentOrphans.map(o => ( o.id === pairNodeId ? setStableStrategyPairFilterLevel(o, filterLevel, DEF) : o ))); return; } if (currentRoot) { const patched = patchStableStrategyPairInTree(currentRoot, pairNodeId, filterLevel, DEF); if (patched && patched !== currentRoot) { handleRootChange(patched); return; } } for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { if (!branch) continue; const patched = patchStableStrategyPairInTree(branch, pairNodeId, filterLevel, DEF); if (patched && patched !== branch) { handleExtraRootsChange({ ...(currentLayout.extraRoots ?? {}), [startId]: patched, }); return; } } }, [ currentRoot, currentOrphans, currentLayout.extraRoots, DEF, handleOrphansChange, handleRootChange, handleExtraRootsChange, ]); const stochPairEditForSelected = useMemo(() => { if (!selectedNodeId || selectedLogicNode?.type !== 'CONDITION' || !selectedLogicNode.condition) { return undefined; } const pairRoot = findStochPairRootInForest(stochPairForest, selectedNodeId); if (!pairRoot?.stochPair) return undefined; return { secondaryIndicator: pairRoot.stochPair.secondaryIndicatorType, stochLocked: selectedLogicNode.condition.indicatorType === 'STOCHASTIC', onSecondaryChange: (next: string) => applyStochPairSecondary(pairRoot.id, next), }; }, [selectedNodeId, selectedLogicNode, stochPairForest, applyStochPairSecondary]); const handleEditorStateChange = useCallback(( next: EditorConditionState | ((prev: EditorConditionState) => EditorConditionState), ) => { const resolved = typeof next === 'function' ? next(currentEditorStateRef.current) : next; setCurrentRoot(resolved.root); setCurrentLayout(prev => ({ ...prev, startMeta: resolved.startMeta, extraStartIds: resolved.extraStartIds, extraRoots: resolved.extraRoots, startCombineOp: normalizeStartCombineOp(resolved.startCombineOp), })); scheduleStrategyPersist(); }, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]); const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => { handleEditorStateChange(updateStartCandleTypes(currentEditorState, startId, candleTypes)); }, [handleEditorStateChange, currentEditorState]); const handleStartCombineOpChange = useCallback((op: StartCombineOp) => { handleEditorStateChange(updateStartCombineOp(currentEditorState, op)); }, [handleEditorStateChange, currentEditorState]); const switchSignalTab = useCallback((tab: 'buy' | 'sell') => { if (editorMode === 'graph') layoutFlushRef.current?.(); flushStrategyPersist(); layoutRevisionRef.current += 1; setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`); setSignalTab(tab); setSelectedNodeId(null); }, [layoutStrategyKey, flushStrategyPersist, editorMode]); const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => { if (mode === editorMode) return; if (editorMode === 'graph') { layoutFlushRef.current?.(); flushStrategyPersist(); } if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) { showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false); } setEditorMode(mode); saveEditorMode(mode); setSelectedNodeId(null); }, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]); const layoutSeed = useMemo((): FlowLayoutSeed => { const source = signalTab === 'buy' ? buyLayout : sellLayout; return { seedKey: layoutSeedKey, positions: source.positions, edgeHandles: source.edgeHandles, }; }, [signalTab, layoutSeedKey, buyLayout, sellLayout]); const persistStrategySnapshotRef = useRef(persistStrategySnapshot); persistStrategySnapshotRef.current = persistStrategySnapshot; useEffect(() => () => { if (strategyAutosaveTimerRef.current != null) { window.clearTimeout(strategyAutosaveTimerRef.current); strategyAutosaveTimerRef.current = null; } const id = selectedIdRef.current; const name = stratNameRef.current; const desc = stratDescRef.current; if (id != null && name.trim()) { void persistStrategySnapshotRef.current(id, name, desc).catch(() => { /* unmount flush */ }); } }, []); useEffect(() => { if (!layoutPersistReadyRef.current) { layoutPersistReadyRef.current = true; return; } scheduleStrategyPersist(); }, [buyOrphans, sellOrphans, scheduleStrategyPersist]); useEffect(() => { saveStratsLocal(strategies); }, [strategies]); useEffect(() => { if (!hasRegisteredUser()) return; loadStrategies().then(async list => { if (list?.length) { const mapped = list.map(s => ({ id: s.id ?? Date.now(), name: s.name, description: s.description, buyCondition: s.buyCondition as LogicNode | null ?? null, sellCondition: s.sellCondition as LogicNode | null ?? null, flowLayout: normalizeStrategyFlowLayout(s.flowLayout) ?? undefined, enabled: s.enabled ?? true, createdAt: s.createdAt, updatedAt: s.updatedAt, })); setStrategies(mapped); setSelectedId(prev => prev != null && mapped.some(x => x.id === prev) ? prev : null, ); } else { const local = loadStratsLocal(); if (!local.length) return; const migrated: StrategyDto[] = []; for (const s of local) { try { const saved = await saveStrategy({ name: s.name, description: s.description, buyCondition: s.buyCondition, sellCondition: s.sellCondition, enabled: s.enabled ?? true, }); if (saved?.id) { migrated.push({ id: saved.id, name: saved.name, description: saved.description, buyCondition: saved.buyCondition as LogicNode | null ?? null, sellCondition: saved.sellCondition as LogicNode | null ?? null, enabled: saved.enabled ?? true, createdAt: saved.createdAt, updatedAt: saved.updatedAt, }); } } catch { /* skip */ } } if (migrated.length) setStrategies(migrated); } }).catch(() => {}); }, []); const flushBeforeStrategySwitch = useCallback(() => { if (editorMode === 'graph') layoutFlushRef.current?.(); if (strategyAutosaveTimerRef.current != null) { window.clearTimeout(strategyAutosaveTimerRef.current); strategyAutosaveTimerRef.current = null; } const prevId = selectedIdRef.current; const prevName = stratNameRef.current; const prevDesc = stratDescRef.current; if (prevId != null && prevName.trim()) { void persistStrategySnapshot(prevId, prevName, prevDesc).catch(() => { /* switch flush */ }); } }, [editorMode, persistStrategySnapshot]); const handleSelectStrategy = (s: StrategyDto) => { if (selectedId !== s.id) { flushBeforeStrategySwitch(); } const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null); const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null); let stored = normalizeStrategyFlowLayout(s.flowLayout); if (!stored) { stored = readLegacyFlowLayoutFromLocalStorage(String(s.id)); } const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy'); const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell'); const buySynced = syncLogicRootWithGlobalDef( buyMigrated.root, buyMigrated.orphans, DEF, 'buy', ); const sellSynced = syncLogicRootWithGlobalDef( sellMigrated.root, sellMigrated.orphans, DEF, 'sell', ); setSelectedId(s.id); setStratName(s.name); setStratDesc(s.description ?? ''); setBuyCondition(buySynced.root); setSellCondition(sellSynced.root); setBuyOrphans(buySynced.orphans); setSellOrphans(sellSynced.orphans); setBuyLayout({ positions: stored?.buy.positions ?? {}, edgeHandles: stored?.buy.edgeHandles ?? {}, startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored?.buy.startMeta), extraStartIds: stored?.buy.extraStartIds?.length ? stored.buy.extraStartIds : buyDecoded.extraStartIds, extraRoots: stored?.buy.extraRoots && Object.keys(stored.buy.extraRoots).length ? stored.buy.extraRoots : buyDecoded.extraRoots, startCombineOp: normalizeStartCombineOp(stored?.buy.startCombineOp ?? buyDecoded.startCombineOp), }); setSellLayout({ positions: stored?.sell.positions ?? {}, edgeHandles: stored?.sell.edgeHandles ?? {}, startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored?.sell.startMeta), extraStartIds: stored?.sell.extraStartIds?.length ? stored.sell.extraStartIds : sellDecoded.extraStartIds, extraRoots: stored?.sell.extraRoots && Object.keys(stored.sell.extraRoots).length ? stored.sell.extraRoots : sellDecoded.extraRoots, startCombineOp: normalizeStartCombineOp(stored?.sell.startCombineOp ?? sellDecoded.startCombineOp), }); setSelectedNodeId(null); bumpLayoutSeed(String(s.id), signalTab); if (!s.flowLayout && stored) { void (async () => { try { const aligned = alignBuySellStartCandleTypesForSave( toEditorState(buySynced.root, { startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored.buy.startMeta), extraStartIds: stored.buy.extraStartIds ?? buyDecoded.extraStartIds, extraRoots: stored.buy.extraRoots ?? buyDecoded.extraRoots, startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp ?? buyDecoded.startCombineOp), }), toEditorState(sellSynced.root, { startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored.sell.startMeta), extraStartIds: stored.sell.extraStartIds ?? sellDecoded.extraStartIds, extraRoots: stored.sell.extraRoots ?? sellDecoded.extraRoots, startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp ?? sellDecoded.startCombineOp), }), ); await saveStrategy({ id: s.id, name: s.name, description: s.description ?? '', buyCondition: encodeConditionForSave(aligned.buy), sellCondition: encodeConditionForSave(aligned.sell), flowLayout: stored, enabled: s.enabled ?? true, }); clearLegacyFlowLayoutFromLocalStorage(String(s.id)); setStrategies(prev => prev.map(x => x.id === s.id ? { ...x, flowLayout: stored! } : x)); } catch { /* ignore */ } })(); } // flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준) void (async () => { if (!hasRegisteredUser() || s.id == null) return; const exists = await loadStrategy(s.id); if (!exists) return; const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout }); if (!synced) return; try { const fresh = await loadStrategy(s.id); if (!fresh) return; const buyD = decodeConditionForEditor((fresh.buyCondition ?? null) as LogicNode | null); const sellD = decodeConditionForEditor((fresh.sellCondition ?? null) as LogicNode | null); const buyM = migrateLogicRootForEditor(buyD.root, buySynced.orphans, DEF, 'buy'); const sellM = migrateLogicRootForEditor(sellD.root, sellSynced.orphans, DEF, 'sell'); setBuyCondition(syncLogicRootWithGlobalDef(buyM.root, buyM.orphans, DEF, 'buy').root); setSellCondition(syncLogicRootWithGlobalDef(sellM.root, sellM.orphans, DEF, 'sell').root); } catch { /* ignore */ } })(); }; useEffect(() => { if (variant !== 'modal' || initialStrategyId == null || initialStrategyAppliedRef.current) return; if (strategies.length === 0) return; const found = strategies.find(s => s.id === initialStrategyId); if (!found) return; initialStrategyAppliedRef.current = true; handleSelectStrategy(found); // eslint-disable-next-line react-hooks/exhaustive-deps }, [variant, initialStrategyId, strategies]); const handleNew = () => { flushBeforeStrategySwitch(); setSelectedId(null); setStratName(''); setStratDesc(''); setBuyCondition(null); setSellCondition(null); setBuyOrphans([]); setSellOrphans([]); setSelectedNodeId(null); resetFlowLayout('draft', 'buy'); setSignalTab('buy'); }; const openSaveDialog = useCallback((mode: SaveDialogMode) => { setSaveMode(mode); setSaveDraftName(mode === 'saveAs' ? suggestSaveAsName(stratName, strategies) : stratName); setSaveDraftDesc(stratDesc); setSaveNameError(false); setSaveDuplicateError(false); setSaveOpen(true); }, [stratName, stratDesc, strategies]); const handleSave = async () => { const name = saveDraftName.trim(); if (!name) { setSaveNameError(true); showSnack('전략 이름을 입력하세요', false); return; } const asNew = saveMode === 'saveAs' || saveMode === 'create'; const excludeId = asNew ? null : selectedId; if (isDuplicateStrategyName(name, strategies, excludeId)) { setSaveDuplicateError(true); showSnack('같은 이름의 전략이 이미 있습니다', false); return; } setSaveNameError(false); setSaveDuplicateError(false); if (editorMode === 'graph') layoutFlushRef.current?.(); if (strategyAutosaveTimerRef.current != null) { window.clearTimeout(strategyAutosaveTimerRef.current); strategyAutosaveTimerRef.current = null; } const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState); const encodedBuy = encodeConditionForSave(aligned.buy); const encodedSell = encodeConditionForSave(aligned.sell); if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; } setIsSaving(true); try { const flowLayout = buildCurrentFlowLayout(); const payload: ApiStrategyDto = { id: asNew ? undefined : (selectedId ?? undefined), name, description: saveDraftDesc, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout, enabled: true, }; const saved = await saveStrategy(payload); const now = new Date().toISOString(); const dbId = saved?.id ?? (asNew ? Date.now() : selectedId ?? Date.now()); const entry: StrategyDto = { id: dbId, name, description: saveDraftDesc, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now, }; if (asNew) { setStrategies(prev => [...prev, entry]); setSelectedId(dbId); } else if (selectedId) { setStrategies(prev => prev.map(s => (s.id === selectedId ? { ...s, ...entry, id: dbId } : s))); } else { setStrategies(prev => [...prev, entry]); setSelectedId(dbId); } setStratName(name); setStratDesc(saveDraftDesc); bumpLayoutSeed(String(dbId), signalTab); setSaveOpen(false); setSaveToast(true); setTimeout(() => setSaveToast(false), 2500); if (saveMode === 'saveAs') { showSnack('새 전략으로 저장되었습니다'); } else if (saveMode === 'update') { showSnack('전략이 수정되었습니다'); } else { showSnack('전략이 저장되었습니다'); } notifyStrategiesChanged(); } catch (e) { showSnack(e instanceof Error ? e.message : '저장 실패', false); } finally { setIsSaving(false); } }; const handleDeleteConfirm = async () => { if (!deleteId) return; try { await deleteStrategy(deleteId); } catch { /* local */ } clearLegacyFlowLayoutFromLocalStorage(String(deleteId)); setStrategies(prev => prev.filter(s => s.id !== deleteId)); if (selectedId === deleteId) handleNew(); setDeleteOpen(false); setDeleteId(null); showSnack('전략이 삭제되었습니다'); notifyStrategiesChanged(); }; const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: 'buy' | 'sell' = signalTab) => { if (layout) { setBuyLayout({ positions: layout.buy.positions ?? {}, edgeHandles: layout.buy.edgeHandles ?? {}, startMeta: layout.buy.startMeta ?? defaultStartMeta(), extraStartIds: layout.buy.extraStartIds ?? [], extraRoots: layout.buy.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(layout.buy.startCombineOp), }); setSellLayout({ positions: layout.sell.positions ?? {}, edgeHandles: layout.sell.edgeHandles ?? {}, startMeta: layout.sell.startMeta ?? defaultStartMeta(), extraStartIds: layout.sell.extraStartIds ?? [], extraRoots: layout.sell.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(layout.sell.startCombineOp), }); setBuyOrphans(layout.buy.orphans ?? []); setSellOrphans(layout.sell.orphans ?? []); } else { resetFlowLayout('draft', tab); } bumpLayoutSeed('draft', tab); }, [resetFlowLayout, bumpLayoutSeed, signalTab]); const handleExport = useCallback(() => { const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState); const encodedBuy = encodeConditionForSave(aligned.buy); const encodedSell = encodeConditionForSave(aligned.sell); if (!encodedBuy && !encodedSell) { showSnack('내보낼 조건이 없습니다', false); return; } if (editorMode === 'graph') layoutFlushRef.current?.(); const payload = buildStrategyExportPayload({ name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout: { buy: { ...buyLayoutRef.current, orphans: buyOrphans }, sell: { ...sellLayoutRef.current, orphans: sellOrphans }, }, editorMode, }); const safeName = (stratName || '전략').replace(/[^\w\uAC00-\uD7A3-]+/g, '_'); downloadStrategyJson(`${safeName}_${new Date().toISOString().slice(0, 10)}`, payload); showSnack('JSON으로 내보냈습니다'); }, [buyEditorState, sellEditorState, stratName, stratDesc, buyOrphans, sellOrphans, editorMode]); const handleImport = useCallback(async () => { const text = await pickJsonFile(); if (!text) return; try { const result = parseStrategyImportFile(text); if (result.kind === 'list') { showSnack('단일 전략 파일이 아닙니다. 전체 가져오기(⬆)를 사용하세요', false); return; } if (editorMode === 'graph') layoutFlushRef.current?.(); const { data } = result; const buyDecoded = decodeConditionForEditor(data.buyCondition ?? null); const sellDecoded = decodeConditionForEditor(data.sellCondition ?? null); setBuyCondition(buyDecoded.root); setSellCondition(sellDecoded.root); setStratName(data.name ?? '가져온 전략'); setStratDesc(data.description ?? ''); setSelectedId(null); setSelectedNodeId(null); applyImportedFlowLayout(data.flowLayout); setBuyLayout(prev => ({ ...prev, startCombineOp: normalizeStartCombineOp(data.flowLayout?.buy?.startCombineOp ?? buyDecoded.startCombineOp), })); setSellLayout(prev => ({ ...prev, startCombineOp: normalizeStartCombineOp(data.flowLayout?.sell?.startCombineOp ?? sellDecoded.startCombineOp), })); if (data.editorMode === 'list' || data.editorMode === 'graph') { setEditorMode(data.editorMode); saveEditorMode(data.editorMode); } showSnack('전략을 가져왔습니다'); } catch (e) { showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false); } }, [editorMode, applyImportedFlowLayout]); const handleExportAll = useCallback(() => { if (strategies.length === 0) { showSnack('내보낼 전략이 없습니다', false); return; } const items = strategies.map(s => strategyDtoToListExportItem( s, s.flowLayout ?? null, )); downloadStrategyJson( `전략목록_${new Date().toISOString().slice(0, 10)}`, buildStrategyListExportPayload(items), ); showSnack(`${strategies.length}개 전략을 내보냈습니다`); }, [strategies]); const handleImportAll = useCallback(async () => { const text = await pickJsonFile(); if (!text) return; try { const result = parseStrategyImportFile(text); if (result.kind !== 'list') { showSnack('전략 목록 형식 파일이 필요합니다', false); return; } const items = result.data.strategies; if (hasRegisteredUser()) { const persisted: StrategyDto[] = []; let failed = 0; for (const item of items) { try { const saved = await saveStrategy({ name: item.name, description: item.description, buyCondition: item.buyCondition, sellCondition: item.sellCondition, flowLayout: item.flowLayout, enabled: item.enabled ?? true, }); if (saved?.id) { persisted.push({ id: saved.id, name: saved.name, description: saved.description, buyCondition: saved.buyCondition as LogicNode | null ?? null, sellCondition: saved.sellCondition as LogicNode | null ?? null, flowLayout: normalizeStrategyFlowLayout(saved.flowLayout) ?? item.flowLayout, enabled: saved.enabled ?? true, createdAt: saved.createdAt, updatedAt: saved.updatedAt, }); } else { failed += 1; } } catch { failed += 1; } } if (persisted.length === 0) { showSnack('전략 저장에 실패했습니다', false); return; } setStrategies(prev => [...prev, ...persisted]); showSnack( failed > 0 ? `${persisted.length}개 저장, ${failed}개 실패` : `${persisted.length}개 전략을 가져와 저장했습니다`, failed === 0, ); return; } const baseId = Date.now(); const imported = items.map((item, index) => listImportItemToStrategyDto(item, baseId + index)); setStrategies(prev => [...prev, ...imported]); showSnack(`${imported.length}개 전략을 가져왔습니다 (로그인 시 DB에 저장됩니다)`, false); } catch (e) { showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false); } }, []); const handleAddStartSection = useCallback(() => { handleEditorStateChange(addExtraStartSection(currentEditorState)); bumpLayoutSeed(layoutStrategyKey, signalTab); }, [handleEditorStateChange, currentEditorState, bumpLayoutSeed, layoutStrategyKey, signalTab]); const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => { if (type === 'start') { handleAddStartSection(); return; } const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined); const root = currentRoot; if (!root) setCurrentRoot(newNode); else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true)); else setCurrentRoot(mergeAtRoot(root, newNode, false)); scheduleStrategyPersist(); }, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]); const applyPaletteItem = useCallback((item: PaletteItem) => { const stochPair = isStochOverboughtPairPaletteValue(item.value); const priceExtremeBreakout = isPriceExtremeBreakoutPairPaletteValue(item.value); const inflection33 = isInflection33PaletteValue(item.value); const stableStrategy = isStableStrategyPaletteValue(item.value); const composite = item.kind === 'composite' && !stochPair && !priceExtremeBreakout && !inflection33 && !stableStrategy; const newNode = stochPair ? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', DEF) : priceExtremeBreakout ? buildPriceExtremeBreakoutTree(item.value, DEF) : inflection33 ? buildInflection33Tree(item.value, DEF) : stableStrategy ? buildStableStrategyTree(item.value, DEF) : makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined); if (!newNode) return; const root = currentRoot; if (!root) setCurrentRoot(newNode); else setCurrentRoot(mergeAtRoot(root, newNode, false)); scheduleStrategyPersist(); }, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]); const applySidewaysFilter = useCallback((filterId: string) => { const newNode = buildSidewaysFilterNode(filterId, DEF, sidewaysPalette); if (!newNode) return; const root = currentRoot; if (!root) setCurrentRoot(newNode); else setCurrentRoot(mergeAtRoot(root, newNode, false)); scheduleStrategyPersist(); }, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]); const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]); const templateRows = useMemo(() => { const userRows: TemplatePaletteRow[] = userTemplates.map(t => ({ key: t.id, source: 'user', label: t.label, description: t.description, signal: t.signal, user: t, })); const hidden = new Set(hiddenBuiltinTemplateIds); const builtinRows: TemplatePaletteRow[] = templates .filter(t => !hidden.has(getBuiltinTemplateId(t))) .map(t => { const builtinId = getBuiltinTemplateId(t); return { key: builtinId, source: 'builtin', label: t.label, description: 'description' in t ? t.description : undefined, signal: t.signal, builtinId, builtin: t, }; }); const compositeRows: TemplatePaletteRow[] = buildCompositePaletteTemplateRows(compositePalette, hidden); const sidewaysRows: TemplatePaletteRow[] = buildSidewaysPaletteTemplateRows(sidewaysPalette, hidden); return [...userRows, ...builtinRows, ...compositeRows, ...sidewaysRows]; }, [userTemplates, templates, hiddenBuiltinTemplateIds, compositePalette, sidewaysPalette]); const filteredTemplateRows = useMemo(() => { const q = templateSearch.trim().toLowerCase(); if (!q) return templateRows; return templateRows.filter(row => { const signalLabel = row.signal === 'buy' ? '매수' : '매도'; const badge = row.source === 'user' ? '내 템플릿' : row.source === 'composite-palette' ? '복합지표' : row.source === 'sideways-palette' ? '횡보지표' : ''; return ( row.label.toLowerCase().includes(q) || row.description?.toLowerCase().includes(q) || signalLabel.includes(q) || row.signal.includes(q) || badge.includes(q) ); }); }, [templateRows, templateSearch]); const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => { if (editorMode === 'graph') layoutFlushRef.current?.(); const targetTab = tmpl.signal; const nextRoot = tmpl.kind === 'composite' ? tmpl.build() : simpleTemplateToNode(tmpl); setBuyCondition(null); setSellCondition(null); setSelectedNodeId(null); if (targetTab === 'buy') setBuyCondition(nextRoot); else setSellCondition(nextRoot); resetFlowLayout(layoutStrategyKey, targetTab); if (targetTab !== signalTab) setSignalTab(targetTab); scheduleStrategyPersist(); showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); }, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]); const applyUserTemplate = useCallback((tmpl: UserStrategyTemplate) => { if (editorMode === 'graph') layoutFlushRef.current?.(); const targetTab = tmpl.signal; const layoutFromState = { startMeta: tmpl.editorState.startMeta, extraStartIds: tmpl.editorState.extraStartIds, extraRoots: tmpl.editorState.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(tmpl.editorState.startCombineOp), positions: {}, edgeHandles: {}, }; setBuyCondition(null); setSellCondition(null); setBuyOrphans([]); setSellOrphans([]); setSelectedNodeId(null); if (targetTab === 'buy') { setBuyCondition(tmpl.editorState.root); setBuyLayout(prev => ({ ...prev, ...layoutFromState })); setBuyOrphans(tmpl.orphans); setSellLayout(normalizeTabLayoutState(emptySignalFlowLayout())); } else { setSellCondition(tmpl.editorState.root); setSellLayout(prev => ({ ...prev, ...layoutFromState })); setSellOrphans(tmpl.orphans); setBuyLayout(normalizeTabLayoutState(emptySignalFlowLayout())); } bumpLayoutSeed(layoutStrategyKey, targetTab); if (targetTab !== signalTab) setSignalTab(targetTab); scheduleStrategyPersist(); showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); }, [ editorMode, layoutStrategyKey, bumpLayoutSeed, signalTab, scheduleStrategyPersist, ]); const handleDeleteUserTemplate = useCallback((id: string, label: string) => { if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return; setUserTemplates(deleteUserStrategyTemplate(id)); showSnack('템플릿을 삭제했습니다'); }, []); const handleDeleteBuiltinTemplate = useCallback((id: string, label: string) => { if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return; setHiddenBuiltinTemplateIds(hideBuiltinTemplate(id)); showSnack('템플릿을 목록에서 제거했습니다'); }, []); const openTemplateAddModal = useCallback(() => { const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0; if (!hasContent) { showSnack('저장할 조건이 없습니다', false); return; } setTemplateModalMode('add'); setTemplateEditId(null); setTemplateSaveUpdateContent(false); setTemplateSaveLabel(stratName.trim() || `${signalTab === 'buy' ? '매수' : '매도'} 템플릿`); setTemplateSaveDesc(''); setTemplateSaveLabelError(false); setTemplateSaveOpen(true); }, [currentEditorState, currentOrphans.length, stratName, signalTab]); const openTemplateEditModal = useCallback(() => { if (!selectedTemplateKey) { window.alert('수정할 템플릿을 먼저 선택해 주세요.'); return; } const row = templateRows.find(r => r.key === selectedTemplateKey); if (!row) return; if (row.source !== 'user' || !row.user) { window.alert('내 템플릿만 수정할 수 있습니다.'); return; } setTemplateModalMode('edit'); setTemplateEditId(row.user.id); setTemplateSaveUpdateContent(false); setTemplateSaveLabel(row.user.label); setTemplateSaveDesc(row.user.description ?? ''); setTemplateSaveLabelError(false); setTemplateSaveOpen(true); }, [selectedTemplateKey, templateRows]); const handleTemplateToolbarDelete = useCallback(() => { if (!selectedTemplateKey) { window.alert('삭제할 템플릿을 먼저 선택해 주세요.'); return; } const row = templateRows.find(r => r.key === selectedTemplateKey); if (!row) return; if (row.source === 'user' && row.user) { handleDeleteUserTemplate(row.user.id, row.label); } else if (row.source === 'builtin' && row.builtinId) { handleDeleteBuiltinTemplate(row.builtinId, row.label); } else if (row.source === 'composite-palette' || row.source === 'sideways-palette') { handleDeleteBuiltinTemplate(row.key, row.label); } setSelectedTemplateKey(null); }, [selectedTemplateKey, templateRows, handleDeleteUserTemplate, handleDeleteBuiltinTemplate]); const handleTemplateApply = useCallback((row: TemplatePaletteRow) => { if (row.source === 'user' && row.user) { applyUserTemplate(row.user); return; } if (row.source === 'builtin' && row.builtin) { handleTemplate(row.builtin); return; } if (row.source === 'composite-palette' && row.paletteItem) { applyPaletteItem(row.paletteItem); showSnack(`"${row.label}" 조건이 추가됐습니다`); return; } if (row.source === 'sideways-palette' && row.sidewaysFilterId) { applySidewaysFilter(row.sidewaysFilterId); showSnack(`"${row.label}" 필터가 추가됐습니다`); } }, [applyUserTemplate, handleTemplate, applyPaletteItem, applySidewaysFilter]); const handleTemplateSaveConfirm = useCallback(() => { const label = templateSaveLabel.trim(); if (!label) { setTemplateSaveLabelError(true); return; } if (templateModalMode === 'edit' && templateEditId) { const patch: Parameters[1] = { label, description: templateSaveDesc.trim() || undefined, }; if (templateSaveUpdateContent) { const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0; if (!hasContent) { showSnack('갱신할 조건이 없습니다', false); return; } patch.signal = signalTab; patch.editorState = toEditorState(currentRoot, currentLayout); patch.orphans = currentOrphans; } setUserTemplates(updateUserStrategyTemplate(templateEditId, patch)); setTemplateSaveOpen(false); showSnack(`"${label}" 템플릿을 수정했습니다`); return; } const saved = addUserStrategyTemplate({ label, description: templateSaveDesc.trim() || undefined, signal: signalTab, editorState: toEditorState(currentRoot, currentLayout), orphans: currentOrphans, }); setUserTemplates(prev => [...prev, saved]); setSelectedTemplateKey(saved.id); setTemplateSaveOpen(false); setRightTab('templates'); showSnack(`"${saved.label}" 템플릿을 저장했습니다`); }, [ templateSaveLabel, templateSaveDesc, templateModalMode, templateEditId, templateSaveUpdateContent, signalTab, currentRoot, currentLayout, currentOrphans, currentEditorState, ]); const handleQuickRun = useCallback(async () => { if (!selectedId) { window.alert('백테스팅할 전략을 먼저 선택해주세요.'); return; } setQbLoading(true); setQbError(null); try { const selectedStrategy = strategies.find(s => s.id === selectedId); const strategyTimeframe = resolveStrategyPrimaryTimeframe( selectedStrategy, { buy: buyEditorState, sell: sellEditorState }, ); const execTimeframe = resolveBacktestExecTimeframe(qbTimeframe, strategyTimeframe); const [settings, indicatorParams] = await Promise.all([ loadBacktestSettings(), loadIndicatorSettings(), ]); const { bars, evaluationBarCount } = await fetchBacktestCandleBundle({ market: qbMarket, timeframe: execTimeframe as import('../types').Timeframe, buyDsl: selectedStrategy?.buyCondition, sellDsl: selectedStrategy?.sellCondition, indicatorParams, }); if (bars.length < 5) { throw new Error('캔들 데이터가 부족합니다. 타임프레임을 변경하거나 잠시 후 다시 시도해주세요.'); } const result = await runBacktest({ strategyId: selectedId, bars, timeframe: execTimeframe, settings, indicatorParams, symbol: qbMarket, strategyName: selectedStrategy?.name, evaluationBarCount, }); if (!result) throw new Error('백테스팅 실행에 실패했습니다.'); if (result.resultId) { try { sessionStorage.setItem(BACKTEST_FOCUS_KEY, String(result.resultId)); } catch { /* ignore */ } } onNavigateToBacktest?.(); } catch (e) { setQbError(e instanceof Error ? e.message : '실행 중 오류가 발생했습니다.'); } finally { setQbLoading(false); } }, [selectedId, strategies, qbMarket, qbTimeframe, buyEditorState, sellEditorState, onNavigateToBacktest]); const qbStrategyTimeframe = useMemo( () => resolveStrategyPrimaryTimeframe( selectedId ? strategies.find(s => s.id === selectedId) : undefined, { buy: buyEditorState, sell: sellEditorState }, ), [selectedId, strategies, buyEditorState, sellEditorState], ); const qbTimeframeOptions = useMemo( () => buildQuickRunTimeframeSelectOptions(qbStrategyTimeframe), [qbStrategyTimeframe], ); const operators = [ { type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' }, { type: 'operator' as const, value: 'OR', label: 'OR', color: 'logic-or' }, { type: 'operator' as const, value: 'NOT', label: 'NOT', color: 'logic-not' }, ]; const maBandItems = [ { type: 'indicator' as const, value: 'MA', label: 'MA', desc: '이동평균', color: 'band' }, { type: 'indicator' as const, value: 'EMA', label: 'EMA', desc: '지수이동평균', color: 'band' }, { type: 'indicator' as const, value: 'BOLLINGER', label: 'Bollinger', desc: '볼린저밴드', color: 'band' }, { type: 'indicator' as const, value: 'DONCHIAN', label: 'Donchian', desc: '돈치안 채널', color: 'band' }, { type: 'indicator' as const, value: 'ICHIMOKU', label: 'Ichimoku', desc: '일목균형표', color: 'band' }, ]; const q = paletteSearch.trim().toLowerCase(); const match = (label: string, desc?: string) => !q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false); const paletteKey = (type: string, id: string) => `${type}:${id}`; const selectPalette = (type: string, id: string) => { setSelectedPaletteKey(paletteKey(type, id)); }; const isPaletteSelected = (type: string, id: string) => selectedPaletteKey === paletteKey(type, id); return (
{needsPointerPaletteDrag() ? : null} {saveToast && (
전략이 DB에 성공적으로 저장되었습니다.
)}

전략 빌더

Strategy Builder {!settingsLoaded && ( · 보조지표 설정 로드 중… )}
{/* 빠른 백테스팅 실행 툴바 */}
{qbMarketOpen && ReactDOM.createPortal( { setQbMarket(market); setQbMarketOpen(false); setQbMarketQ(''); }} onClose={() => { setQbMarketOpen(false); setQbMarketQ(''); }} anchorRect={qbMarketBtnRef.current?.getBoundingClientRect() ?? null} anchorPlacement="dropdown" />, document.body, )} {qbError && ( ⚠ {qbError} )}
{variant === 'modal' && onClose && ( )}
{stratName && {stratName}} {hasMultipleStartSections(currentEditorState) && ( )}
{editorMode === 'graph' ? ( <> {selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
{stochPairEditForSelected && !stochPairEditForSelected.stochLocked ? 'Stoch 과열×보조' : selectedLogicNode.condition.indicatorType} { if (!selectedNodeId) return; const inOrphans = currentOrphans.some(o => o.id === selectedNodeId); if (inOrphans) { handleOrphansChange(currentOrphans.map(o => ( o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o ))); return; } if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) { if (findLogicNode(currentRoot, [], selectedNodeId)) { handleRootChange(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c }))); return; } for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) { if (branch && findLogicNode(branch, [], selectedNodeId)) { handleExtraRootsChange({ ...(currentLayout.extraRoots ?? {}), [startId]: updateNode(branch, selectedNodeId, n => ({ ...n, condition: c })), }); return; } } } }} /> 전략 조건 전용 설정 · 차트 보조지표와 무관
)} {selectedLogicNode && isStochOverboughtPairRoot(selectedLogicNode) && selectedLogicNode.stochPair && (
Stoch 과열×보조 applyStochPairSecondary(selectedLogicNode.id, next)} onClose={() => {}} />
)} {selectedLogicNode && isPriceExtremeBreakoutPairRoot(selectedLogicNode) && selectedLogicNode.priceExtremePair && (
9·20 신고가/신저가 필터 applyPriceExtremeFilterLevel(selectedLogicNode.id, next)} onClose={() => {}} />
)} {selectedLogicNode && isInflection33PairRoot(selectedLogicNode) && selectedLogicNode.inflection33Pair && (
33변곡 필터 applyInflection33FilterLevel(selectedLogicNode.id, next)} onClose={() => {}} />
)} {selectedLogicNode && isStableStrategyPairRoot(selectedLogicNode) && selectedLogicNode.stableStrategyPair && (
추천 전략 필터 applyStableStrategyFilterLevel(selectedLogicNode.id, next)} onClose={() => {}} />
)} ) : ( )}
LOGIC EXPRESSION
조건 설명
{rightOpen && (
)}
{templateSaveOpen && ( { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }} title={templateModalMode === 'edit' ? '템플릿 수정' : '템플릿 저장'} >

{templateModalMode === 'edit' ? <>선택한 내 템플릿의 이름·설명을 수정합니다. : <>현재 {signalTab === 'buy' ? '매수' : '매도'} 탭 조건을 템플릿 목록에 저장합니다.}

{ setTemplateSaveLabel(e.target.value); setTemplateSaveLabelError(false); }} placeholder="예: RSI 과매도 + MA 골든" /> {templateSaveLabelError && (

템플릿 이름을 입력하세요

)}