/** * 전략편집기 — 노드 기반 논리 트리 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 { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi'; 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, syncBuySellPrimaryStartCandleTypes, mergeStartMetaForLoad, addExtraStartSection, hasMultipleStartSections, normalizeStartCombineOp, updateStartCombineOp, updateStartCandleTypes, type EditorConditionState, } from '../utils/strategyConditionSerde'; import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize'; import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync'; import { START_NODE_ID, defaultStartMeta, type StartCombineOp, } from '../utils/strategyStartNodes'; import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab'; import { loadPaletteItems, type PaletteItem, } from '../utils/strategyPaletteStorage'; import { emptySignalFlowLayout, loadStrategyFlowLayout, migrateStrategyFlowLayout, saveStrategyFlowLayout, deleteStrategyFlowLayout, type SignalFlowLayoutSnapshot, type FlowLayoutChangePayload, type StrategyFlowLayoutStore, } from '../utils/strategyEditorLayoutStorage'; import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview'; 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, simpleTemplateToNode, type StrategyTemplateDef, } from '../utils/strategyPresets'; import { buildStrategyExportPayload, buildStrategyListExportPayload, downloadStrategyJson, listImportItemToStrategyDto, parseStrategyImportFile, pickJsonFile, strategyDtoToListExportItem, } from '../utils/strategyImportExport'; import PaletteChip from './strategyEditor/PaletteChip'; import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal'; import '../styles/strategyEditor.css'; import '../styles/strategyEditorTheme.css'; const LEFT_PANEL_MIN = 220; const LEFT_PANEL_MAX = 520; const LEFT_PANEL_DEFAULT = 280; const TERMINAL_MIN = 88; const TERMINAL_MAX = 420; const TERMINAL_DEFAULT = 140; interface Props { theme: Theme; } function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot { const stored = loadStrategyFlowLayout(strategyKey); const snap = stored?.[tab]; if (!snap) return emptySignalFlowLayout(); return { positions: snap.positions ?? {}, edgeHandles: snap.edgeHandles ?? {}, orphans: snap.orphans ?? [], startMeta: snap.startMeta ?? defaultStartMeta(), extraStartIds: snap.extraStartIds ?? [], extraRoots: snap.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(snap.startCombineOp), }; } 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), }; } export default function StrategyEditorPage({ theme }: Props) { const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings(); const DEF = useMemo( () => buildStrategyEditorDefFromSettings(getParams, getVisualConfig), [getParams, getVisualConfig, settingsRevision], ); const settingsSyncRef = useRef(-1); 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'>('auxiliary'); const [auxiliaryPalette, setAuxiliaryPalette] = useState(() => loadPaletteItems('auxiliary')); const [compositePalette, setCompositePalette] = useState(() => loadPaletteItems('composite')); const [paletteSearch, setPaletteSearch] = useState(''); const [selectedPaletteKey, setSelectedPaletteKey] = useState(null); const [stratName, setStratName] = useState(''); const [stratDesc, setStratDesc] = useState(''); const [isSaving, setIsSaving] = useState(false); const [saveOpen, setSaveOpen] = useState(false); const [saveNameError, setSaveNameError] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [descOpen, setDescOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const initialDraftBuy = readTabLayout('draft', 'buy'); const initialDraftSell = readTabLayout('draft', 'sell'); const [buyOrphans, setBuyOrphans] = useState(() => initialDraftBuy.orphans ?? []); const [sellOrphans, setSellOrphans] = useState(() => initialDraftSell.orphans ?? []); const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null); const [saveToast, setSaveToast] = useState(false); const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT)); const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT)); const leftWidthRef = useRef(leftWidth); const terminalHeightRef = useRef(terminalHeight); const [buyLayout, setBuyLayout] = useState(() => ({ positions: initialDraftBuy.positions, edgeHandles: initialDraftBuy.edgeHandles, startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(), extraStartIds: initialDraftBuy.extraStartIds ?? [], extraRoots: initialDraftBuy.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp), })); const [sellLayout, setSellLayout] = useState(() => ({ positions: initialDraftSell.positions, edgeHandles: initialDraftSell.edgeHandles, startMeta: initialDraftSell.startMeta ?? defaultStartMeta(), extraStartIds: initialDraftSell.extraStartIds ?? [], extraRoots: initialDraftSell.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp), })); const buyLayoutRef = useRef(buyLayout); const sellLayoutRef = useRef(sellLayout); const buyConditionRef = useRef(buyCondition); const sellConditionRef = useRef(sellCondition); buyLayoutRef.current = buyLayout; sellLayoutRef.current = sellLayout; buyConditionRef.current = buyCondition; sellConditionRef.current = sellCondition; const timeframeAutosaveRef = useRef(null); const layoutRevisionRef = useRef(0); const persistLayoutTimerRef = useRef(null); const layoutPersistReadyRef = useRef(false); leftWidthRef.current = leftWidth; 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 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-terminal-height': `${terminalHeight}px`, }) as React.CSSProperties, [leftWidth, 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 selectedLogicNode = useMemo(() => { if (!selectedNodeId) return null; return findLogicNode( currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {}, ); }, [selectedNodeId, currentRoot, currentOrphans, currentLayout.extraRoots]); const orphanTotal = buyOrphans.length + sellOrphans.length; const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft'; const persistFlowLayout = useCallback((strategyKey: string) => { saveStrategyFlowLayout(strategyKey, { buy: { ...buyLayoutRef.current, orphans: buyOrphans }, sell: { ...sellLayoutRef.current, orphans: sellOrphans }, }); }, [buyOrphans, sellOrphans]); const schedulePersistFlowLayout = useCallback((strategyKey: string) => { if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current); persistLayoutTimerRef.current = window.setTimeout(() => { persistLayoutTimerRef.current = null; persistFlowLayout(strategyKey); }, 150); }, [persistFlowLayout]); 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); }, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]); const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => { const emptyBuy = emptySignalFlowLayout(); const emptySell = emptySignalFlowLayout(); setBuyLayout(normalizeTabLayoutState(emptyBuy)); setSellLayout(normalizeTabLayoutState(emptySell)); setBuyOrphans([]); setSellOrphans([]); saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell }); bumpLayoutSeed(strategyKey, tab); }, [bumpLayoutSeed]); const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => { const stored = loadStrategyFlowLayout(strategyKey); if (stored) { const nextBuy = { positions: stored.buy.positions ?? {}, edgeHandles: stored.buy.edgeHandles ?? {}, startMeta: stored.buy.startMeta ?? defaultStartMeta(), extraStartIds: stored.buy.extraStartIds ?? [], extraRoots: stored.buy.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp), }; const nextSell = { positions: stored.sell.positions ?? {}, edgeHandles: stored.sell.edgeHandles ?? {}, startMeta: stored.sell.startMeta ?? defaultStartMeta(), extraStartIds: stored.sell.extraStartIds ?? [], extraRoots: stored.sell.extraRoots ?? {}, startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp), }; setBuyLayout(nextBuy); setSellLayout(nextSell); setBuyOrphans(stored.buy.orphans ?? []); setSellOrphans(stored.sell.orphans ?? []); } else { const emptyBuy = emptySignalFlowLayout(); const emptySell = emptySignalFlowLayout(); setBuyLayout(normalizeTabLayoutState(emptyBuy)); setSellLayout(normalizeTabLayoutState(emptySell)); 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 })); schedulePersistFlowLayout(layoutStrategyKey); }, [layoutStrategyKey, schedulePersistFlowLayout]); const handleStartMetaChange = useCallback((meta: Record) => { setCurrentLayout(prev => ({ ...prev, startMeta: meta })); schedulePersistFlowLayout(layoutStrategyKey); }, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]); const handleExtraStartIdsChange = useCallback((ids: string[]) => { setCurrentLayout(prev => ({ ...prev, extraStartIds: ids })); schedulePersistFlowLayout(layoutStrategyKey); bumpLayoutSeed(layoutStrategyKey, signalTab); }, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]); const handleExtraRootsChange = useCallback((roots: Record) => { setCurrentLayout(prev => ({ ...prev, extraRoots: roots })); schedulePersistFlowLayout(layoutStrategyKey); }, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]); const handleEditorStateChange = useCallback((next: EditorConditionState) => { setCurrentRoot(next.root); setCurrentLayout(prev => ({ ...prev, startMeta: next.startMeta, extraStartIds: next.extraStartIds, extraRoots: next.extraRoots, startCombineOp: normalizeStartCombineOp(next.startCombineOp), })); schedulePersistFlowLayout(layoutStrategyKey); }, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]); const scheduleTimeframePersist = useCallback(() => { if (selectedId == null || !stratName.trim()) return; if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current); const id = selectedId; const name = stratName; const desc = stratDesc; timeframeAutosaveRef.current = window.setTimeout(() => { timeframeAutosaveRef.current = null; const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current); const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current); void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => { console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err); }); }, 700); }, [selectedId, stratName, stratDesc]); const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => { if (startId === START_NODE_ID) { const synced = syncBuySellPrimaryStartCandleTypes(buyEditorState, sellEditorState, candleTypes); setBuyCondition(synced.buy.root); setBuyLayout(prev => ({ ...prev, startMeta: synced.buy.startMeta, extraStartIds: synced.buy.extraStartIds, extraRoots: synced.buy.extraRoots, startCombineOp: normalizeStartCombineOp(synced.buy.startCombineOp), })); setSellCondition(synced.sell.root); setSellLayout(prev => ({ ...prev, startMeta: synced.sell.startMeta, extraStartIds: synced.sell.extraStartIds, extraRoots: synced.sell.extraRoots, startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp), })); schedulePersistFlowLayout(layoutStrategyKey); scheduleTimeframePersist(); return; } const state = signalTab === 'buy' ? buyEditorState : sellEditorState; handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes)); scheduleTimeframePersist(); }, [ buyEditorState, sellEditorState, handleEditorStateChange, layoutStrategyKey, schedulePersistFlowLayout, scheduleTimeframePersist, signalTab, ]); const handleStartCombineOpChange = useCallback((op: StartCombineOp) => { handleEditorStateChange(updateStartCombineOp(currentEditorState, op)); }, [handleEditorStateChange, currentEditorState]); const switchSignalTab = useCallback((tab: 'buy' | 'sell') => { if (editorMode === 'graph') layoutFlushRef.current?.(); persistFlowLayout(layoutStrategyKey); layoutRevisionRef.current += 1; setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`); setSignalTab(tab); setSelectedNodeId(null); }, [layoutStrategyKey, persistFlowLayout, editorMode]); const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => { if (mode === editorMode) return; if (editorMode === 'graph') { layoutFlushRef.current?.(); persistFlowLayout(layoutStrategyKey); } if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) { showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false); } setEditorMode(mode); saveEditorMode(mode); setSelectedNodeId(null); }, [editorMode, layoutStrategyKey, persistFlowLayout, 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]); useEffect(() => () => { if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current); }, []); useEffect(() => { if (!layoutPersistReadyRef.current) { layoutPersistReadyRef.current = true; return; } schedulePersistFlowLayout(layoutStrategyKey); }, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]); useEffect(() => { saveStratsLocal(strategies); }, [strategies]); useEffect(() => { loadStrategies().then(async list => { if (list?.length) { setStrategies(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, enabled: s.enabled ?? true, createdAt: s.createdAt, updatedAt: s.updatedAt, }))); } 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 handleSelectStrategy = (s: StrategyDto) => { const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null); const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null); const stored = loadStrategyFlowLayout(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); }; const handleNew = () => { setSelectedId(null); setStratName(''); setStratDesc(''); setBuyCondition(null); setSellCondition(null); setSelectedNodeId(null); resetFlowLayout('draft', signalTab); }; const handleSave = async () => { if (!stratName.trim()) { setSaveNameError(true); showSnack('전략 이름을 입력하세요', false); return; } setSaveNameError(false); if (editorMode === 'graph') layoutFlushRef.current?.(); 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 payload: ApiStrategyDto = { id: selectedId ?? undefined, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, }; const saved = await saveStrategy(payload); const now = new Date().toISOString(); const dbId = saved?.id ?? selectedId ?? Date.now(); const wasDraft = selectedId == null; setStrategies(prev => { const existing = prev.find(s => s.id === selectedId); if (existing && selectedId) { return prev.map(s => s.id === selectedId ? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now } : s); } setSelectedId(dbId); return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }]; }); if (!selectedId) setSelectedId(dbId); if (wasDraft) { migrateStrategyFlowLayout('draft', String(dbId)); } persistFlowLayout(String(dbId)); bumpLayoutSeed(String(dbId), signalTab); setSaveOpen(false); setSaveToast(true); setTimeout(() => setSaveToast(false), 2500); showSnack(selectedId ? '전략이 수정되었습니다' : '전략이 저장되었습니다'); } catch (e) { showSnack(e instanceof Error ? e.message : '저장 실패', false); } finally { setIsSaving(false); } }; const handleDeleteConfirm = async () => { if (!deleteId) return; try { await deleteStrategy(deleteId); } catch { /* local */ } deleteStrategyFlowLayout(String(deleteId)); setStrategies(prev => prev.filter(s => s.id !== deleteId)); if (selectedId === deleteId) handleNew(); setDeleteOpen(false); setDeleteId(null); showSnack('전략이 삭제되었습니다'); }; 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 ?? []); saveStrategyFlowLayout('draft', layout); } 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, loadStrategyFlowLayout(String(s.id)), )); 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 baseId = Date.now(); const imported = result.data.strategies.map((item, index) => { const id = baseId + index; if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout); return listImportItemToStrategyDto(item, id); }); setStrategies(prev => [...prev, ...imported]); showSnack(`${imported.length}개 전략을 가져왔습니다`); } 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)); }, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]); const applyPaletteItem = useCallback((item: PaletteItem) => { const composite = item.kind === 'composite'; const newNode = makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined); const root = currentRoot; if (!root) setCurrentRoot(newNode); else setCurrentRoot(mergeAtRoot(root, newNode, false)); }, [signalTab, DEF, currentRoot, setCurrentRoot]); const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]); 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); showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); }, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]); 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 (
{saveToast && (
전략이 DB에 성공적으로 저장되었습니다.
)}

전략 빌더

Strategy Builder {!settingsLoaded && ( · 보조지표 설정 로드 중… )}
{stratName && {stratName}} {hasMultipleStartSections(currentEditorState) && ( )}
{editorMode === 'graph' ? ( <> {selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
{selectedLogicNode.condition.indicatorType} { if (!selectedNodeId) return; const inOrphans = currentOrphans.some(o => o.id === selectedNodeId); if (inOrphans) { setCurrentOrphans(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)) { setCurrentRoot(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; } } } }} /> 전략 조건 전용 설정 · 차트 보조지표와 무관
)} ) : ( )}
LOGIC EXPRESSION
{saveOpen && ( { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
{ setStratName(e.target.value); setSaveNameError(false); }} placeholder="예: Golden_RSI_V1" /> {saveNameError && (

전략 이름을 입력하세요

)}