Files
goldenChart/frontend/src/components/StrategyEditorPage.tsx
T
Macbook b34bdda073 fix: 백테스트 시간봉·캔들 수 불일치로 시그널 누락 문제 해결
- 전략 DSL 시간봉(5m)과 실행 시간봉(3m) 불일치 시 자동 동기화
- 업비트 캔들 200봉 제한 → 페이지네이션으로 800봉까지 조회
- 백테스트 차트는 실행 타임프레임 기준으로 표시 (시그널·지표 정렬)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:16:53 +09:00

1608 lines
65 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, API_BASE, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { timeframeToCandleType } from '../utils/chartCandleType';
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
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,
syncBuySellPrimaryStartCandleTypes,
mergeStartMetaForLoad,
addExtraStartSection,
hasMultipleStartSections,
normalizeStartCombineOp,
updateStartCombineOp,
updateStartCandleTypes,
type EditorConditionState,
} from '../utils/strategyConditionSerde';
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
import {
START_NODE_ID,
defaultStartMeta,
type StartCombineOp,
} from '../utils/strategyStartNodes';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import {
loadPaletteItems,
type PaletteItem,
} from '../utils/strategyPaletteStorage';
import {
emptySignalFlowLayout,
buildStrategyFlowLayoutStore,
normalizeStrategyFlowLayout,
readLegacyFlowLayoutFromLocalStorage,
clearLegacyFlowLayoutFromLocalStorage,
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 ReactDOM from 'react-dom';
import { MarketSearchPanel } from './MarketSearchPanel';
import { getKoreanName } from '../utils/marketNameCache';
import type { Timeframe } from '../types';
import '../styles/strategyEditor.css';
import '../styles/strategyEditorTheme.css';
const QUICK_RUN_TIMEFRAMES: { value: Timeframe; label: string }[] = [
{ value: '1m', label: '1분' },
{ value: '3m', label: '3분' },
{ value: '5m', label: '5분' },
{ value: '10m', label: '10분' },
{ value: '15m', label: '15분' },
{ value: '30m', label: '30분' },
{ value: '1h', label: '1시간' },
{ value: '4h', label: '4시간' },
{ value: '1D', label: '일봉' },
{ value: '1W', label: '주봉' },
];
const BACKTEST_FOCUS_KEY = 'backtest_focus_id';
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;
onNavigateToBacktest?: () => 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([]);
}
}
export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Props) {
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo(
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
[getParams, getVisualConfig, settingsRevision],
);
const settingsSyncRef = useRef(-1);
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'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [paletteSearch, setPaletteSearch] = useState('');
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 [saveNameError, setSaveNameError] = 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<Timeframe>('1D');
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);
// 전략 선택/조건 변경 시 DSL 기준 시간봉으로 빠른 실행 TF 동기화
useEffect(() => {
if (selectedId == null) return;
const tf = resolveStrategyPrimaryTimeframe({
buyCondition: buyCondition,
sellCondition: sellCondition,
} as ApiStrategyDto);
setQbTimeframe(prev => (prev === tf ? prev : tf));
}, [selectedId, buyCondition, sellCondition]);
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(() => 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);
buyLayoutRef.current = buyLayout;
sellLayoutRef.current = sellLayout;
buyConditionRef.current = buyCondition;
sellConditionRef.current = sellCondition;
buyOrphansRef.current = buyOrphans;
sellOrphansRef.current = sellOrphans;
const layoutRevisionRef = useRef(0);
const strategyAutosaveTimerRef = useRef<number | null>(null);
const layoutPersistReadyRef = useRef(false);
const STRATEGY_AUTOSAVE_MS = 350;
leftWidthRef.current = leftWidth;
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 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 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]);
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 handleEditorStateChange = useCallback((next: EditorConditionState) => {
setCurrentRoot(next.root);
setCurrentLayout(prev => ({
...prev,
startMeta: next.startMeta,
extraStartIds: next.extraStartIds,
extraRoots: next.extraRoots,
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
}));
scheduleStrategyPersist();
}, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
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),
}));
scheduleStrategyPersist();
return;
}
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
}, [
buyEditorState,
sellEditorState,
handleEditorStateChange,
scheduleStrategyPersist,
signalTab,
]);
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
}, [handleEditorStateChange, currentEditorState]);
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
if (editorMode === 'graph') layoutFlushRef.current?.();
flushStrategyPersist();
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
setSignalTab(tab);
setSelectedNodeId(null);
}, [layoutStrategyKey, flushStrategyPersist, editorMode]);
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
if (mode === editorMode) return;
if (editorMode === 'graph') {
layoutFlushRef.current?.();
flushStrategyPersist();
}
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
}
setEditorMode(mode);
saveEditorMode(mode);
setSelectedNodeId(null);
}, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]);
const layoutSeed = useMemo((): FlowLayoutSeed => {
const source = signalTab === 'buy' ? buyLayout : sellLayout;
return {
seedKey: layoutSeedKey,
positions: source.positions,
edgeHandles: source.edgeHandles,
};
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
const persistStrategyToDbRef = useRef(persistStrategyToDb);
persistStrategyToDbRef.current = persistStrategyToDb;
useEffect(() => {
const id = selectedId;
const name = stratName;
return () => {
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
if (id != null && name.trim()) {
void persistStrategyToDbRef.current().catch(() => { /* unmount flush */ });
}
};
}, [selectedId, stratName]);
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 handleSelectStrategy = (s: StrategyDto) => {
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 */
}
})();
};
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?.();
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: selectedId ?? undefined,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
enabled: true,
};
const saved = await saveStrategy(payload);
const now = new Date().toISOString();
const dbId = saved?.id ?? selectedId ?? Date.now();
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,
flowLayout,
updatedAt: saved?.updatedAt ?? now,
}
: s);
}
setSelectedId(dbId);
return [...prev, {
id: dbId,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
enabled: true,
createdAt: saved?.createdAt ?? now,
updatedAt: saved?.updatedAt ?? now,
}];
});
if (!selectedId) setSelectedId(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 */ }
clearLegacyFlowLayoutFromLocalStorage(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 ?? []);
} 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 baseId = Date.now();
const imported = result.data.strategies.map((item, index) => {
const id = baseId + index;
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));
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
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));
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
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);
scheduleStrategyPersist();
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
const handleQuickRun = useCallback(async () => {
if (!selectedId) {
window.alert('백테스팅할 전략을 먼저 선택해주세요.');
return;
}
setQbLoading(true);
setQbError(null);
try {
const selectedStrategy = strategies.find(s => s.id === selectedId);
const execTimeframe = resolveStrategyPrimaryTimeframe({
buyCondition: buyCondition,
sellCondition: sellCondition,
} as ApiStrategyDto);
const [settings, indicatorParams, barsRes] = await Promise.all([
loadBacktestSettings(),
loadIndicatorSettings(),
fetch(`${API_BASE}/candles/history?${new URLSearchParams({
market: qbMarket,
type: timeframeToCandleType(execTimeframe),
count: '800',
})}`),
]);
if (!barsRes.ok) throw new Error(`캔들 로딩 실패 (${barsRes.status})`);
const bars = (await barsRes.json()) as Array<{
time: number; open: number; high: number; low: number; close: number; volume: number;
}>;
if (bars.length < 5) {
throw new Error('캔들 데이터가 부족합니다. 타임프레임을 변경하거나 잠시 후 다시 시도해주세요.');
}
const result = await runBacktest({
strategyId: selectedId,
bars,
timeframe: execTimeframe,
settings,
indicatorParams,
symbol: qbMarket,
strategyName: selectedStrategy?.name,
});
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, buyCondition, sellCondition, onNavigateToBacktest]);
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}`}>
{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(e.target.value as Timeframe)}
disabled={qbLoading}
title="타임프레임"
>
{QUICK_RUN_TIMEFRAMES.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">
<div className="se-editor-mode" 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" onClick={handleNew}> 전략</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>
<button type="button" className="se-btn se-btn--gold" onClick={() => { setSaveNameError(false); setSaveOpen(true); }}>저장하기</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>
{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">{selectedLogicNode.condition.indicatorType}</span>
<CondEditor
cond={selectedLogicNode.condition}
signalType={signalTab}
def={DEF}
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>
)}
</>
) : (
<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-label">LOGIC EXPRESSION</div>
<LogicExpressionPreview
buyCondition={buyCondition}
sellCondition={sellCondition}
buyEditorState={buyEditorState}
sellEditorState={sellEditorState}
orphanCount={orphanTotal}
def={DEF}
/>
</footer>
</div>
</main>
<aside className="se-right">
<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${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('auxiliary');
setSelectedPaletteKey(null);
}}
>
보조지표
</button>
<button
type="button"
className={`se-palette-subtab${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('composite');
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);
}}
/>
) : (
<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);
}}
/>
)}
</>
)}
{rightTab === 'templates' && (
<div className="se-template-list">
{templates.map((t, i) => (
<button key={i} type="button" className="se-template-item" onClick={() => handleTemplate(t)}>
<span className="se-template-label">{t.label}</span>
{'description' in t && t.description && (
<span className="se-template-desc">{t.description}</span>
)}
<span className={`se-template-signal se-template-signal--${t.signal}`}>{t.signal === 'buy' ? '매수' : '매도'}</span>
</button>
))}
</div>
)}
</div>
</div>
</aside>
</div>
</div>
</div>
{saveOpen && (
<DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
<div className="se-modal-body">
<label className="se-field-lbl">전략명 *</label>
<input
className={`se-field-inp${saveNameError ? ' se-field-inp--error' : ''}`}
value={stratName}
onChange={e => { setStratName(e.target.value); setSaveNameError(false); }}
placeholder="예: Golden_RSI_V1"
/>
{saveNameError && (
<p className="se-field-error" role="alert">전략 이름을 입력하세요</p>
)}
<label className="se-field-lbl">설명</label>
<textarea className="se-field-ta" value={stratDesc} onChange={e => setStratDesc(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 ? '저장 중…' : '저장'}</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>
);
}