1211 lines
49 KiB
TypeScript
1211 lines
49 KiB
TypeScript
/**
|
||
* 전략편집기 — 노드 기반 논리 트리 UI (React Flow)
|
||
*/
|
||
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||
import { ReactFlowProvider } from '@xyflow/react';
|
||
import DraggableModalFrame from './DraggableModalFrame';
|
||
import type { Theme } from '../types/index';
|
||
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||
import type { LogicNode } from '../utils/strategyTypes';
|
||
import {
|
||
buildStrategyEditorDef,
|
||
loadStratsLocal,
|
||
saveStratsLocal,
|
||
mergeAtRoot,
|
||
makeNode,
|
||
updateNode,
|
||
CondEditor,
|
||
genId,
|
||
type StrategyDto,
|
||
} from '../utils/strategyEditorShared';
|
||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||
import {
|
||
decodeConditionForEditor,
|
||
encodeConditionForSave,
|
||
addExtraStartSection,
|
||
hasMultipleStartSections,
|
||
normalizeStartCombineOp,
|
||
updateStartCombineOp,
|
||
type EditorConditionState,
|
||
} from '../utils/strategyConditionSerde';
|
||
import {
|
||
defaultStartMeta,
|
||
type StartCombineOp,
|
||
} from '../utils/strategyStartNodes';
|
||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||
import {
|
||
loadPaletteItems,
|
||
type PaletteItem,
|
||
} from '../utils/strategyPaletteStorage';
|
||
import {
|
||
emptySignalFlowLayout,
|
||
loadStrategyFlowLayout,
|
||
migrateStrategyFlowLayout,
|
||
saveStrategyFlowLayout,
|
||
deleteStrategyFlowLayout,
|
||
type SignalFlowLayoutSnapshot,
|
||
type FlowLayoutChangePayload,
|
||
type StrategyFlowLayoutStore,
|
||
} from '../utils/strategyEditorLayoutStorage';
|
||
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
||
import StrategyEditorCanvas, { type FlowLayoutSeed } from './strategyEditor/StrategyEditorCanvas';
|
||
import StrategyListEditor from './strategyEditor/StrategyListEditor';
|
||
import StartCombineOpControl from './strategyEditor/StartCombineOpControl';
|
||
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
|
||
import {
|
||
loadEditorMode,
|
||
saveEditorMode,
|
||
type StrategyEditorMode,
|
||
} from '../utils/strategyEditorModeStorage';
|
||
import {
|
||
getStrategyTemplates,
|
||
simpleTemplateToNode,
|
||
type StrategyTemplateDef,
|
||
} from '../utils/strategyPresets';
|
||
import {
|
||
buildStrategyExportPayload,
|
||
buildStrategyListExportPayload,
|
||
downloadStrategyJson,
|
||
listImportItemToStrategyDto,
|
||
parseStrategyImportFile,
|
||
pickJsonFile,
|
||
strategyDtoToListExportItem,
|
||
} from '../utils/strategyImportExport';
|
||
import PaletteChip from './strategyEditor/PaletteChip';
|
||
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
|
||
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
|
||
import '../styles/strategyEditor.css';
|
||
import '../styles/strategyEditorTheme.css';
|
||
|
||
const LEFT_PANEL_MIN = 220;
|
||
const LEFT_PANEL_MAX = 520;
|
||
const LEFT_PANEL_DEFAULT = 280;
|
||
const TERMINAL_MIN = 88;
|
||
const TERMINAL_MAX = 420;
|
||
const TERMINAL_DEFAULT = 140;
|
||
|
||
interface Props {
|
||
theme: Theme;
|
||
}
|
||
|
||
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
|
||
const stored = loadStrategyFlowLayout(strategyKey);
|
||
const snap = stored?.[tab];
|
||
if (!snap) return emptySignalFlowLayout();
|
||
return {
|
||
positions: snap.positions ?? {},
|
||
edgeHandles: snap.edgeHandles ?? {},
|
||
orphans: snap.orphans ?? [],
|
||
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: snap.extraStartIds ?? [],
|
||
extraRoots: snap.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
|
||
};
|
||
}
|
||
|
||
function toEditorState(
|
||
root: LogicNode | null,
|
||
layout: Pick<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),
|
||
};
|
||
}
|
||
|
||
export default function StrategyEditorPage({ theme }: Props) {
|
||
const DEF = useMemo(() => buildStrategyEditorDef(), []);
|
||
|
||
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 [deleteOpen, setDeleteOpen] = useState(false);
|
||
const [descOpen, setDescOpen] = useState(false);
|
||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
const initialDraftBuy = readTabLayout('draft', 'buy');
|
||
const initialDraftSell = readTabLayout('draft', 'sell');
|
||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>(() => initialDraftBuy.orphans ?? []);
|
||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>(() => initialDraftSell.orphans ?? []);
|
||
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
|
||
const [saveToast, setSaveToast] = useState(false);
|
||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
||
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
|
||
const leftWidthRef = useRef(leftWidth);
|
||
const terminalHeightRef = useRef(terminalHeight);
|
||
const [buyLayout, setBuyLayout] = useState(() => ({
|
||
positions: initialDraftBuy.positions,
|
||
edgeHandles: initialDraftBuy.edgeHandles,
|
||
startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: initialDraftBuy.extraStartIds ?? [],
|
||
extraRoots: initialDraftBuy.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp),
|
||
}));
|
||
const [sellLayout, setSellLayout] = useState(() => ({
|
||
positions: initialDraftSell.positions,
|
||
edgeHandles: initialDraftSell.edgeHandles,
|
||
startMeta: initialDraftSell.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: initialDraftSell.extraStartIds ?? [],
|
||
extraRoots: initialDraftSell.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp),
|
||
}));
|
||
const buyLayoutRef = useRef(buyLayout);
|
||
const sellLayoutRef = useRef(sellLayout);
|
||
buyLayoutRef.current = buyLayout;
|
||
sellLayoutRef.current = sellLayout;
|
||
const layoutRevisionRef = useRef(0);
|
||
const persistLayoutTimerRef = useRef<number | null>(null);
|
||
const layoutPersistReadyRef = useRef(false);
|
||
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 persistFlowLayout = useCallback((strategyKey: string) => {
|
||
saveStrategyFlowLayout(strategyKey, {
|
||
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
||
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
||
});
|
||
}, [buyOrphans, sellOrphans]);
|
||
|
||
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
|
||
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
|
||
persistLayoutTimerRef.current = window.setTimeout(() => {
|
||
persistLayoutTimerRef.current = null;
|
||
persistFlowLayout(strategyKey);
|
||
}, 150);
|
||
}, [persistFlowLayout]);
|
||
|
||
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
|
||
layoutRevisionRef.current += 1;
|
||
setLayoutSeedKey(`${strategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||
}, []);
|
||
|
||
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||
const emptyBuy = emptySignalFlowLayout();
|
||
const emptySell = emptySignalFlowLayout();
|
||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||
setBuyOrphans([]);
|
||
setSellOrphans([]);
|
||
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
|
||
bumpLayoutSeed(strategyKey, tab);
|
||
}, [bumpLayoutSeed]);
|
||
|
||
const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
|
||
const stored = loadStrategyFlowLayout(strategyKey);
|
||
if (stored) {
|
||
const nextBuy = {
|
||
positions: stored.buy.positions ?? {},
|
||
edgeHandles: stored.buy.edgeHandles ?? {},
|
||
startMeta: stored.buy.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: stored.buy.extraStartIds ?? [],
|
||
extraRoots: stored.buy.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp),
|
||
};
|
||
const nextSell = {
|
||
positions: stored.sell.positions ?? {},
|
||
edgeHandles: stored.sell.edgeHandles ?? {},
|
||
startMeta: stored.sell.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: stored.sell.extraStartIds ?? [],
|
||
extraRoots: stored.sell.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp),
|
||
};
|
||
setBuyLayout(nextBuy);
|
||
setSellLayout(nextSell);
|
||
setBuyOrphans(stored.buy.orphans ?? []);
|
||
setSellOrphans(stored.sell.orphans ?? []);
|
||
} else {
|
||
const emptyBuy = emptySignalFlowLayout();
|
||
const emptySell = emptySignalFlowLayout();
|
||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||
setBuyOrphans([]);
|
||
setSellOrphans([]);
|
||
}
|
||
bumpLayoutSeed(strategyKey, tab);
|
||
}, [bumpLayoutSeed]);
|
||
|
||
const handleLayoutChange = useCallback((snapshot: FlowLayoutChangePayload) => {
|
||
const patch = {
|
||
positions: snapshot.positions,
|
||
edgeHandles: snapshot.edgeHandles,
|
||
};
|
||
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
|
||
else setSellLayout(prev => ({ ...prev, ...patch }));
|
||
schedulePersistFlowLayout(layoutStrategyKey);
|
||
}, [layoutStrategyKey, schedulePersistFlowLayout]);
|
||
|
||
const handleStartMetaChange = useCallback((meta: Record<string, { candleType: string }>) => {
|
||
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
|
||
schedulePersistFlowLayout(layoutStrategyKey);
|
||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||
|
||
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
|
||
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
|
||
schedulePersistFlowLayout(layoutStrategyKey);
|
||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
|
||
|
||
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
|
||
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
|
||
schedulePersistFlowLayout(layoutStrategyKey);
|
||
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||
|
||
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
|
||
setCurrentRoot(next.root);
|
||
setCurrentLayout(prev => ({
|
||
...prev,
|
||
startMeta: next.startMeta,
|
||
extraStartIds: next.extraStartIds,
|
||
extraRoots: next.extraRoots,
|
||
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
|
||
}));
|
||
schedulePersistFlowLayout(layoutStrategyKey);
|
||
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
|
||
|
||
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
||
handleEditorStateChange(updateStartCombineOp(currentEditorState, op));
|
||
}, [handleEditorStateChange, currentEditorState]);
|
||
|
||
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
persistFlowLayout(layoutStrategyKey);
|
||
layoutRevisionRef.current += 1;
|
||
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||
setSignalTab(tab);
|
||
setSelectedNodeId(null);
|
||
}, [layoutStrategyKey, persistFlowLayout, editorMode]);
|
||
|
||
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
|
||
if (mode === editorMode) return;
|
||
if (editorMode === 'graph') {
|
||
layoutFlushRef.current?.();
|
||
persistFlowLayout(layoutStrategyKey);
|
||
}
|
||
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
|
||
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
|
||
}
|
||
setEditorMode(mode);
|
||
saveEditorMode(mode);
|
||
setSelectedNodeId(null);
|
||
}, [editorMode, layoutStrategyKey, persistFlowLayout, buyOrphans.length, sellOrphans.length]);
|
||
|
||
const layoutSeed = useMemo((): FlowLayoutSeed => {
|
||
const source = signalTab === 'buy' ? buyLayout : sellLayout;
|
||
return {
|
||
seedKey: layoutSeedKey,
|
||
positions: source.positions,
|
||
edgeHandles: source.edgeHandles,
|
||
};
|
||
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
|
||
|
||
useEffect(() => () => {
|
||
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!layoutPersistReadyRef.current) {
|
||
layoutPersistReadyRef.current = true;
|
||
return;
|
||
}
|
||
schedulePersistFlowLayout(layoutStrategyKey);
|
||
}, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
|
||
|
||
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
|
||
|
||
useEffect(() => {
|
||
loadStrategies().then(async list => {
|
||
if (list?.length) {
|
||
setStrategies(list.map(s => ({
|
||
id: s.id ?? Date.now(),
|
||
name: s.name,
|
||
description: s.description,
|
||
buyCondition: s.buyCondition as LogicNode | null ?? null,
|
||
sellCondition: s.sellCondition as LogicNode | null ?? null,
|
||
enabled: s.enabled ?? true,
|
||
createdAt: s.createdAt,
|
||
updatedAt: s.updatedAt,
|
||
})));
|
||
} else {
|
||
const local = loadStratsLocal();
|
||
if (!local.length) return;
|
||
const migrated: StrategyDto[] = [];
|
||
for (const s of local) {
|
||
try {
|
||
const saved = await saveStrategy({
|
||
name: s.name,
|
||
description: s.description,
|
||
buyCondition: s.buyCondition,
|
||
sellCondition: s.sellCondition,
|
||
enabled: s.enabled ?? true,
|
||
});
|
||
if (saved?.id) {
|
||
migrated.push({
|
||
id: saved.id,
|
||
name: saved.name,
|
||
description: saved.description,
|
||
buyCondition: saved.buyCondition as LogicNode | null ?? null,
|
||
sellCondition: saved.sellCondition as LogicNode | null ?? null,
|
||
enabled: saved.enabled ?? true,
|
||
createdAt: saved.createdAt,
|
||
updatedAt: saved.updatedAt,
|
||
});
|
||
}
|
||
} catch { /* skip */ }
|
||
}
|
||
if (migrated.length) setStrategies(migrated);
|
||
}
|
||
}).catch(() => {});
|
||
}, []);
|
||
|
||
const handleSelectStrategy = (s: StrategyDto) => {
|
||
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
|
||
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
|
||
const stored = loadStrategyFlowLayout(String(s.id));
|
||
|
||
setSelectedId(s.id);
|
||
setStratName(s.name);
|
||
setStratDesc(s.description ?? '');
|
||
setBuyCondition(buyDecoded.root);
|
||
setSellCondition(sellDecoded.root);
|
||
setBuyOrphans(stored?.buy.orphans ?? []);
|
||
setSellOrphans(stored?.sell.orphans ?? []);
|
||
setBuyLayout({
|
||
positions: stored?.buy.positions ?? {},
|
||
edgeHandles: stored?.buy.edgeHandles ?? {},
|
||
startMeta: stored?.buy.startMeta ?? buyDecoded.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: stored?.sell.startMeta ?? sellDecoded.startMeta,
|
||
extraStartIds: stored?.sell.extraStartIds?.length ? stored.sell.extraStartIds : sellDecoded.extraStartIds,
|
||
extraRoots: stored?.sell.extraRoots && Object.keys(stored.sell.extraRoots).length
|
||
? stored.sell.extraRoots
|
||
: sellDecoded.extraRoots,
|
||
startCombineOp: normalizeStartCombineOp(stored?.sell.startCombineOp ?? sellDecoded.startCombineOp),
|
||
});
|
||
setSelectedNodeId(null);
|
||
bumpLayoutSeed(String(s.id), signalTab);
|
||
};
|
||
|
||
const handleNew = () => {
|
||
setSelectedId(null);
|
||
setStratName('');
|
||
setStratDesc('');
|
||
setBuyCondition(null);
|
||
setSellCondition(null);
|
||
setSelectedNodeId(null);
|
||
resetFlowLayout('draft', signalTab);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
if (!stratName.trim()) { showSnack('전략 이름을 입력하세요', false); return; }
|
||
const encodedBuy = encodeConditionForSave(buyEditorState);
|
||
const encodedSell = encodeConditionForSave(sellEditorState);
|
||
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||
setIsSaving(true);
|
||
try {
|
||
const payload: ApiStrategyDto = {
|
||
id: selectedId ?? undefined,
|
||
name: stratName,
|
||
description: stratDesc,
|
||
buyCondition: encodedBuy,
|
||
sellCondition: encodedSell,
|
||
enabled: true,
|
||
};
|
||
const saved = await saveStrategy(payload);
|
||
const now = new Date().toISOString();
|
||
const dbId = saved?.id ?? selectedId ?? Date.now();
|
||
const wasDraft = selectedId == null;
|
||
setStrategies(prev => {
|
||
const existing = prev.find(s => s.id === selectedId);
|
||
if (existing && selectedId) {
|
||
return prev.map(s => s.id === selectedId
|
||
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now }
|
||
: s);
|
||
}
|
||
setSelectedId(dbId);
|
||
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
|
||
});
|
||
if (!selectedId) setSelectedId(dbId);
|
||
if (wasDraft) {
|
||
migrateStrategyFlowLayout('draft', String(dbId));
|
||
}
|
||
persistFlowLayout(String(dbId));
|
||
bumpLayoutSeed(String(dbId), signalTab);
|
||
setSaveOpen(false);
|
||
setSaveToast(true);
|
||
setTimeout(() => setSaveToast(false), 2500);
|
||
showSnack(selectedId ? '전략이 수정되었습니다' : '전략이 저장되었습니다');
|
||
} catch (e) {
|
||
showSnack(e instanceof Error ? e.message : '저장 실패', false);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteConfirm = async () => {
|
||
if (!deleteId) return;
|
||
try { await deleteStrategy(deleteId); } catch { /* local */ }
|
||
deleteStrategyFlowLayout(String(deleteId));
|
||
setStrategies(prev => prev.filter(s => s.id !== deleteId));
|
||
if (selectedId === deleteId) handleNew();
|
||
setDeleteOpen(false);
|
||
setDeleteId(null);
|
||
showSnack('전략이 삭제되었습니다');
|
||
};
|
||
|
||
const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: 'buy' | 'sell' = signalTab) => {
|
||
if (layout) {
|
||
setBuyLayout({
|
||
positions: layout.buy.positions ?? {},
|
||
edgeHandles: layout.buy.edgeHandles ?? {},
|
||
startMeta: layout.buy.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: layout.buy.extraStartIds ?? [],
|
||
extraRoots: layout.buy.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(layout.buy.startCombineOp),
|
||
});
|
||
setSellLayout({
|
||
positions: layout.sell.positions ?? {},
|
||
edgeHandles: layout.sell.edgeHandles ?? {},
|
||
startMeta: layout.sell.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: layout.sell.extraStartIds ?? [],
|
||
extraRoots: layout.sell.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(layout.sell.startCombineOp),
|
||
});
|
||
setBuyOrphans(layout.buy.orphans ?? []);
|
||
setSellOrphans(layout.sell.orphans ?? []);
|
||
saveStrategyFlowLayout('draft', layout);
|
||
} else {
|
||
resetFlowLayout('draft', tab);
|
||
}
|
||
bumpLayoutSeed('draft', tab);
|
||
}, [resetFlowLayout, bumpLayoutSeed, signalTab]);
|
||
|
||
const handleExport = useCallback(() => {
|
||
const encodedBuy = encodeConditionForSave(buyEditorState);
|
||
const encodedSell = encodeConditionForSave(sellEditorState);
|
||
if (!encodedBuy && !encodedSell) {
|
||
showSnack('내보낼 조건이 없습니다', false);
|
||
return;
|
||
}
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
const payload = buildStrategyExportPayload({
|
||
name: stratName,
|
||
description: stratDesc,
|
||
buyCondition: encodedBuy,
|
||
sellCondition: encodedSell,
|
||
flowLayout: {
|
||
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
||
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
||
},
|
||
editorMode,
|
||
});
|
||
const safeName = (stratName || '전략').replace(/[^\w\uAC00-\uD7A3-]+/g, '_');
|
||
downloadStrategyJson(`${safeName}_${new Date().toISOString().slice(0, 10)}`, payload);
|
||
showSnack('JSON으로 내보냈습니다');
|
||
}, [buyEditorState, sellEditorState, stratName, stratDesc, buyOrphans, sellOrphans, editorMode]);
|
||
|
||
const handleImport = useCallback(async () => {
|
||
const text = await pickJsonFile();
|
||
if (!text) return;
|
||
try {
|
||
const result = parseStrategyImportFile(text);
|
||
if (result.kind === 'list') {
|
||
showSnack('단일 전략 파일이 아닙니다. 전체 가져오기(⬆)를 사용하세요', false);
|
||
return;
|
||
}
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
const { data } = result;
|
||
const buyDecoded = decodeConditionForEditor(data.buyCondition ?? null);
|
||
const sellDecoded = decodeConditionForEditor(data.sellCondition ?? null);
|
||
setBuyCondition(buyDecoded.root);
|
||
setSellCondition(sellDecoded.root);
|
||
setStratName(data.name ?? '가져온 전략');
|
||
setStratDesc(data.description ?? '');
|
||
setSelectedId(null);
|
||
setSelectedNodeId(null);
|
||
applyImportedFlowLayout(data.flowLayout);
|
||
setBuyLayout(prev => ({
|
||
...prev,
|
||
startCombineOp: normalizeStartCombineOp(data.flowLayout?.buy?.startCombineOp ?? buyDecoded.startCombineOp),
|
||
}));
|
||
setSellLayout(prev => ({
|
||
...prev,
|
||
startCombineOp: normalizeStartCombineOp(data.flowLayout?.sell?.startCombineOp ?? sellDecoded.startCombineOp),
|
||
}));
|
||
if (data.editorMode === 'list' || data.editorMode === 'graph') {
|
||
setEditorMode(data.editorMode);
|
||
saveEditorMode(data.editorMode);
|
||
}
|
||
showSnack('전략을 가져왔습니다');
|
||
} catch (e) {
|
||
showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false);
|
||
}
|
||
}, [editorMode, applyImportedFlowLayout]);
|
||
|
||
const handleExportAll = useCallback(() => {
|
||
if (strategies.length === 0) {
|
||
showSnack('내보낼 전략이 없습니다', false);
|
||
return;
|
||
}
|
||
const items = strategies.map(s => strategyDtoToListExportItem(
|
||
s,
|
||
loadStrategyFlowLayout(String(s.id)),
|
||
));
|
||
downloadStrategyJson(
|
||
`전략목록_${new Date().toISOString().slice(0, 10)}`,
|
||
buildStrategyListExportPayload(items),
|
||
);
|
||
showSnack(`${strategies.length}개 전략을 내보냈습니다`);
|
||
}, [strategies]);
|
||
|
||
const handleImportAll = useCallback(async () => {
|
||
const text = await pickJsonFile();
|
||
if (!text) return;
|
||
try {
|
||
const result = parseStrategyImportFile(text);
|
||
if (result.kind !== 'list') {
|
||
showSnack('전략 목록 형식 파일이 필요합니다', false);
|
||
return;
|
||
}
|
||
const baseId = Date.now();
|
||
const imported = result.data.strategies.map((item, index) => {
|
||
const id = baseId + index;
|
||
if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout);
|
||
return listImportItemToStrategyDto(item, id);
|
||
});
|
||
setStrategies(prev => [...prev, ...imported]);
|
||
showSnack(`${imported.length}개 전략을 가져왔습니다`);
|
||
} catch (e) {
|
||
showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false);
|
||
}
|
||
}, []);
|
||
|
||
const handleAddStartSection = useCallback(() => {
|
||
handleEditorStateChange(addExtraStartSection(currentEditorState));
|
||
bumpLayoutSeed(layoutStrategyKey, signalTab);
|
||
}, [handleEditorStateChange, currentEditorState, bumpLayoutSeed, layoutStrategyKey, signalTab]);
|
||
|
||
const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => {
|
||
if (type === 'start') {
|
||
handleAddStartSection();
|
||
return;
|
||
}
|
||
const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined);
|
||
const root = currentRoot;
|
||
if (!root) setCurrentRoot(newNode);
|
||
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
|
||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
|
||
|
||
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
||
const composite = item.kind === 'composite';
|
||
const newNode = makeNode('indicator', item.value, signalTab, DEF, {
|
||
composite,
|
||
period: item.period,
|
||
leftPeriod: item.shortPeriod,
|
||
rightPeriod: item.longPeriod,
|
||
});
|
||
const root = currentRoot;
|
||
if (!root) setCurrentRoot(newNode);
|
||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
|
||
|
||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||
|
||
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
|
||
const targetTab = tmpl.signal;
|
||
const nextRoot = tmpl.kind === 'composite' ? tmpl.build() : simpleTemplateToNode(tmpl);
|
||
|
||
setBuyCondition(null);
|
||
setSellCondition(null);
|
||
setSelectedNodeId(null);
|
||
|
||
if (targetTab === 'buy') setBuyCondition(nextRoot);
|
||
else setSellCondition(nextRoot);
|
||
|
||
resetFlowLayout(layoutStrategyKey, targetTab);
|
||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||
|
||
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
|
||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]);
|
||
|
||
const operators = [
|
||
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
|
||
{ type: 'operator' as const, value: 'OR', label: 'OR', color: 'logic-or' },
|
||
{ type: 'operator' as const, value: 'NOT', label: 'NOT', color: 'logic-not' },
|
||
];
|
||
|
||
const maBandItems = [
|
||
{ type: 'indicator' as const, value: 'MA', label: 'MA', desc: '이동평균', color: 'band' },
|
||
{ type: 'indicator' as const, value: 'EMA', label: 'EMA', desc: '지수이동평균', color: 'band' },
|
||
{ type: 'indicator' as const, value: 'BOLLINGER', label: 'Bollinger', desc: '볼린저밴드', color: 'band' },
|
||
{ type: 'indicator' as const, value: 'DONCHIAN', label: 'Donchian', desc: '돈치안 채널', color: 'band' },
|
||
{ type: 'indicator' as const, value: 'ICHIMOKU', label: 'Ichimoku', desc: '일목균형표', color: 'band' },
|
||
];
|
||
|
||
const q = paletteSearch.trim().toLowerCase();
|
||
const match = (label: string, desc?: string) =>
|
||
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
||
|
||
const paletteKey = (type: string, id: string) => `${type}:${id}`;
|
||
const selectPalette = (type: string, id: string) => {
|
||
setSelectedPaletteKey(paletteKey(type, id));
|
||
};
|
||
const isPaletteSelected = (type: string, id: string) =>
|
||
selectedPaletteKey === paletteKey(type, id);
|
||
|
||
return (
|
||
<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</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={() => 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={setCurrentOrphans}
|
||
def={DEF}
|
||
signalTab={signalTab}
|
||
onChange={setCurrentRoot}
|
||
selectedNodeId={selectedNodeId}
|
||
onSelectNode={setSelectedNodeId}
|
||
layoutSeed={layoutSeed}
|
||
onLayoutChange={handleLayoutChange}
|
||
startMeta={currentLayout.startMeta}
|
||
extraStartIds={currentLayout.extraStartIds}
|
||
extraRoots={currentLayout.extraRoots}
|
||
onStartMetaChange={handleStartMetaChange}
|
||
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) {
|
||
setCurrentOrphans(currentOrphans.map(o => (
|
||
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
||
)));
|
||
return;
|
||
}
|
||
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
|
||
if (findLogicNode(currentRoot, [], selectedNodeId)) {
|
||
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||
return;
|
||
}
|
||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||
if (branch && findLogicNode(branch, [], selectedNodeId)) {
|
||
handleExtraRootsChange({
|
||
...(currentLayout.extraRoots ?? {}),
|
||
[startId]: updateNode(branch, selectedNodeId, n => ({ ...n, condition: c })),
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
<span className="se-sync-tip">전략 조건 전용 설정 · 차트 보조지표와 무관</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<StrategyListEditor
|
||
editorState={currentEditorState}
|
||
signalTab={signalTab}
|
||
def={DEF}
|
||
onEditorStateChange={handleEditorStateChange}
|
||
onAddStart={handleAddStartSection}
|
||
orphans={currentOrphans}
|
||
onOrphansChange={setCurrentOrphans}
|
||
/>
|
||
)}
|
||
|
||
</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={() => setSaveOpen(false)} title="전략 저장">
|
||
<div className="se-modal-body">
|
||
<label className="se-field-lbl">전략명 *</label>
|
||
<input className="se-field-inp" value={stratName} onChange={e => setStratName(e.target.value)} placeholder="예: Golden_RSI_V1" />
|
||
<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>
|
||
);
|
||
}
|