Files
goldenChart/frontend/src/components/StrategyEditorPage.tsx
T
2026-06-18 02:05:09 +09:00

2502 lines
100 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 전략편집기 — 노드 기반 논리 트리 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,
updateBranchRoot,
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<SignalFlowLayoutSnapshot, 'startMeta' | 'extraStartIds' | 'extraRoots' | 'startCombineOp'>,
): 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<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
setSellLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
setBuyOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
setSellOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
) {
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<StrategyDto[]>(() => loadStratsLocal());
const [selectedId, setSelectedId] = useState<number | null>(null);
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
const [sellCondition, setSellCondition] = useState<LogicNode | null>(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<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
const [paletteSearch, setPaletteSearch] = useState('');
const [templateSearch, setTemplateSearch] = useState('');
const [userTemplates, setUserTemplates] = useState<UserStrategyTemplate[]>(() => loadUserStrategyTemplates());
const [hiddenBuiltinTemplateIds, setHiddenBuiltinTemplateIds] = useState<string[]>(
() => loadHiddenBuiltinTemplateIds(),
);
const [templateSaveOpen, setTemplateSaveOpen] = useState(false);
const [templateModalMode, setTemplateModalMode] = useState<'add' | 'edit'>('add');
const [templateEditId, setTemplateEditId] = useState<string | null>(null);
const [templateSaveUpdateContent, setTemplateSaveUpdateContent] = useState(false);
const [templateSaveLabel, setTemplateSaveLabel] = useState('');
const [templateSaveDesc, setTemplateSaveDesc] = useState('');
const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false);
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string | null>(null);
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
const [stratName, setStratName] = useState('');
const [stratDesc, setStratDesc] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveOpen, setSaveOpen] = useState(false);
const [saveMode, setSaveMode] = useState<SaveDialogMode>('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<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
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<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
const [qbLoading, setQbLoading] = useState(false);
const [qbError, setQbError] = useState<string | null>(null);
const [qbMarketOpen, setQbMarketOpen] = useState(false);
const [qbMarketQ, setQbMarketQ] = useState('');
const qbMarketBtnRef = useRef<HTMLButtonElement>(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<number | null>(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<StrategyEditorMode>(() => 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<string, LogicNode | null>,
signalType: 'buy' | 'sell',
) => {
const next: Record<string, LogicNode | null> = {};
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<string, import('../utils/strategyStartNodes').StartNodeMeta>) => {
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<string, LogicNode | null>) => {
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;
currentEditorStateRef.current = resolved;
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(prev => updateStartCandleTypes(prev, startId, candleTypes));
}, [handleEditorStateChange]);
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
handleEditorStateChange(prev => updateStartCombineOp(prev, op));
}, [handleEditorStateChange]);
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(prev => addExtraStartSection(prev));
bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [handleEditorStateChange, 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);
handleEditorStateChange(prev => {
const root = prev.root;
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
if (type === 'operator') {
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, true));
}
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
});
}, [signalTab, DEF, handleEditorStateChange, handleAddStartSection]);
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;
handleEditorStateChange(prev => {
const root = prev.root;
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
});
}, [signalTab, DEF, handleEditorStateChange]);
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<typeof updateUserStrategyTemplate>[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 (
<div className={`se-page se-page--${theme}${variant === 'modal' ? ' se-page--modal' : ''}`}>
{needsPointerPaletteDrag() ? <PaletteDragOverlay /> : null}
{saveToast && (
<div className="se-save-toast">전략이 DB에 성공적으로 저장되었습니다.</div>
)}
<header className="se-header">
<div className="se-header-left">
<h1 className="se-title">전략 빌더</h1>
<span className="se-subtitle">
Strategy Builder
{!settingsLoaded && (
<span className="se-subtitle-hint"> · 보조지표 설정 로드 중…</span>
)}
</span>
</div>
{/* 빠른 백테스팅 실행 툴바 */}
<div className="se-quick-run">
<button
ref={qbMarketBtnRef}
type="button"
className="se-qr-market-btn"
disabled={qbLoading}
title="종목 검색"
onClick={() => { setQbMarketQ(''); setQbMarketOpen(v => !v); }}
>
<span className="se-qr-market-ko">{getKoreanName(qbMarket)}</span>
<span className="se-qr-market-sym">{qbMarket.replace('KRW-', '')}</span>
<svg className="se-qr-market-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{qbMarketOpen && ReactDOM.createPortal(
<MarketSearchPanel
currentMarket={qbMarket}
query={qbMarketQ}
onQueryChange={setQbMarketQ}
onSelect={market => { setQbMarket(market); setQbMarketOpen(false); setQbMarketQ(''); }}
onClose={() => { setQbMarketOpen(false); setQbMarketQ(''); }}
anchorRect={qbMarketBtnRef.current?.getBoundingClientRect() ?? null}
anchorPlacement="dropdown"
/>,
document.body,
)}
<select
className="se-qr-select"
value={qbTimeframe}
onChange={e => setQbTimeframe(parseBacktestRunTimeframeChoice(e.target.value))}
disabled={qbLoading}
title="백테스팅 실행 시간봉"
>
{qbTimeframeOptions.map(tf => (
<option key={tf.value} value={tf.value}>{tf.label}</option>
))}
</select>
<select
className="se-qr-select se-qr-select--strategy"
value={selectedId ?? ''}
onChange={e => {
const id = e.target.value ? Number(e.target.value) : null;
if (!id) return;
const s = strategies.find(st => st.id === id);
if (s) handleSelectStrategy(s);
}}
disabled={qbLoading}
title="전략 선택"
>
<option value="">전략 선택</option>
{strategies.map(s => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
<button
type="button"
className={`se-qr-run-btn${qbLoading ? ' se-qr-run-btn--loading' : ''}`}
disabled={!selectedId || qbLoading}
onClick={() => { void handleQuickRun(); }}
title={selectedId ? '백테스팅 실행' : '전략을 먼저 선택하세요'}
>
{qbLoading ? (
<span className="se-qr-spinner" />
) : (
<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true">
<path d="M3 2.5l10 5.5-10 5.5V2.5z"/>
</svg>
)}
</button>
{qbError && (
<span className="se-qr-error" title={qbError}> {qbError}</span>
)}
</div>
<div className="se-header-actions">
{variant === 'modal' && onClose && (
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon se-btn--modal-close"
title="닫기"
aria-label="전략 편집 닫기"
onClick={onClose}
>
×
</button>
)}
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon se-btn--desc"
title="전략 설명 — 현재 조건을 서술형으로 보기"
aria-label="전략 설명"
onClick={() => setDescOpen(true)}
>
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden className="se-desc-icon">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.75" />
<path
fill="currentColor"
d="M11.25 10.5h1.5V17h-1.5V10.5zm0-3.25h1.5V9h-1.5V6.25z"
/>
</svg>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
title="현재 전략 JSON 내보내기"
onClick={handleExport}
disabled={!buyCondition && !sellCondition}
>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
title="JSON 파일에서 전략 가져오기"
onClick={() => { void handleImport(); }}
>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
title="전체 전략 목록 JSON 내보내기"
onClick={handleExportAll}
disabled={strategies.length === 0}
>
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--icon"
title="전체 전략 목록 JSON 가져오기"
onClick={() => { void handleImportAll(); }}
>
</button>
</div>
</header>
<div className="se-body" style={bodyStyle}>
<aside className="se-left" style={{ width: leftWidth }}>
<div className="se-strat-panel">
<div className="se-strat-panel-head">
<h2 className="se-panel-title">전략 목록</h2>
</div>
<button type="button" className="se-new-strat-btn" onClick={handleNew}>
전략 만들기
</button>
<div className="se-strat-list">
{strategies.length === 0 ? (
<p className="se-empty">저장된 전략이 없습니다</p>
) : (
strategies.map(s => {
const isSel = selectedId === s.id;
return (
<div
key={s.id}
role="button"
tabIndex={0}
className={`se-strat-item${isSel ? ' se-strat-item--sel' : ''}`}
onClick={() => handleSelectStrategy(s)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelectStrategy(s);
}
}}
>
<span className="se-strat-name" title={s.name}>{s.name}</span>
<div className="se-strat-item-actions">
<button
type="button"
className="se-strat-del"
title="전략 삭제"
onClick={e => {
e.stopPropagation();
setDeleteId(s.id);
setDeleteOpen(true);
}}
>
<svg viewBox="0 0 16 16" width="17" height="17" aria-hidden>
<path
fill="currentColor"
d="M5.5 2a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5H12a.5.5 0 0 1 0 1h-.55l-.62 8.07A1.5 1.5 0 0 1 9.83 13H6.17a1.5 1.5 0 0 1-1.49-1.43L4.05 3.5H4a.5.5 0 0 1 0-1h1.5V2zm1.5 0v.5h2V2H7zm-2.38 1.5l.58 7.53a.5.5 0 0 0 .5.47h3.66a.5.5 0 0 0 .5-.47l.58-7.53H4.62z"
/>
</svg>
</button>
<span className={`se-strat-status${s.enabled ? ' se-strat-status--on' : ''}`}>
{s.enabled ? '활성' : '비활성'}
</span>
</div>
</div>
);
})
)}
</div>
</div>
</aside>
<div
className="se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="전략 목록 너비 조절"
onPointerDown={onLeftSplitter}
/>
<div className="se-main">
<div className="se-main-row">
<main className="se-center">
<div className="se-center-panel">
<div className={`se-center-work${editorMode === 'list' ? ' se-center-work--list' : ''}`}>
<div className="se-center-head">
<div className="se-signal-tabs">
<button
type="button"
className={`se-signal-tab${signalTab === 'buy' ? ' se-signal-tab--buy-on' : ''}`}
onClick={() => switchSignalTab('buy')}
>
매수 조건 (Entry)
</button>
<button
type="button"
className={`se-signal-tab${signalTab === 'sell' ? ' se-signal-tab--sell-on' : ''}`}
onClick={() => switchSignalTab('sell')}
>
매도 조건 (Exit)
</button>
</div>
{stratName && <span className="se-editing-name">{stratName}</span>}
{hasMultipleStartSections(currentEditorState) && (
<StartCombineOpControl
value={normalizeStartCombineOp(currentEditorState.startCombineOp)}
onChange={handleStartCombineOpChange}
/>
)}
<div className="se-center-head-actions">
<button type="button" className="se-btn se-btn--ghost se-btn--sm" onClick={handleNew}> 전략</button>
<div className="se-editor-mode se-editor-mode--compact" role="group" aria-label="편집 방식">
<button
type="button"
className={`se-editor-mode-btn${editorMode === 'graph' ? ' se-editor-mode-btn--on' : ''}`}
onClick={() => handleEditorModeChange('graph')}
>
그래프 방식
</button>
<button
type="button"
className={`se-editor-mode-btn${editorMode === 'list' ? ' se-editor-mode-btn--on' : ''}`}
onClick={() => handleEditorModeChange('list')}
>
목록 방식
</button>
</div>
<button
type="button"
className="se-btn se-btn--ghost se-btn--sm"
onClick={openTemplateAddModal}
title="현재 탭(매수/매도) 조건을 템플릿 목록에 저장"
>
템플릿 저장
</button>
<button
type="button"
className="se-btn se-btn--gold se-btn--sm"
onClick={() => openSaveDialog(selectedId ? 'update' : 'create')}
>
저장하기
</button>
<button
type="button"
className="se-btn se-btn--ghost se-btn--sm"
onClick={() => openSaveDialog('saveAs')}
title="현재 조건을 다른 이름으로 새 전략으로 저장 (기존 전략은 유지)"
>
새로저장
</button>
</div>
</div>
{editorMode === 'graph' ? (
<>
<ReactFlowProvider>
<StrategyEditorCanvas
theme={theme}
root={currentRoot}
orphans={currentOrphans}
onOrphansChange={handleOrphansChange}
def={DEF}
signalTab={signalTab}
onChange={handleRootChange}
selectedNodeId={selectedNodeId}
onSelectNode={setSelectedNodeId}
layoutSeed={layoutSeed}
onLayoutChange={handleLayoutChange}
startMeta={currentLayout.startMeta}
extraStartIds={currentLayout.extraStartIds}
extraRoots={currentLayout.extraRoots}
onStartMetaChange={handleStartMetaChange}
onStartCandleTypesChange={handleStartCandleTypesChange}
onExtraStartIdsChange={handleExtraStartIdsChange}
onExtraRootsChange={handleExtraRootsChange}
/>
</ReactFlowProvider>
{selectedLogicNode?.type === 'CONDITION' && selectedLogicNode.condition && (
<div className="se-node-config-bar">
<span className="se-node-config-label">
{stochPairEditForSelected && !stochPairEditForSelected.stochLocked
? 'Stoch 과열×보조'
: selectedLogicNode.condition.indicatorType}
</span>
<CondEditor
cond={selectedLogicNode.condition}
signalType={signalTab}
def={DEF}
stochPairEdit={stochPairEditForSelected}
onChange={c => {
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;
}
}
}
}}
/>
<span className="se-sync-tip">전략 조건 전용 설정 · 차트 보조지표와 무관</span>
</div>
)}
{selectedLogicNode && isStochOverboughtPairRoot(selectedLogicNode) && selectedLogicNode.stochPair && (
<div className="se-node-config-bar se-node-config-bar--stoch-pair">
<span className="se-node-config-label">Stoch 과열×보조</span>
<StochPairNodeSettings
variant="inline"
secondaryIndicator={selectedLogicNode.stochPair.secondaryIndicatorType}
onChange={next => applyStochPairSecondary(selectedLogicNode.id, next)}
onClose={() => {}}
/>
</div>
)}
{selectedLogicNode && isPriceExtremeBreakoutPairRoot(selectedLogicNode) && selectedLogicNode.priceExtremePair && (
<div className="se-node-config-bar se-node-config-bar--price-extreme">
<span className="se-node-config-label">9·20 신고가/신저가 필터</span>
<PriceExtremePairNodeSettings
variant="inline"
mode={selectedLogicNode.priceExtremePair.mode}
shortPeriod={selectedLogicNode.priceExtremePair.shortPeriod}
longPeriod={selectedLogicNode.priceExtremePair.longPeriod}
filterLevel={inferPriceExtremeFilterLevel(selectedLogicNode)}
onChange={next => applyPriceExtremeFilterLevel(selectedLogicNode.id, next)}
onClose={() => {}}
/>
</div>
)}
{selectedLogicNode && isInflection33PairRoot(selectedLogicNode) && selectedLogicNode.inflection33Pair && (
<div className="se-node-config-bar se-node-config-bar--inflection33">
<span className="se-node-config-label">33변곡 필터</span>
<Inflection33PairNodeSettings
variant="inline"
mode={selectedLogicNode.inflection33Pair.mode}
period={selectedLogicNode.inflection33Pair.period}
filterLevel={inferInflection33FilterLevel(selectedLogicNode)}
onChange={next => applyInflection33FilterLevel(selectedLogicNode.id, next)}
onClose={() => {}}
/>
</div>
)}
{selectedLogicNode && isStableStrategyPairRoot(selectedLogicNode) && selectedLogicNode.stableStrategyPair && (
<div className="se-node-config-bar se-node-config-bar--stable-strategy">
<span className="se-node-config-label">추천 전략 필터</span>
<StableStrategyPairNodeSettings
variant="inline"
strategyId={selectedLogicNode.stableStrategyPair.strategyId as StableStrategyId}
mode={selectedLogicNode.stableStrategyPair.mode}
filterLevel={inferStableStrategyFilterLevel(selectedLogicNode)}
def={DEF}
onChange={next => applyStableStrategyFilterLevel(selectedLogicNode.id, next)}
onClose={() => {}}
/>
</div>
)}
</>
) : (
<StrategyListEditor
editorState={currentEditorState}
signalTab={signalTab}
def={DEF}
onEditorStateChange={handleEditorStateChange}
onAddStart={handleAddStartSection}
orphans={currentOrphans}
onOrphansChange={handleOrphansChange}
/>
)}
</div>
<div
className="se-splitter se-splitter--h"
role="separator"
aria-orientation="horizontal"
aria-label="Logic Expression 높이 조절"
onPointerDown={onTerminalSplitter}
/>
<footer className="se-terminal" style={{ height: terminalHeight }}>
<div className="se-terminal-scroll">
<section className="se-terminal-section">
<div className="se-terminal-label">LOGIC EXPRESSION</div>
<LogicExpressionPreview
buyCondition={buyCondition}
sellCondition={sellCondition}
buyEditorState={buyEditorState}
sellEditorState={sellEditorState}
orphanCount={orphanTotal}
def={DEF}
/>
</section>
<section className="se-terminal-section se-terminal-section--narrative">
<div className="se-terminal-label se-terminal-label--guide">조건 설명</div>
<LogicExpressionNarrative
name={stratName}
description={stratDesc}
buyCondition={buyCondition}
sellCondition={sellCondition}
buyEditorState={buyEditorState}
sellEditorState={sellEditorState}
orphanCount={orphanTotal}
def={DEF}
compact
/>
</section>
</div>
</footer>
</div>
</main>
{rightOpen && (
<div
className="se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="우측 패널 너비 조절"
onPointerDown={onRightSplitter}
/>
)}
<div className={`se-side-wrap se-side-wrap--right${rightOpen ? ' se-side-wrap--open' : ''}`}>
<SePanelCollapseHandle
side="right"
open={rightOpen}
onToggle={toggleRightPanel}
label="지표 패널"
/>
<aside className={`se-right se-right--collapsible${rightOpen ? ' se-right--collapsible--open' : ''}`}>
<div className="se-palette-panel">
<div className="se-right-tabs">
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}>지표</button>
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>템플릿</button>
</div>
<div className="se-right-body">
{rightTab === 'indicators' && (
<>
<input
className="se-palette-search"
placeholder="지표 검색 (RSI, MACD…)"
value={paletteSearch}
onChange={e => setPaletteSearch(e.target.value)}
/>
<div className="se-palette-section se-palette-section--logic">
<h3>시작 · 논리</h3>
<div className="se-palette-grid se-palette-grid--3">
<PaletteChip
type="start"
value="START"
label="START"
desc="시간봉 시작점"
color="logic-start"
selected={selectedPaletteKey === 'start:START'}
onSelect={() => setSelectedPaletteKey('start:START')}
onAdd={() => applyPalette('start', 'START', 'START')}
/>
{operators.map(op => (
<PaletteChip
key={op.value}
{...op}
selected={isPaletteSelected(op.type, op.value)}
onSelect={() => selectPalette(op.type, op.value)}
onAdd={() => applyPalette(op.type, op.value, op.label)}
/>
))}
</div>
</div>
<div className="se-palette-section se-palette-section--band">
<h3>밴드 · 추세</h3>
<div className="se-palette-grid se-palette-grid--3">
{maBandItems.filter(i => match(i.label, i.desc)).map(item => (
<PaletteChip
key={item.value}
{...item}
period={getIndicatorPeriodLabel(item.value, DEF)}
selected={isPaletteSelected(item.type, item.value)}
onSelect={() => selectPalette(item.type, item.value)}
onAdd={() => applyPalette(item.type, item.value, item.label)}
/>
))}
</div>
</div>
<div className="se-palette-subtabs">
<button
type="button"
className={`se-palette-subtab se-palette-subtab--aux${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('auxiliary');
setSelectedPaletteKey(null);
}}
>
보조지표
</button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--composite${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('composite');
setSelectedPaletteKey(null);
}}
>
복합지표
</button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--range${indicatorSubTab === 'range' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('range');
setSelectedPaletteKey(null);
}}
>
횡보필터
</button>
</div>
{indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab
kind="auxiliary"
items={auxiliaryPalette}
onItemsChange={setAuxiliaryPalette}
def={DEF}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('auxiliary:')
? selectedPaletteKey.slice('auxiliary:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('auxiliary', id) : null)}
onAddToCanvas={item => {
selectPalette('auxiliary', item.id);
applyPaletteItem(item);
}}
/>
) : indicatorSubTab === 'composite' ? (
<IndicatorPaletteTab
kind="composite"
items={compositePalette}
onItemsChange={setCompositePalette}
def={DEF}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('composite:')
? selectedPaletteKey.slice('composite:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('composite', id) : null)}
onAddToCanvas={item => {
selectPalette('composite', item.id);
applyPaletteItem(item);
}}
/>
) : (
<SidewaysFilterPaletteTab
def={DEF}
items={sidewaysPalette}
onItemsChange={setSidewaysPalette}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('range:')
? selectedPaletteKey.slice('range:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('range', id) : null)}
onAddToCanvas={item => {
selectPalette('range', item.id);
applySidewaysFilter(item.id);
}}
/>
)}
</>
)}
{rightTab === 'templates' && (
<TemplatePaletteTab
rows={filteredTemplateRows}
searchQuery={templateSearch}
onSearchChange={setTemplateSearch}
selectedKey={selectedTemplateKey}
onSelectKey={setSelectedTemplateKey}
onApply={handleTemplateApply}
onAdd={openTemplateAddModal}
onEdit={openTemplateEditModal}
onDelete={handleTemplateToolbarDelete}
/>
)}
</div>
</div>
</aside>
</div>
</div>
</div>
</div>
{templateSaveOpen && (
<DraggableModalFrame
onClose={() => { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }}
title={templateModalMode === 'edit' ? '템플릿 수정' : '템플릿 저장'}
>
<div className="se-modal-body">
<p className="se-template-save-hint">
{templateModalMode === 'edit'
? <>선택한 <strong> 템플릿</strong> 이름·설명을 수정합니다.</>
: <>현재 <strong>{signalTab === 'buy' ? '매수' : '매도'}</strong> 조건을 템플릿 목록에 저장합니다.</>}
</p>
<label className="se-field-lbl">템플릿명 *</label>
<input
className={`se-field-inp${templateSaveLabelError ? ' se-field-inp--error' : ''}`}
value={templateSaveLabel}
onChange={e => { setTemplateSaveLabel(e.target.value); setTemplateSaveLabelError(false); }}
placeholder="예: RSI 과매도 + MA 골든"
/>
{templateSaveLabelError && (
<p className="se-field-error" role="alert">템플릿 이름을 입력하세요</p>
)}
<label className="se-field-lbl">설명</label>
<textarea
className="se-field-ta"
value={templateSaveDesc}
onChange={e => setTemplateSaveDesc(e.target.value)}
rows={2}
placeholder="템플릿 설명 (선택)"
/>
{templateModalMode === 'edit' && (
<label className="se-template-save-update">
<input
type="checkbox"
checked={templateSaveUpdateContent}
onChange={e => setTemplateSaveUpdateContent(e.target.checked)}
/>
<span>현재 {signalTab === 'buy' ? '매수' : '매도'} 조건으로 내용 갱신</span>
</label>
)}
<div className="se-modal-actions">
<button type="button" className="se-btn se-btn--ghost" onClick={() => setTemplateSaveOpen(false)}>취소</button>
<button type="button" className="se-btn se-btn--gold" onClick={handleTemplateSaveConfirm}>
{templateModalMode === 'edit' ? '수정' : '저장'}
</button>
</div>
</div>
</DraggableModalFrame>
)}
{saveOpen && (
<DraggableModalFrame
onClose={() => { setSaveNameError(false); setSaveDuplicateError(false); setSaveOpen(false); }}
title={saveMode === 'saveAs' ? '새 이름으로 저장' : '전략 저장'}
>
<div className="se-modal-body">
{saveMode === 'saveAs' && selectedId && (
<p className="se-template-save-hint">
현재 편집 중인 조건을 <strong> 전략</strong>으로 저장합니다.
기존 전략「{strategies.find(s => s.id === selectedId)?.name ?? stratName}」은 변경되지 않습니다.
</p>
)}
{saveMode === 'saveAs' && !selectedId && (
<p className="se-template-save-hint">
현재 조건을 <strong> 전략</strong>으로 저장합니다.
</p>
)}
<label className="se-field-lbl">전략명 *</label>
<input
className={`se-field-inp${saveNameError || saveDuplicateError ? ' se-field-inp--error' : ''}`}
value={saveDraftName}
onChange={e => {
setSaveDraftName(e.target.value);
setSaveNameError(false);
setSaveDuplicateError(false);
}}
placeholder="예: Golden_RSI_V1"
/>
{saveNameError && (
<p className="se-field-error" role="alert">전략 이름을 입력하세요</p>
)}
{saveDuplicateError && (
<p className="se-field-error" role="alert">같은 이름의 전략이 이미 있습니다</p>
)}
<label className="se-field-lbl">설명</label>
<textarea
className="se-field-ta"
value={saveDraftDesc}
onChange={e => setSaveDraftDesc(e.target.value)}
rows={2}
/>
<div className="se-modal-actions">
<button type="button" className="se-btn se-btn--ghost" onClick={() => setSaveOpen(false)}>취소</button>
<button type="button" className="se-btn se-btn--gold" disabled={isSaving} onClick={handleSave}>
{isSaving ? '저장 중…' : (saveMode === 'saveAs' ? '새로저장' : '저장')}
</button>
</div>
</div>
</DraggableModalFrame>
)}
{deleteOpen && (
<DraggableModalFrame onClose={() => setDeleteOpen(false)} title="전략 삭제">
<p> 전략을 삭제하시겠습니까?</p>
<div className="se-modal-actions">
<button type="button" className="se-btn se-btn--ghost" onClick={() => setDeleteOpen(false)}>취소</button>
<button type="button" className="se-btn se-btn--danger" onClick={handleDeleteConfirm}>삭제</button>
</div>
</DraggableModalFrame>
)}
{descOpen && (
<StrategyDescriptionModal
onClose={() => setDescOpen(false)}
name={stratName}
description={stratDesc}
buyEditorState={buyEditorState}
sellEditorState={sellEditorState}
buyCondition={buyCondition}
sellCondition={sellCondition}
orphanCount={orphanTotal}
def={DEF}
/>
)}
{snack && <div className={`se-snack${snack.ok ? '' : ' se-snack--err'}`}>{snack.msg}</div>}
</div>
);
}