Files
goldenChart/frontend/src/components/StrategyEditorPage.tsx
T
2026-05-25 01:34:52 +09:00

955 lines
39 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 { 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 {
COMPOSITE_INDICATOR_ITEMS,
compositePeriodLabel,
} from '../utils/compositeIndicators';
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 { 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 '../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 ?? [],
};
}
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 [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 [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,
}));
const [sellLayout, setSellLayout] = useState(() => ({
positions: initialDraftSell.positions,
edgeHandles: initialDraftSell.edgeHandles,
}));
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 selectedLogicNode = useMemo(() => {
if (!selectedNodeId) return null;
return findLogicNode(currentRoot, currentOrphans, selectedNodeId);
}, [selectedNodeId, currentRoot, currentOrphans]);
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(emptyBuy);
setSellLayout(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 ?? {},
};
const nextSell = {
positions: stored.sell.positions ?? {},
edgeHandles: stored.sell.edgeHandles ?? {},
};
setBuyLayout(nextBuy);
setSellLayout(nextSell);
setBuyOrphans(stored.buy.orphans ?? []);
setSellOrphans(stored.sell.orphans ?? []);
} else {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(emptyBuy);
setSellLayout(emptySell);
setBuyOrphans([]);
setSellOrphans([]);
}
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
const handleLayoutChange = useCallback((snapshot: FlowLayoutChangePayload) => {
const next = {
positions: snapshot.positions,
edgeHandles: snapshot.edgeHandles,
};
if (snapshot.tab === 'buy') setBuyLayout(next);
else setSellLayout(next);
schedulePersistFlowLayout(layoutStrategyKey);
}, [layoutStrategyKey, schedulePersistFlowLayout]);
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) => {
setSelectedId(s.id);
setStratName(s.name);
setStratDesc(s.description ?? '');
setBuyCondition(s.buyCondition ?? null);
setSellCondition(s.sellCondition ?? null);
setSelectedNodeId(null);
applyStoredFlowLayout(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; }
if (!buyCondition && !sellCondition) { showSnack('조건을 최소 1개 추가하세요', false); return; }
setIsSaving(true);
try {
const payload: ApiStrategyDto = {
id: selectedId ?? undefined,
name: stratName,
description: stratDesc,
buyCondition,
sellCondition,
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, sellCondition, updatedAt: saved?.updatedAt ?? now }
: s);
}
setSelectedId(dbId);
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition, sellCondition, 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 ?? {},
});
setSellLayout({
positions: layout.sell.positions ?? {},
edgeHandles: layout.sell.edgeHandles ?? {},
});
setBuyOrphans(layout.buy.orphans ?? []);
setSellOrphans(layout.sell.orphans ?? []);
saveStrategyFlowLayout('draft', layout);
} else {
resetFlowLayout('draft', tab);
}
bumpLayoutSeed('draft', tab);
}, [resetFlowLayout, bumpLayoutSeed, signalTab]);
const handleExport = useCallback(() => {
if (!buyCondition && !sellCondition) {
showSnack('내보낼 조건이 없습니다', false);
return;
}
if (editorMode === 'graph') layoutFlushRef.current?.();
const payload = buildStrategyExportPayload({
name: stratName,
description: stratDesc,
buyCondition,
sellCondition,
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으로 내보냈습니다');
}, [buyCondition, sellCondition, 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;
setBuyCondition(data.buyCondition ?? null);
setSellCondition(data.sellCondition ?? null);
setStratName(data.name ?? '가져온 전략');
setStratDesc(data.description ?? '');
setSelectedId(null);
setSelectedNodeId(null);
applyImportedFlowLayout(data.flowLayout);
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 applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => {
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]);
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 indicatorItems = [
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', color: 'ind' as const },
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', color: 'ind' as const },
{ value: 'STOCHASTIC', label: 'Stochastic', desc: '스토캐스틱', color: 'ind' as const },
{ value: 'CCI', label: 'CCI', desc: '상품채널지수', color: 'ind' as const },
{ value: 'ADX', label: 'ADX', desc: '평균방향지수', color: 'ind' as const },
{ value: 'DMI', label: 'DMI', desc: '방향성 지표', color: 'ind' as const },
{ value: 'OBV', label: 'OBV', desc: '거래량 균형', color: 'ind' as const },
{ value: 'WILLIAMS_R', label: 'Williams %R', desc: '윌리엄스 %R', color: 'ind' as const },
{ value: 'TRIX', label: 'TRIX', desc: '삼중지수이동평균', color: 'ind' as const },
{ value: 'VOLUME_OSC', label: 'Volume OSC', desc: '거래량 오실레이터', color: 'ind' as const },
{ value: 'VR', label: 'VR', desc: '거래량 비율', color: 'ind' as const },
{ value: 'DISPARITY', label: '이격도', desc: 'Disparity', color: 'ind' as const },
{ value: 'PSYCHOLOGICAL', label: '심리도', desc: 'Psychological', color: 'ind' as const },
{ value: 'INVEST_PSYCHOLOGICAL', label: '투자심리도', desc: 'Invest PSY', color: 'ind' as const },
{ value: 'VOLUME', label: '거래량', desc: 'Volume', color: 'ind' as const },
];
const q = paletteSearch.trim().toLowerCase();
const match = (label: string, desc?: string) =>
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
const paletteKey = (type: 'operator' | 'indicator' | 'composite', value: string) => `${type}:${value}`;
const selectPalette = (type: 'operator' | 'indicator' | 'composite', value: string) => {
setSelectedPaletteKey(paletteKey(type, value));
};
const isPaletteSelected = (type: 'operator' | 'indicator' | 'composite', value: string) =>
selectedPaletteKey === paletteKey(type, value);
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"
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">
<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>}
</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}
/>
</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
)));
} else if (currentRoot) {
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
}
}}
/>
<span className="se-sync-tip">전략 조건 전용 설정 · 차트 보조지표와 무관</span>
</div>
)}
</>
) : (
<StrategyListEditor
root={currentRoot}
signalTab={signalTab}
def={DEF}
onChange={setCurrentRoot}
/>
)}
</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}
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">
{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-section se-palette-section--aux">
<h3>보조지표</h3>
<div className="se-palette-grid se-palette-grid--3">
{indicatorItems.filter(i => match(i.label, i.desc)).map(item => (
<PaletteChip
key={item.value}
type="indicator"
value={item.value}
label={item.label}
desc={item.desc}
color={item.color}
period={getIndicatorPeriodLabel(item.value, DEF)}
selected={isPaletteSelected('indicator', item.value)}
onSelect={() => selectPalette('indicator', item.value)}
onAdd={() => applyPalette('indicator', item.value, item.label)}
/>
))}
</div>
</div>
<div className="se-palette-section se-palette-section--composite se-palette-section--scroll">
<h3>복합지표</h3>
<div className="se-palette-grid se-palette-grid--3">
{COMPOSITE_INDICATOR_ITEMS.filter(i => match(i.label, i.desc)).map(item => (
<PaletteChip
key={`composite-${item.value}`}
type="indicator"
value={item.value}
label={item.label}
desc={item.desc}
color="composite"
composite
period={compositePeriodLabel(item.value, DEF)}
selected={isPaletteSelected('composite', item.value)}
onSelect={() => selectPalette('composite', item.value)}
onAdd={() => applyPalette('indicator', item.value, item.label, true)}
/>
))}
</div>
</div>
</>
)}
{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>
)}
{snack && <div className={`se-snack${snack.ok ? '' : ' se-snack--err'}`}>{snack.msg}</div>}
</div>
);
}