2814 lines
113 KiB
TypeScript
2814 lines
113 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 { hasRegisteredUser, loadStrategies, loadStrategy, saveStrategy, deleteStrategy, runBacktest, loadBacktestSettings, loadIndicatorSettings, type BacktestSignal, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
|
||
import { resolveStrategyPrimaryTimeframe } from '../utils/strategyToChartIndicators';
|
||
import { fetchBacktestCandleBundle } from '../utils/backtestWarmup';
|
||
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
|
||
import type { LogicNode } from '../utils/strategyTypes';
|
||
import {
|
||
buildStrategyEditorDefFromSettings,
|
||
loadStratsLocal,
|
||
saveStratsLocal,
|
||
mergeAtRoot,
|
||
makeNode,
|
||
updateNode,
|
||
CondEditor,
|
||
genId,
|
||
type StrategyDto,
|
||
} from '../utils/strategyEditorShared';
|
||
import { syncLogicNodeWithGlobalDef, syncLogicRootWithGlobalDef } from '../utils/strategyDefSync';
|
||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||
import { findLogicNode, getIndicatorPeriodLabel } from '../utils/strategyFlowLayout';
|
||
import {
|
||
decodeConditionForEditor,
|
||
alignBuySellStartCandleTypesForSave,
|
||
encodeConditionForSave,
|
||
mergeStartMetaForLoad,
|
||
addExtraStartSection,
|
||
hasMultipleStartSections,
|
||
normalizeStartCombineOp,
|
||
updateStartCombineOp,
|
||
updateStartCandleTypes,
|
||
updateBranchRoot,
|
||
type EditorConditionState,
|
||
} from '../utils/strategyConditionSerde';
|
||
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
|
||
import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
|
||
import {
|
||
START_NODE_ID,
|
||
defaultStartMeta,
|
||
isStartNodeId,
|
||
type StartCombineOp,
|
||
} from '../utils/strategyStartNodes';
|
||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||
import TemplatePaletteTab, { type TemplatePaletteRow } from './strategyEditor/TemplatePaletteTab';
|
||
import AiStrategyPanel, { type AiStrategyContext } from './strategyEditor/AiStrategyPanel';
|
||
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
|
||
import TrendLinePaletteTab from './strategyEditor/TrendLinePaletteTab';
|
||
import { buildTrendLineConditionNode } from '../utils/trendLinePalette';
|
||
import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage';
|
||
import {
|
||
BAND_PALETTE_CHIP_ITEMS,
|
||
loadPaletteItems,
|
||
type PaletteItem,
|
||
} from '../utils/strategyPaletteStorage';
|
||
import {
|
||
buildStrategyTemplatePaletteRows,
|
||
filterStrategyTemplatePaletteRows,
|
||
} from '../utils/strategyTemplateList';
|
||
import {
|
||
buildPriceExtremeBreakoutTree,
|
||
isPriceExtremeBreakoutPairPaletteValue,
|
||
isPriceExtremeBreakoutPairRoot,
|
||
findPriceExtremePairRootInForest,
|
||
patchPriceExtremePairInTree,
|
||
setPriceExtremePairFilterLevel,
|
||
inferPriceExtremeFilterLevel,
|
||
type PriceExtremeFilterLevel,
|
||
} from '../utils/priceExtremeBreakoutPair';
|
||
import {
|
||
buildInflection33Tree,
|
||
isInflection33PaletteValue,
|
||
isInflection33PairRoot,
|
||
setInflection33PairFilterLevel,
|
||
patchInflection33PairInTree,
|
||
inferInflection33FilterLevel,
|
||
type Inflection33FilterLevel,
|
||
} from '../utils/inflection33Strategy';
|
||
import {
|
||
buildIchimokuBbTree,
|
||
isIchimokuBbPaletteValue,
|
||
} from '../utils/ichimokuBbStrategy';
|
||
import {
|
||
buildStableStrategyTree,
|
||
isStableStrategyPaletteValue,
|
||
isStableStrategyPairRoot,
|
||
setStableStrategyPairFilterLevel,
|
||
patchStableStrategyPairInTree,
|
||
inferStableStrategyFilterLevel,
|
||
type StableStrategyFilterLevel,
|
||
type StableStrategyId,
|
||
} from '../utils/stableStrategyPairs';
|
||
import {
|
||
buildIchimokuSituationTree,
|
||
isIchimokuSituationPaletteValue,
|
||
} from '../utils/ichimokuSituationStrategy';
|
||
import {
|
||
buildStochOverboughtPairTree,
|
||
findStochPairRootInForest,
|
||
isStochOverboughtPairPaletteValue,
|
||
isStochOverboughtPairRoot,
|
||
patchStochPairInTree,
|
||
setStochPairSecondary,
|
||
} from '../utils/stochOverboughtPair';
|
||
import StochPairNodeSettings from './strategyEditor/StochPairNodeSettings';
|
||
import PriceExtremePairNodeSettings from './strategyEditor/PriceExtremePairNodeSettings';
|
||
import Inflection33PairNodeSettings from './strategyEditor/Inflection33PairNodeSettings';
|
||
import StableStrategyPairNodeSettings from './strategyEditor/StableStrategyPairNodeSettings';
|
||
import {
|
||
emptySignalFlowLayout,
|
||
buildStrategyFlowLayoutStore,
|
||
normalizeStrategyFlowLayout,
|
||
readLegacyFlowLayoutFromLocalStorage,
|
||
clearLegacyFlowLayoutFromLocalStorage,
|
||
type SignalFlowLayoutSnapshot,
|
||
type FlowLayoutChangePayload,
|
||
type StrategyFlowLayoutStore,
|
||
} from '../utils/strategyEditorLayoutStorage';
|
||
import LogicExpressionPreview from './strategyEditor/LogicExpressionPreview';
|
||
import LogicExpressionNarrative from './strategyEditor/LogicExpressionNarrative';
|
||
import StrategyEditorCanvas, { type FlowLayoutSeed, type StrategyEditorCanvasHandle } from './strategyEditor/StrategyEditorCanvas';
|
||
import StrategyListEditor from './strategyEditor/StrategyListEditor';
|
||
import StrategyEditorEvaluationPanel from './strategyEditor/StrategyEditorEvaluationPanel';
|
||
import StartCombineOpControl from './strategyEditor/StartCombineOpControl';
|
||
import { layoutFlushRef } from './strategyEditor/strategyEditorCallbacks';
|
||
import {
|
||
loadEditorMode,
|
||
saveEditorMode,
|
||
type StrategyEditorMode,
|
||
} from '../utils/strategyEditorModeStorage';
|
||
import {
|
||
simpleTemplateToNode,
|
||
type StrategyTemplateDef,
|
||
} from '../utils/strategyPresets';
|
||
import {
|
||
addUserStrategyTemplate,
|
||
deleteUserStrategyTemplate,
|
||
updateUserStrategyTemplate,
|
||
hideBuiltinTemplate,
|
||
loadHiddenBuiltinTemplateIds,
|
||
loadUserStrategyTemplates,
|
||
type UserStrategyTemplate,
|
||
} from '../utils/strategyTemplateStorage';
|
||
import {
|
||
buildStrategyExportPayload,
|
||
buildStrategyListExportPayload,
|
||
downloadStrategyJson,
|
||
listImportItemToStrategyDto,
|
||
parseStrategyImportFile,
|
||
pickJsonFile,
|
||
strategyDtoToListExportItem,
|
||
} from '../utils/strategyImportExport';
|
||
import PaletteChip from './strategyEditor/PaletteChip';
|
||
import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay';
|
||
import { needsPointerPaletteDrag } from '../utils/paletteDragSession';
|
||
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
|
||
import {
|
||
clampPanelSize,
|
||
readStoredBool,
|
||
readStoredSize,
|
||
storeBool,
|
||
storeSize,
|
||
usePanelResize,
|
||
useRightPanelResize,
|
||
} from './strategyEditor/usePanelResize';
|
||
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
|
||
import ReactDOM from 'react-dom';
|
||
import { MarketSearchPanel } from './MarketSearchPanel';
|
||
import { getKoreanName } from '../utils/marketNameCache';
|
||
import type { Timeframe, OHLCVBar } from '../types';
|
||
import {
|
||
BACKTEST_STRATEGY_TIMEFRAME,
|
||
buildQuickRunTimeframeSelectOptions,
|
||
parseBacktestRunTimeframeChoice,
|
||
resolveBacktestExecTimeframe,
|
||
type BacktestRunTimeframeChoice,
|
||
} from '../utils/backtestRunTimeframe';
|
||
import '../styles/strategyEditor.css';
|
||
import '../styles/strategyEditorTheme.css';
|
||
import '../styles/strategyEvaluation.css';
|
||
import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup';
|
||
import {
|
||
buildStrategyEvaluationToolbarSummary,
|
||
formatEvaluationReturnPct,
|
||
} from '../utils/strategyEvaluationReport';
|
||
import { resolveEvaluationCommissionRate } from '../utils/strategyEvaluationSignals';
|
||
import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart';
|
||
|
||
type SignalTab = 'buy' | 'sell' | 'eval';
|
||
type BuySellTab = 'buy' | 'sell';
|
||
|
||
function buySellTab(tab: SignalTab): BuySellTab {
|
||
return tab === 'sell' ? 'sell' : 'buy';
|
||
}
|
||
|
||
const BACKTEST_FOCUS_KEY = 'backtest_focus_id';
|
||
|
||
const LEFT_PANEL_MIN = 220;
|
||
const LEFT_PANEL_MAX = 520;
|
||
const LEFT_PANEL_DEFAULT = 280;
|
||
const RIGHT_PANEL_MIN = 380;
|
||
const RIGHT_PANEL_MAX = 560;
|
||
const RIGHT_PANEL_DEFAULT = 380;
|
||
const TERMINAL_MIN = 88;
|
||
const TERMINAL_MAX = 420;
|
||
const TERMINAL_DEFAULT = 220;
|
||
|
||
function readRightPanelWidth(): number {
|
||
return clampPanelSize(
|
||
readStoredSize('se-right-width', RIGHT_PANEL_DEFAULT),
|
||
RIGHT_PANEL_MIN,
|
||
RIGHT_PANEL_MAX,
|
||
);
|
||
}
|
||
|
||
interface Props {
|
||
theme: Theme;
|
||
onNavigateToBacktest?: () => void;
|
||
/** 팝업(임베드) — 전략평가 등 */
|
||
variant?: 'page' | 'modal';
|
||
onClose?: () => void;
|
||
initialStrategyId?: number | null;
|
||
onStrategiesChanged?: () => void;
|
||
}
|
||
|
||
function toEditorState(
|
||
root: LogicNode | null,
|
||
layout: Pick<SignalFlowLayoutSnapshot, 'startMeta' | 'extraStartIds' | 'extraRoots' | 'startCombineOp'>,
|
||
): EditorConditionState {
|
||
return {
|
||
root,
|
||
startMeta: layout.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: layout.extraStartIds ?? [],
|
||
extraRoots: layout.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(layout.startCombineOp),
|
||
};
|
||
}
|
||
|
||
function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
|
||
return {
|
||
positions: snap.positions ?? {},
|
||
edgeHandles: snap.edgeHandles ?? {},
|
||
startMeta: snap.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: snap.extraStartIds ?? [],
|
||
extraRoots: snap.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
|
||
};
|
||
}
|
||
|
||
function applyFlowLayoutFromStore(
|
||
stored: StrategyFlowLayoutStore | null,
|
||
setBuyLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
|
||
setSellLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
|
||
setBuyOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
|
||
setSellOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
|
||
) {
|
||
if (stored) {
|
||
setBuyLayout(normalizeTabLayoutState(stored.buy));
|
||
setSellLayout(normalizeTabLayoutState(stored.sell));
|
||
setBuyOrphans(stored.buy.orphans ?? []);
|
||
setSellOrphans(stored.sell.orphans ?? []);
|
||
} else {
|
||
const emptyBuy = emptySignalFlowLayout();
|
||
const emptySell = emptySignalFlowLayout();
|
||
setBuyLayout(normalizeTabLayoutState(emptyBuy));
|
||
setSellLayout(normalizeTabLayoutState(emptySell));
|
||
setBuyOrphans([]);
|
||
setSellOrphans([]);
|
||
}
|
||
}
|
||
|
||
type SaveDialogMode = 'update' | 'create' | 'saveAs';
|
||
|
||
function suggestSaveAsName(baseName: string, existing: StrategyDto[]): string {
|
||
const trimmed = baseName.trim() || '새 전략';
|
||
const names = new Set(existing.map(s => s.name.trim()));
|
||
let candidate = `${trimmed} (복사)`;
|
||
let n = 2;
|
||
while (names.has(candidate)) {
|
||
candidate = `${trimmed} (복사 ${n})`;
|
||
n += 1;
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
function isDuplicateStrategyName(
|
||
name: string,
|
||
strategies: StrategyDto[],
|
||
excludeId?: number | null,
|
||
): boolean {
|
||
const trimmed = name.trim();
|
||
return strategies.some(s => s.name.trim() === trimmed && s.id !== excludeId);
|
||
}
|
||
|
||
export default function StrategyEditorPage({
|
||
theme,
|
||
onNavigateToBacktest,
|
||
variant = 'page',
|
||
onClose,
|
||
initialStrategyId = null,
|
||
onStrategiesChanged,
|
||
}: Props) {
|
||
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
|
||
const DEF = useMemo(
|
||
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
|
||
[getParams, getVisualConfig, settingsRevision],
|
||
);
|
||
const settingsSyncRef = useRef(-1);
|
||
const initialStrategyAppliedRef = useRef(false);
|
||
|
||
const notifyStrategiesChanged = useCallback(() => {
|
||
onStrategiesChanged?.();
|
||
}, [onStrategiesChanged]);
|
||
|
||
const [strategies, setStrategies] = useState<StrategyDto[]>(() => loadStratsLocal());
|
||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||
const [buyCondition, setBuyCondition] = useState<LogicNode | null>(null);
|
||
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
|
||
const [signalTab, setSignalTab] = useState<SignalTab>('buy');
|
||
const [rightTab, setRightTab] = useState<'indicators' | 'templates' | 'ai'>('indicators');
|
||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range' | 'trendline'>('auxiliary');
|
||
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
|
||
const [paletteSearch, setPaletteSearch] = useState('');
|
||
const [strategyListSearch, setStrategyListSearch] = useState('');
|
||
const [templateSearch, setTemplateSearch] = useState('');
|
||
const [userTemplates, setUserTemplates] = useState<UserStrategyTemplate[]>(() => loadUserStrategyTemplates());
|
||
const [hiddenBuiltinTemplateIds, setHiddenBuiltinTemplateIds] = useState<string[]>(
|
||
() => loadHiddenBuiltinTemplateIds(),
|
||
);
|
||
const [templateSaveOpen, setTemplateSaveOpen] = useState(false);
|
||
const [templateModalMode, setTemplateModalMode] = useState<'add' | 'edit'>('add');
|
||
const [templateEditId, setTemplateEditId] = useState<string | null>(null);
|
||
const [templateSaveUpdateContent, setTemplateSaveUpdateContent] = useState(false);
|
||
const [templateSaveLabel, setTemplateSaveLabel] = useState('');
|
||
const [templateSaveDesc, setTemplateSaveDesc] = useState('');
|
||
const [templateSaveLabelError, setTemplateSaveLabelError] = useState(false);
|
||
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string | null>(null);
|
||
const [selectedPaletteKey, setSelectedPaletteKey] = useState<string | null>(null);
|
||
const [stratName, setStratName] = useState('');
|
||
const [stratDesc, setStratDesc] = useState('');
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [saveOpen, setSaveOpen] = useState(false);
|
||
const [saveMode, setSaveMode] = useState<SaveDialogMode>('create');
|
||
const [saveDraftName, setSaveDraftName] = useState('');
|
||
const [saveDraftDesc, setSaveDraftDesc] = useState('');
|
||
const [saveNameError, setSaveNameError] = useState(false);
|
||
const [saveDuplicateError, setSaveDuplicateError] = useState(false);
|
||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||
const [descOpen, setDescOpen] = useState(false);
|
||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||
const canvasRef = useRef<StrategyEditorCanvasHandle>(null);
|
||
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
|
||
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
|
||
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
|
||
const [saveToast, setSaveToast] = useState(false);
|
||
|
||
// ── 빠른 백테스팅 실행 ────────────────────────────────────────────────────
|
||
const [qbMarket, setQbMarket] = useState('KRW-BTC');
|
||
const [qbTimeframe, setQbTimeframe] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
|
||
const [qbLoading, setQbLoading] = useState(false);
|
||
const [qbError, setQbError] = useState<string | null>(null);
|
||
const [qbMarketOpen, setQbMarketOpen] = useState(false);
|
||
const [qbMarketQ, setQbMarketQ] = useState('');
|
||
const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
|
||
|
||
// ── 평가 탭 — 수익률 요약 ─────────────────────────────────────────────────
|
||
const [evalBacktestSignals, setEvalBacktestSignals] = useState<BacktestSignal[]>([]);
|
||
const [evalBars, setEvalBars] = useState<OHLCVBar[]>([]);
|
||
const [evalWindowMeta, setEvalWindowMeta] = useState<StrategyEvaluationWindowMeta>({
|
||
evaluationBarCount: BACKTEST_DISPLAY_BAR_COUNT,
|
||
warmupBarCount: 0,
|
||
isAtLatestWindow: true,
|
||
windowEndTimeSec: null,
|
||
});
|
||
const [evalSignalScanRunning, setEvalSignalScanRunning] = useState(false);
|
||
const [evalMarket, setEvalMarket] = useState('KRW-BTC');
|
||
const [evalInitialCapital, setEvalInitialCapital] = useState(10_000_000);
|
||
const [evalCommissionRate, setEvalCommissionRate] = useState(0.0015);
|
||
|
||
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
|
||
const [rightWidth, setRightWidth] = useState(() => readRightPanelWidth());
|
||
const [rightOpen, setRightOpen] = useState(() => readStoredBool('se-right-open', true));
|
||
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
|
||
const leftWidthRef = useRef(leftWidth);
|
||
const rightWidthRef = useRef(rightWidth);
|
||
const terminalHeightRef = useRef(terminalHeight);
|
||
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
|
||
const buyLayoutRef = useRef(buyLayout);
|
||
const sellLayoutRef = useRef(sellLayout);
|
||
const buyConditionRef = useRef(buyCondition);
|
||
const sellConditionRef = useRef(sellCondition);
|
||
const buyOrphansRef = useRef(buyOrphans);
|
||
const sellOrphansRef = useRef(sellOrphans);
|
||
const selectedIdRef = useRef(selectedId);
|
||
const stratNameRef = useRef(stratName);
|
||
const stratDescRef = useRef(stratDesc);
|
||
buyLayoutRef.current = buyLayout;
|
||
sellLayoutRef.current = sellLayout;
|
||
buyConditionRef.current = buyCondition;
|
||
sellConditionRef.current = sellCondition;
|
||
buyOrphansRef.current = buyOrphans;
|
||
sellOrphansRef.current = sellOrphans;
|
||
selectedIdRef.current = selectedId;
|
||
stratNameRef.current = stratName;
|
||
stratDescRef.current = stratDesc;
|
||
const layoutRevisionRef = useRef(0);
|
||
const strategyAutosaveTimerRef = useRef<number | null>(null);
|
||
const layoutPersistReadyRef = useRef(false);
|
||
const STRATEGY_AUTOSAVE_MS = 350;
|
||
leftWidthRef.current = leftWidth;
|
||
rightWidthRef.current = rightWidth;
|
||
terminalHeightRef.current = terminalHeight;
|
||
|
||
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
|
||
const [editorMode, setEditorMode] = useState<StrategyEditorMode>(() => loadEditorMode());
|
||
|
||
const onLeftSplitter = usePanelResize(
|
||
'vertical',
|
||
setLeftWidth,
|
||
() => leftWidthRef.current,
|
||
LEFT_PANEL_MIN,
|
||
LEFT_PANEL_MAX,
|
||
v => storeSize('se-left-width', v),
|
||
);
|
||
|
||
const onRightSplitter = useRightPanelResize(
|
||
setRightWidth,
|
||
() => rightWidthRef.current,
|
||
RIGHT_PANEL_MIN,
|
||
RIGHT_PANEL_MAX,
|
||
v => storeSize('se-right-width', v),
|
||
);
|
||
|
||
const toggleRightPanel = useCallback(() => {
|
||
setRightOpen(prev => {
|
||
const next = !prev;
|
||
storeBool('se-right-open', next);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
const onTerminalSplitter = usePanelResize(
|
||
'horizontal',
|
||
setTerminalHeight,
|
||
() => terminalHeightRef.current,
|
||
TERMINAL_MIN,
|
||
TERMINAL_MAX,
|
||
v => storeSize('se-terminal-height', v),
|
||
);
|
||
|
||
const bodyStyle = useMemo(() => ({
|
||
'--se-left-width': `${leftWidth}px`,
|
||
'--se-right-width': `${rightWidth}px`,
|
||
'--se-terminal-height': `${terminalHeight}px`,
|
||
}) as React.CSSProperties, [leftWidth, rightWidth, terminalHeight]);
|
||
|
||
const showSnack = (msg: string, ok = true) => {
|
||
setSnack({ msg, ok });
|
||
setTimeout(() => setSnack(null), 3000);
|
||
};
|
||
|
||
const currentRoot = signalTab === 'buy' ? buyCondition : sellCondition;
|
||
const setCurrentRoot = signalTab === 'buy' ? setBuyCondition : setSellCondition;
|
||
const currentOrphans = signalTab === 'buy' ? buyOrphans : sellOrphans;
|
||
const setCurrentOrphans = signalTab === 'buy' ? setBuyOrphans : setSellOrphans;
|
||
|
||
const currentLayout = signalTab === 'buy' ? buyLayout : sellLayout;
|
||
const setCurrentLayout = signalTab === 'buy' ? setBuyLayout : setSellLayout;
|
||
|
||
const buyEditorState = useMemo(
|
||
() => toEditorState(buyCondition, buyLayout),
|
||
[buyCondition, buyLayout],
|
||
);
|
||
const sellEditorState = useMemo(
|
||
() => toEditorState(sellCondition, sellLayout),
|
||
[sellCondition, sellLayout],
|
||
);
|
||
const currentEditorState = signalTab === 'buy' ? buyEditorState : sellEditorState;
|
||
const currentEditorStateRef = useRef(currentEditorState);
|
||
currentEditorStateRef.current = currentEditorState;
|
||
|
||
const selectedLogicNode = useMemo(() => {
|
||
if (!selectedNodeId) return null;
|
||
return findLogicNode(
|
||
currentRoot,
|
||
currentOrphans,
|
||
selectedNodeId,
|
||
currentLayout.extraRoots ?? {},
|
||
);
|
||
}, [selectedNodeId, currentRoot, currentOrphans, currentLayout.extraRoots]);
|
||
|
||
const handleDeleteSelectedNode = useCallback(() => {
|
||
if (!selectedNodeId || isStartNodeId(selectedNodeId)) return;
|
||
canvasRef.current?.deleteNode(selectedNodeId);
|
||
}, [selectedNodeId]);
|
||
|
||
const stochPairForest = useMemo(
|
||
() => [
|
||
currentRoot,
|
||
...currentOrphans,
|
||
...Object.values(currentLayout.extraRoots ?? {}),
|
||
],
|
||
[currentRoot, currentOrphans, currentLayout.extraRoots],
|
||
);
|
||
|
||
const orphanTotal = buyOrphans.length + sellOrphans.length;
|
||
|
||
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
|
||
|
||
const buildCurrentFlowLayout = useCallback(
|
||
(): StrategyFlowLayoutStore => buildStrategyFlowLayoutStore(
|
||
buyLayoutRef.current,
|
||
sellLayoutRef.current,
|
||
buyOrphansRef.current,
|
||
sellOrphansRef.current,
|
||
),
|
||
[],
|
||
);
|
||
|
||
const persistStrategyToDb = useCallback(async () => {
|
||
if (selectedId == null || !stratName.trim()) return;
|
||
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
|
||
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
|
||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||
if (!encodedBuy && !encodedSell) return;
|
||
const flowLayout = buildCurrentFlowLayout();
|
||
const saved = await persistStrategyEditorState(
|
||
selectedId,
|
||
stratName,
|
||
stratDesc,
|
||
aligned.buy,
|
||
aligned.sell,
|
||
flowLayout,
|
||
);
|
||
setStrategies(prev => prev.map(s => s.id === selectedId
|
||
? {
|
||
...s,
|
||
buyCondition: encodedBuy,
|
||
sellCondition: encodedSell,
|
||
flowLayout,
|
||
updatedAt: saved?.updatedAt ?? s.updatedAt,
|
||
}
|
||
: s));
|
||
}, [selectedId, stratName, stratDesc, buildCurrentFlowLayout]);
|
||
|
||
/** 전략 전환·새 전략 전 — ref 스냅샷을 지정 id 로 저장 (selectedId 변경 후 빈 DSL 덮어쓰기 방지) */
|
||
const persistStrategySnapshot = useCallback(async (
|
||
strategyId: number,
|
||
name: string,
|
||
description: string,
|
||
) => {
|
||
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
|
||
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
|
||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||
if (!encodedBuy && !encodedSell) return;
|
||
const flowLayout = buildCurrentFlowLayout();
|
||
const saved = await persistStrategyEditorState(
|
||
strategyId,
|
||
name,
|
||
description,
|
||
aligned.buy,
|
||
aligned.sell,
|
||
flowLayout,
|
||
);
|
||
setStrategies(prev => prev.map(s => s.id === strategyId
|
||
? {
|
||
...s,
|
||
buyCondition: encodedBuy,
|
||
sellCondition: encodedSell,
|
||
flowLayout,
|
||
updatedAt: saved?.updatedAt ?? s.updatedAt,
|
||
}
|
||
: s));
|
||
}, [buildCurrentFlowLayout]);
|
||
|
||
const flushStrategyPersist = useCallback(() => {
|
||
if (strategyAutosaveTimerRef.current != null) {
|
||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||
strategyAutosaveTimerRef.current = null;
|
||
}
|
||
if (selectedId == null || !stratName.trim()) return;
|
||
void persistStrategyToDb().catch(err => {
|
||
console.warn('[StrategyEditor] 전략 DB 저장 실패:', err);
|
||
});
|
||
}, [selectedId, stratName, persistStrategyToDb]);
|
||
|
||
const scheduleStrategyPersist = useCallback(() => {
|
||
if (selectedId == null || !stratName.trim()) return;
|
||
if (strategyAutosaveTimerRef.current != null) {
|
||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||
}
|
||
strategyAutosaveTimerRef.current = window.setTimeout(() => {
|
||
strategyAutosaveTimerRef.current = null;
|
||
void persistStrategyToDb().catch(err => {
|
||
console.warn('[StrategyEditor] 전략 자동저장 실패:', err);
|
||
});
|
||
}, STRATEGY_AUTOSAVE_MS);
|
||
}, [selectedId, stratName, persistStrategyToDb]);
|
||
|
||
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
|
||
layoutRevisionRef.current += 1;
|
||
setLayoutSeedKey(`${strategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||
}, []);
|
||
|
||
/** 보조지표 설정 변경 시 오버라이드되지 않은 조건 노드·캔버스 기준값 동기화 */
|
||
useEffect(() => {
|
||
if (!settingsLoaded) return;
|
||
if (settingsRevision <= settingsSyncRef.current) return;
|
||
settingsSyncRef.current = settingsRevision;
|
||
|
||
const syncExtraRoots = (
|
||
roots: Record<string, LogicNode | null>,
|
||
signalType: 'buy' | 'sell',
|
||
) => {
|
||
const next: Record<string, LogicNode | null> = {};
|
||
for (const [key, node] of Object.entries(roots)) {
|
||
next[key] = node ? syncLogicNodeWithGlobalDef(node, DEF, signalType) : null;
|
||
}
|
||
return next;
|
||
};
|
||
|
||
setBuyCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'buy') : prev));
|
||
setSellCondition(prev => (prev ? syncLogicNodeWithGlobalDef(prev, DEF, 'sell') : prev));
|
||
setBuyOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'buy')));
|
||
setSellOrphans(prev => prev.map(n => syncLogicNodeWithGlobalDef(n, DEF, 'sell')));
|
||
setBuyLayout(prev => ({
|
||
...prev,
|
||
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'buy'),
|
||
}));
|
||
setSellLayout(prev => ({
|
||
...prev,
|
||
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
|
||
}));
|
||
if (signalTab !== 'eval') bumpLayoutSeed(layoutStrategyKey, buySellTab(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();
|
||
if (signalTab !== 'eval') bumpLayoutSeed(layoutStrategyKey, buySellTab(signalTab));
|
||
}, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]);
|
||
|
||
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
|
||
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
|
||
scheduleStrategyPersist();
|
||
}, [setCurrentLayout, scheduleStrategyPersist]);
|
||
|
||
const applyStochPairSecondary = useCallback((orNodeId: string, secondary: string) => {
|
||
if (currentOrphans.some(o => o.id === orNodeId)) {
|
||
handleOrphansChange(currentOrphans.map(o => (
|
||
o.id === orNodeId ? setStochPairSecondary(o, secondary, DEF) : o
|
||
)));
|
||
return;
|
||
}
|
||
if (currentRoot) {
|
||
const patched = patchStochPairInTree(currentRoot, orNodeId, secondary, DEF);
|
||
if (patched && patched !== currentRoot) {
|
||
handleRootChange(patched);
|
||
return;
|
||
}
|
||
}
|
||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||
if (!branch) continue;
|
||
const patched = patchStochPairInTree(branch, orNodeId, secondary, DEF);
|
||
if (patched && patched !== branch) {
|
||
handleExtraRootsChange({
|
||
...(currentLayout.extraRoots ?? {}),
|
||
[startId]: patched,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
}, [
|
||
currentRoot, currentOrphans, currentLayout.extraRoots, DEF,
|
||
handleOrphansChange, handleRootChange, handleExtraRootsChange,
|
||
]);
|
||
|
||
const applyPriceExtremeFilterLevel = useCallback((andNodeId: string, filterLevel: PriceExtremeFilterLevel) => {
|
||
if (currentOrphans.some(o => o.id === andNodeId)) {
|
||
handleOrphansChange(currentOrphans.map(o => (
|
||
o.id === andNodeId ? setPriceExtremePairFilterLevel(o, filterLevel, DEF) : o
|
||
)));
|
||
return;
|
||
}
|
||
if (currentRoot) {
|
||
const patched = patchPriceExtremePairInTree(currentRoot, andNodeId, filterLevel, DEF);
|
||
if (patched && patched !== currentRoot) {
|
||
handleRootChange(patched);
|
||
return;
|
||
}
|
||
}
|
||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||
if (!branch) continue;
|
||
const patched = patchPriceExtremePairInTree(branch, andNodeId, filterLevel, DEF);
|
||
if (patched && patched !== branch) {
|
||
handleExtraRootsChange({
|
||
...(currentLayout.extraRoots ?? {}),
|
||
[startId]: patched,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
}, [
|
||
currentRoot, currentOrphans, currentLayout.extraRoots, DEF,
|
||
handleOrphansChange, handleRootChange, handleExtraRootsChange,
|
||
]);
|
||
|
||
const applyInflection33FilterLevel = useCallback((pairNodeId: string, filterLevel: Inflection33FilterLevel) => {
|
||
if (currentOrphans.some(o => o.id === pairNodeId)) {
|
||
handleOrphansChange(currentOrphans.map(o => (
|
||
o.id === pairNodeId ? setInflection33PairFilterLevel(o, filterLevel, DEF) : o
|
||
)));
|
||
return;
|
||
}
|
||
if (currentRoot) {
|
||
const patched = patchInflection33PairInTree(currentRoot, pairNodeId, filterLevel, DEF);
|
||
if (patched && patched !== currentRoot) {
|
||
handleRootChange(patched);
|
||
return;
|
||
}
|
||
}
|
||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||
if (!branch) continue;
|
||
const patched = patchInflection33PairInTree(branch, pairNodeId, filterLevel, DEF);
|
||
if (patched && patched !== branch) {
|
||
handleExtraRootsChange({
|
||
...(currentLayout.extraRoots ?? {}),
|
||
[startId]: patched,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
}, [
|
||
currentRoot, currentOrphans, currentLayout.extraRoots, DEF,
|
||
handleOrphansChange, handleRootChange, handleExtraRootsChange,
|
||
]);
|
||
|
||
const applyStableStrategyFilterLevel = useCallback((pairNodeId: string, filterLevel: StableStrategyFilterLevel) => {
|
||
if (currentOrphans.some(o => o.id === pairNodeId)) {
|
||
handleOrphansChange(currentOrphans.map(o => (
|
||
o.id === pairNodeId ? setStableStrategyPairFilterLevel(o, filterLevel, DEF) : o
|
||
)));
|
||
return;
|
||
}
|
||
if (currentRoot) {
|
||
const patched = patchStableStrategyPairInTree(currentRoot, pairNodeId, filterLevel, DEF);
|
||
if (patched && patched !== currentRoot) {
|
||
handleRootChange(patched);
|
||
return;
|
||
}
|
||
}
|
||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||
if (!branch) continue;
|
||
const patched = patchStableStrategyPairInTree(branch, pairNodeId, filterLevel, DEF);
|
||
if (patched && patched !== branch) {
|
||
handleExtraRootsChange({
|
||
...(currentLayout.extraRoots ?? {}),
|
||
[startId]: patched,
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
}, [
|
||
currentRoot, currentOrphans, currentLayout.extraRoots, DEF,
|
||
handleOrphansChange, handleRootChange, handleExtraRootsChange,
|
||
]);
|
||
|
||
const stochPairEditForSelected = useMemo(() => {
|
||
if (!selectedNodeId || selectedLogicNode?.type !== 'CONDITION' || !selectedLogicNode.condition) {
|
||
return undefined;
|
||
}
|
||
const pairRoot = findStochPairRootInForest(stochPairForest, selectedNodeId);
|
||
if (!pairRoot?.stochPair) return undefined;
|
||
return {
|
||
secondaryIndicator: pairRoot.stochPair.secondaryIndicatorType,
|
||
stochLocked: selectedLogicNode.condition.indicatorType === 'STOCHASTIC',
|
||
onSecondaryChange: (next: string) => applyStochPairSecondary(pairRoot.id, next),
|
||
};
|
||
}, [selectedNodeId, selectedLogicNode, stochPairForest, applyStochPairSecondary]);
|
||
|
||
const handleEditorStateChange = useCallback((
|
||
next: EditorConditionState | ((prev: EditorConditionState) => EditorConditionState),
|
||
) => {
|
||
const resolved = typeof next === 'function'
|
||
? next(currentEditorStateRef.current)
|
||
: next;
|
||
currentEditorStateRef.current = resolved;
|
||
setCurrentRoot(resolved.root);
|
||
setCurrentLayout(prev => ({
|
||
...prev,
|
||
startMeta: resolved.startMeta,
|
||
extraStartIds: resolved.extraStartIds,
|
||
extraRoots: resolved.extraRoots,
|
||
startCombineOp: normalizeStartCombineOp(resolved.startCombineOp),
|
||
}));
|
||
scheduleStrategyPersist();
|
||
}, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
|
||
|
||
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
|
||
handleEditorStateChange(prev => updateStartCandleTypes(prev, startId, candleTypes));
|
||
}, [handleEditorStateChange]);
|
||
|
||
const handleStartCombineOpChange = useCallback((op: StartCombineOp) => {
|
||
handleEditorStateChange(prev => updateStartCombineOp(prev, op));
|
||
}, [handleEditorStateChange]);
|
||
|
||
const switchSignalTab = useCallback((tab: SignalTab) => {
|
||
if (tab === signalTab) return;
|
||
if (editorMode === 'graph' && signalTab !== 'eval') layoutFlushRef.current?.();
|
||
if (signalTab !== 'eval') flushStrategyPersist();
|
||
if (tab !== 'eval') {
|
||
layoutRevisionRef.current += 1;
|
||
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
|
||
}
|
||
setSignalTab(tab);
|
||
if (tab !== 'eval') setSelectedNodeId(null);
|
||
}, [layoutStrategyKey, flushStrategyPersist, editorMode, signalTab]);
|
||
|
||
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
|
||
if (mode === editorMode) return;
|
||
if (editorMode === 'graph') {
|
||
layoutFlushRef.current?.();
|
||
flushStrategyPersist();
|
||
}
|
||
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
|
||
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
|
||
}
|
||
setEditorMode(mode);
|
||
saveEditorMode(mode);
|
||
setSelectedNodeId(null);
|
||
}, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]);
|
||
|
||
const layoutSeed = useMemo((): FlowLayoutSeed => {
|
||
const source = signalTab === 'buy' ? buyLayout : sellLayout;
|
||
return {
|
||
seedKey: layoutSeedKey,
|
||
positions: source.positions,
|
||
edgeHandles: source.edgeHandles,
|
||
};
|
||
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
|
||
|
||
const persistStrategySnapshotRef = useRef(persistStrategySnapshot);
|
||
persistStrategySnapshotRef.current = persistStrategySnapshot;
|
||
useEffect(() => () => {
|
||
if (strategyAutosaveTimerRef.current != null) {
|
||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||
strategyAutosaveTimerRef.current = null;
|
||
}
|
||
const id = selectedIdRef.current;
|
||
const name = stratNameRef.current;
|
||
const desc = stratDescRef.current;
|
||
if (id != null && name.trim()) {
|
||
void persistStrategySnapshotRef.current(id, name, desc).catch(() => { /* unmount flush */ });
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!layoutPersistReadyRef.current) {
|
||
layoutPersistReadyRef.current = true;
|
||
return;
|
||
}
|
||
scheduleStrategyPersist();
|
||
}, [buyOrphans, sellOrphans, scheduleStrategyPersist]);
|
||
|
||
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
|
||
|
||
useEffect(() => {
|
||
if (!hasRegisteredUser()) return;
|
||
loadStrategies().then(async list => {
|
||
if (list?.length) {
|
||
const mapped = list.map(s => ({
|
||
id: s.id ?? Date.now(),
|
||
name: s.name,
|
||
description: s.description,
|
||
buyCondition: s.buyCondition as LogicNode | null ?? null,
|
||
sellCondition: s.sellCondition as LogicNode | null ?? null,
|
||
flowLayout: normalizeStrategyFlowLayout(s.flowLayout) ?? undefined,
|
||
enabled: s.enabled ?? true,
|
||
createdAt: s.createdAt,
|
||
updatedAt: s.updatedAt,
|
||
}));
|
||
setStrategies(mapped);
|
||
setSelectedId(prev =>
|
||
prev != null && mapped.some(x => x.id === prev) ? prev : null,
|
||
);
|
||
} else {
|
||
const local = loadStratsLocal();
|
||
if (!local.length) return;
|
||
const migrated: StrategyDto[] = [];
|
||
for (const s of local) {
|
||
try {
|
||
const saved = await saveStrategy({
|
||
name: s.name,
|
||
description: s.description,
|
||
buyCondition: s.buyCondition,
|
||
sellCondition: s.sellCondition,
|
||
enabled: s.enabled ?? true,
|
||
});
|
||
if (saved?.id) {
|
||
migrated.push({
|
||
id: saved.id,
|
||
name: saved.name,
|
||
description: saved.description,
|
||
buyCondition: saved.buyCondition as LogicNode | null ?? null,
|
||
sellCondition: saved.sellCondition as LogicNode | null ?? null,
|
||
enabled: saved.enabled ?? true,
|
||
createdAt: saved.createdAt,
|
||
updatedAt: saved.updatedAt,
|
||
});
|
||
}
|
||
} catch { /* skip */ }
|
||
}
|
||
if (migrated.length) setStrategies(migrated);
|
||
}
|
||
}).catch(() => {});
|
||
}, []);
|
||
|
||
const flushBeforeStrategySwitch = useCallback(() => {
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
if (strategyAutosaveTimerRef.current != null) {
|
||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||
strategyAutosaveTimerRef.current = null;
|
||
}
|
||
const prevId = selectedIdRef.current;
|
||
const prevName = stratNameRef.current;
|
||
const prevDesc = stratDescRef.current;
|
||
if (prevId != null && prevName.trim()) {
|
||
void persistStrategySnapshot(prevId, prevName, prevDesc).catch(() => { /* switch flush */ });
|
||
}
|
||
}, [editorMode, persistStrategySnapshot]);
|
||
|
||
const handleSelectStrategy = (s: StrategyDto) => {
|
||
if (selectedId !== s.id) {
|
||
flushBeforeStrategySwitch();
|
||
}
|
||
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
|
||
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
|
||
let stored = normalizeStrategyFlowLayout(s.flowLayout);
|
||
if (!stored) {
|
||
stored = readLegacyFlowLayoutFromLocalStorage(String(s.id));
|
||
}
|
||
|
||
const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy');
|
||
const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell');
|
||
|
||
const buySynced = syncLogicRootWithGlobalDef(
|
||
buyMigrated.root,
|
||
buyMigrated.orphans,
|
||
DEF,
|
||
'buy',
|
||
);
|
||
const sellSynced = syncLogicRootWithGlobalDef(
|
||
sellMigrated.root,
|
||
sellMigrated.orphans,
|
||
DEF,
|
||
'sell',
|
||
);
|
||
|
||
setSelectedId(s.id);
|
||
setStratName(s.name);
|
||
setStratDesc(s.description ?? '');
|
||
setBuyCondition(buySynced.root);
|
||
setSellCondition(sellSynced.root);
|
||
setBuyOrphans(buySynced.orphans);
|
||
setSellOrphans(sellSynced.orphans);
|
||
setBuyLayout({
|
||
positions: stored?.buy.positions ?? {},
|
||
edgeHandles: stored?.buy.edgeHandles ?? {},
|
||
startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored?.buy.startMeta),
|
||
extraStartIds: stored?.buy.extraStartIds?.length ? stored.buy.extraStartIds : buyDecoded.extraStartIds,
|
||
extraRoots: stored?.buy.extraRoots && Object.keys(stored.buy.extraRoots).length
|
||
? stored.buy.extraRoots
|
||
: buyDecoded.extraRoots,
|
||
startCombineOp: normalizeStartCombineOp(stored?.buy.startCombineOp ?? buyDecoded.startCombineOp),
|
||
});
|
||
setSellLayout({
|
||
positions: stored?.sell.positions ?? {},
|
||
edgeHandles: stored?.sell.edgeHandles ?? {},
|
||
startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored?.sell.startMeta),
|
||
extraStartIds: stored?.sell.extraStartIds?.length ? stored.sell.extraStartIds : sellDecoded.extraStartIds,
|
||
extraRoots: stored?.sell.extraRoots && Object.keys(stored.sell.extraRoots).length
|
||
? stored.sell.extraRoots
|
||
: sellDecoded.extraRoots,
|
||
startCombineOp: normalizeStartCombineOp(stored?.sell.startCombineOp ?? sellDecoded.startCombineOp),
|
||
});
|
||
setSelectedNodeId(null);
|
||
bumpLayoutSeed(String(s.id), buySellTab(signalTab));
|
||
|
||
if (!s.flowLayout && stored) {
|
||
void (async () => {
|
||
try {
|
||
const aligned = alignBuySellStartCandleTypesForSave(
|
||
toEditorState(buySynced.root, {
|
||
startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored.buy.startMeta),
|
||
extraStartIds: stored.buy.extraStartIds ?? buyDecoded.extraStartIds,
|
||
extraRoots: stored.buy.extraRoots ?? buyDecoded.extraRoots,
|
||
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp ?? buyDecoded.startCombineOp),
|
||
}),
|
||
toEditorState(sellSynced.root, {
|
||
startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored.sell.startMeta),
|
||
extraStartIds: stored.sell.extraStartIds ?? sellDecoded.extraStartIds,
|
||
extraRoots: stored.sell.extraRoots ?? sellDecoded.extraRoots,
|
||
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp ?? sellDecoded.startCombineOp),
|
||
}),
|
||
);
|
||
await saveStrategy({
|
||
id: s.id,
|
||
name: s.name,
|
||
description: s.description ?? '',
|
||
buyCondition: encodeConditionForSave(aligned.buy),
|
||
sellCondition: encodeConditionForSave(aligned.sell),
|
||
flowLayout: stored,
|
||
enabled: s.enabled ?? true,
|
||
});
|
||
clearLegacyFlowLayoutFromLocalStorage(String(s.id));
|
||
setStrategies(prev => prev.map(x => x.id === s.id ? { ...x, flowLayout: stored! } : x));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
})();
|
||
}
|
||
|
||
// flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준)
|
||
void (async () => {
|
||
if (!hasRegisteredUser() || s.id == null) return;
|
||
const exists = await loadStrategy(s.id);
|
||
if (!exists) return;
|
||
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout });
|
||
if (!synced) return;
|
||
try {
|
||
const fresh = await loadStrategy(s.id);
|
||
if (!fresh) return;
|
||
const buyD = decodeConditionForEditor((fresh.buyCondition ?? null) as LogicNode | null);
|
||
const sellD = decodeConditionForEditor((fresh.sellCondition ?? null) as LogicNode | null);
|
||
const buyM = migrateLogicRootForEditor(buyD.root, buySynced.orphans, DEF, 'buy');
|
||
const sellM = migrateLogicRootForEditor(sellD.root, sellSynced.orphans, DEF, 'sell');
|
||
setBuyCondition(syncLogicRootWithGlobalDef(buyM.root, buyM.orphans, DEF, 'buy').root);
|
||
setSellCondition(syncLogicRootWithGlobalDef(sellM.root, sellM.orphans, DEF, 'sell').root);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
})();
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (variant !== 'modal' || initialStrategyId == null || initialStrategyAppliedRef.current) return;
|
||
if (strategies.length === 0) return;
|
||
const found = strategies.find(s => s.id === initialStrategyId);
|
||
if (!found) return;
|
||
initialStrategyAppliedRef.current = true;
|
||
handleSelectStrategy(found);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [variant, initialStrategyId, strategies]);
|
||
|
||
const handleNew = () => {
|
||
flushBeforeStrategySwitch();
|
||
setSelectedId(null);
|
||
setStratName('');
|
||
setStratDesc('');
|
||
setBuyCondition(null);
|
||
setSellCondition(null);
|
||
setBuyOrphans([]);
|
||
setSellOrphans([]);
|
||
setSelectedNodeId(null);
|
||
resetFlowLayout('draft', 'buy');
|
||
setSignalTab('buy');
|
||
};
|
||
|
||
const openSaveDialog = useCallback((mode: SaveDialogMode) => {
|
||
setSaveMode(mode);
|
||
setSaveDraftName(mode === 'saveAs' ? suggestSaveAsName(stratName, strategies) : stratName);
|
||
setSaveDraftDesc(stratDesc);
|
||
setSaveNameError(false);
|
||
setSaveDuplicateError(false);
|
||
setSaveOpen(true);
|
||
}, [stratName, stratDesc, strategies]);
|
||
|
||
const handleSave = async () => {
|
||
const name = saveDraftName.trim();
|
||
if (!name) {
|
||
setSaveNameError(true);
|
||
showSnack('전략 이름을 입력하세요', false);
|
||
return;
|
||
}
|
||
const asNew = saveMode === 'saveAs' || saveMode === 'create';
|
||
const excludeId = asNew ? null : selectedId;
|
||
if (isDuplicateStrategyName(name, strategies, excludeId)) {
|
||
setSaveDuplicateError(true);
|
||
showSnack('같은 이름의 전략이 이미 있습니다', false);
|
||
return;
|
||
}
|
||
setSaveNameError(false);
|
||
setSaveDuplicateError(false);
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
if (strategyAutosaveTimerRef.current != null) {
|
||
window.clearTimeout(strategyAutosaveTimerRef.current);
|
||
strategyAutosaveTimerRef.current = null;
|
||
}
|
||
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
|
||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||
setIsSaving(true);
|
||
try {
|
||
const flowLayout = buildCurrentFlowLayout();
|
||
const payload: ApiStrategyDto = {
|
||
id: asNew ? undefined : (selectedId ?? undefined),
|
||
name,
|
||
description: saveDraftDesc,
|
||
buyCondition: encodedBuy,
|
||
sellCondition: encodedSell,
|
||
flowLayout,
|
||
enabled: true,
|
||
};
|
||
const saved = await saveStrategy(payload);
|
||
const now = new Date().toISOString();
|
||
const dbId = saved?.id ?? (asNew ? Date.now() : selectedId ?? Date.now());
|
||
const entry: StrategyDto = {
|
||
id: dbId,
|
||
name,
|
||
description: saveDraftDesc,
|
||
buyCondition: encodedBuy,
|
||
sellCondition: encodedSell,
|
||
flowLayout,
|
||
enabled: true,
|
||
createdAt: saved?.createdAt ?? now,
|
||
updatedAt: saved?.updatedAt ?? now,
|
||
};
|
||
if (asNew) {
|
||
setStrategies(prev => [...prev, entry]);
|
||
setSelectedId(dbId);
|
||
} else if (selectedId) {
|
||
setStrategies(prev => prev.map(s => (s.id === selectedId ? { ...s, ...entry, id: dbId } : s)));
|
||
} else {
|
||
setStrategies(prev => [...prev, entry]);
|
||
setSelectedId(dbId);
|
||
}
|
||
setStratName(name);
|
||
setStratDesc(saveDraftDesc);
|
||
bumpLayoutSeed(String(dbId), buySellTab(signalTab));
|
||
setSaveOpen(false);
|
||
setSaveToast(true);
|
||
setTimeout(() => setSaveToast(false), 2500);
|
||
if (saveMode === 'saveAs') {
|
||
showSnack('새 전략으로 저장되었습니다');
|
||
} else if (saveMode === 'update') {
|
||
showSnack('전략이 수정되었습니다');
|
||
} else {
|
||
showSnack('전략이 저장되었습니다');
|
||
}
|
||
notifyStrategiesChanged();
|
||
} catch (e) {
|
||
showSnack(e instanceof Error ? e.message : '저장 실패', false);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteConfirm = async () => {
|
||
if (!deleteId) return;
|
||
try { await deleteStrategy(deleteId); } catch { /* local */ }
|
||
clearLegacyFlowLayoutFromLocalStorage(String(deleteId));
|
||
setStrategies(prev => prev.filter(s => s.id !== deleteId));
|
||
if (selectedId === deleteId) handleNew();
|
||
setDeleteOpen(false);
|
||
setDeleteId(null);
|
||
showSnack('전략이 삭제되었습니다');
|
||
notifyStrategiesChanged();
|
||
};
|
||
|
||
const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: BuySellTab = buySellTab(signalTab)) => {
|
||
if (layout) {
|
||
setBuyLayout({
|
||
positions: layout.buy.positions ?? {},
|
||
edgeHandles: layout.buy.edgeHandles ?? {},
|
||
startMeta: layout.buy.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: layout.buy.extraStartIds ?? [],
|
||
extraRoots: layout.buy.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(layout.buy.startCombineOp),
|
||
});
|
||
setSellLayout({
|
||
positions: layout.sell.positions ?? {},
|
||
edgeHandles: layout.sell.edgeHandles ?? {},
|
||
startMeta: layout.sell.startMeta ?? defaultStartMeta(),
|
||
extraStartIds: layout.sell.extraStartIds ?? [],
|
||
extraRoots: layout.sell.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(layout.sell.startCombineOp),
|
||
});
|
||
setBuyOrphans(layout.buy.orphans ?? []);
|
||
setSellOrphans(layout.sell.orphans ?? []);
|
||
} else {
|
||
resetFlowLayout('draft', tab);
|
||
}
|
||
bumpLayoutSeed('draft', tab);
|
||
}, [resetFlowLayout, bumpLayoutSeed, signalTab]);
|
||
|
||
const handleExport = useCallback(() => {
|
||
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
|
||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||
if (!encodedBuy && !encodedSell) {
|
||
showSnack('내보낼 조건이 없습니다', false);
|
||
return;
|
||
}
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
const payload = buildStrategyExportPayload({
|
||
name: stratName,
|
||
description: stratDesc,
|
||
buyCondition: encodedBuy,
|
||
sellCondition: encodedSell,
|
||
flowLayout: {
|
||
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
|
||
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
|
||
},
|
||
editorMode,
|
||
});
|
||
const safeName = (stratName || '전략').replace(/[^\w\uAC00-\uD7A3-]+/g, '_');
|
||
downloadStrategyJson(`${safeName}_${new Date().toISOString().slice(0, 10)}`, payload);
|
||
showSnack('JSON으로 내보냈습니다');
|
||
}, [buyEditorState, sellEditorState, stratName, stratDesc, buyOrphans, sellOrphans, editorMode]);
|
||
|
||
const handleImport = useCallback(async () => {
|
||
const text = await pickJsonFile();
|
||
if (!text) return;
|
||
try {
|
||
const result = parseStrategyImportFile(text);
|
||
if (result.kind === 'list') {
|
||
showSnack('단일 전략 파일이 아닙니다. 전체 가져오기(⬆)를 사용하세요', false);
|
||
return;
|
||
}
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
const { data } = result;
|
||
const buyDecoded = decodeConditionForEditor(data.buyCondition ?? null);
|
||
const sellDecoded = decodeConditionForEditor(data.sellCondition ?? null);
|
||
setBuyCondition(buyDecoded.root);
|
||
setSellCondition(sellDecoded.root);
|
||
setStratName(data.name ?? '가져온 전략');
|
||
setStratDesc(data.description ?? '');
|
||
setSelectedId(null);
|
||
setSelectedNodeId(null);
|
||
applyImportedFlowLayout(data.flowLayout);
|
||
setBuyLayout(prev => ({
|
||
...prev,
|
||
startCombineOp: normalizeStartCombineOp(data.flowLayout?.buy?.startCombineOp ?? buyDecoded.startCombineOp),
|
||
}));
|
||
setSellLayout(prev => ({
|
||
...prev,
|
||
startCombineOp: normalizeStartCombineOp(data.flowLayout?.sell?.startCombineOp ?? sellDecoded.startCombineOp),
|
||
}));
|
||
if (data.editorMode === 'list' || data.editorMode === 'graph') {
|
||
setEditorMode(data.editorMode);
|
||
saveEditorMode(data.editorMode);
|
||
}
|
||
showSnack('전략을 가져왔습니다');
|
||
} catch (e) {
|
||
showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false);
|
||
}
|
||
}, [editorMode, applyImportedFlowLayout]);
|
||
|
||
const handleExportAll = useCallback(() => {
|
||
if (strategies.length === 0) {
|
||
showSnack('내보낼 전략이 없습니다', false);
|
||
return;
|
||
}
|
||
const items = strategies.map(s => strategyDtoToListExportItem(
|
||
s,
|
||
s.flowLayout ?? null,
|
||
));
|
||
downloadStrategyJson(
|
||
`전략목록_${new Date().toISOString().slice(0, 10)}`,
|
||
buildStrategyListExportPayload(items),
|
||
);
|
||
showSnack(`${strategies.length}개 전략을 내보냈습니다`);
|
||
}, [strategies]);
|
||
|
||
const handleImportAll = useCallback(async () => {
|
||
const text = await pickJsonFile();
|
||
if (!text) return;
|
||
try {
|
||
const result = parseStrategyImportFile(text);
|
||
if (result.kind !== 'list') {
|
||
showSnack('전략 목록 형식 파일이 필요합니다', false);
|
||
return;
|
||
}
|
||
const items = result.data.strategies;
|
||
if (hasRegisteredUser()) {
|
||
const persisted: StrategyDto[] = [];
|
||
let failed = 0;
|
||
for (const item of items) {
|
||
try {
|
||
const saved = await saveStrategy({
|
||
name: item.name,
|
||
description: item.description,
|
||
buyCondition: item.buyCondition,
|
||
sellCondition: item.sellCondition,
|
||
flowLayout: item.flowLayout,
|
||
enabled: item.enabled ?? true,
|
||
});
|
||
if (saved?.id) {
|
||
persisted.push({
|
||
id: saved.id,
|
||
name: saved.name,
|
||
description: saved.description,
|
||
buyCondition: saved.buyCondition as LogicNode | null ?? null,
|
||
sellCondition: saved.sellCondition as LogicNode | null ?? null,
|
||
flowLayout: normalizeStrategyFlowLayout(saved.flowLayout) ?? item.flowLayout,
|
||
enabled: saved.enabled ?? true,
|
||
createdAt: saved.createdAt,
|
||
updatedAt: saved.updatedAt,
|
||
});
|
||
} else {
|
||
failed += 1;
|
||
}
|
||
} catch {
|
||
failed += 1;
|
||
}
|
||
}
|
||
if (persisted.length === 0) {
|
||
showSnack('전략 저장에 실패했습니다', false);
|
||
return;
|
||
}
|
||
setStrategies(prev => [...prev, ...persisted]);
|
||
showSnack(
|
||
failed > 0
|
||
? `${persisted.length}개 저장, ${failed}개 실패`
|
||
: `${persisted.length}개 전략을 가져와 저장했습니다`,
|
||
failed === 0,
|
||
);
|
||
return;
|
||
}
|
||
const baseId = Date.now();
|
||
const imported = items.map((item, index) => listImportItemToStrategyDto(item, baseId + index));
|
||
setStrategies(prev => [...prev, ...imported]);
|
||
showSnack(`${imported.length}개 전략을 가져왔습니다 (로그인 시 DB에 저장됩니다)`, false);
|
||
} catch (e) {
|
||
showSnack(e instanceof Error ? e.message : '파일 읽기 실패', false);
|
||
}
|
||
}, []);
|
||
|
||
const handleAddStartSection = useCallback(() => {
|
||
handleEditorStateChange(prev => addExtraStartSection(prev));
|
||
if (signalTab !== 'eval') bumpLayoutSeed(layoutStrategyKey, buySellTab(signalTab));
|
||
}, [handleEditorStateChange, bumpLayoutSeed, layoutStrategyKey, signalTab]);
|
||
|
||
const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => {
|
||
if (type === 'start') {
|
||
handleAddStartSection();
|
||
return;
|
||
}
|
||
const newNode = makeNode(type, value, buySellTab(signalTab), DEF, composite ? { composite: true } : undefined);
|
||
handleEditorStateChange(prev => {
|
||
const root = prev.root;
|
||
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
|
||
if (type === 'operator') {
|
||
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, true));
|
||
}
|
||
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
|
||
});
|
||
}, [signalTab, DEF, handleEditorStateChange, handleAddStartSection]);
|
||
|
||
const applyPaletteItem = useCallback((item: PaletteItem) => {
|
||
const stochPair = isStochOverboughtPairPaletteValue(item.value);
|
||
const priceExtremeBreakout = isPriceExtremeBreakoutPairPaletteValue(item.value);
|
||
const inflection33 = isInflection33PaletteValue(item.value);
|
||
const ichimokuBb = isIchimokuBbPaletteValue(item.value);
|
||
const stableStrategy = isStableStrategyPaletteValue(item.value);
|
||
const ichimokuSituation = isIchimokuSituationPaletteValue(item.value);
|
||
const composite = item.kind === 'composite' && !stochPair && !priceExtremeBreakout && !inflection33 && !ichimokuBb && !stableStrategy && !ichimokuSituation;
|
||
const newNode = stochPair
|
||
? buildStochOverboughtPairTree(item.secondaryIndicator ?? 'CCI', DEF)
|
||
: priceExtremeBreakout
|
||
? buildPriceExtremeBreakoutTree(item.value, DEF)
|
||
: inflection33
|
||
? buildInflection33Tree(item.value, DEF)
|
||
: ichimokuBb
|
||
? buildIchimokuBbTree(item.value, DEF)
|
||
: stableStrategy
|
||
? buildStableStrategyTree(item.value, DEF)
|
||
: ichimokuSituation
|
||
? buildIchimokuSituationTree(item.value, DEF)
|
||
: makeNode('indicator', item.value, buySellTab(signalTab), DEF, composite ? { composite: true } : undefined);
|
||
if (!newNode) return;
|
||
handleEditorStateChange(prev => {
|
||
const root = prev.root;
|
||
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
|
||
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
|
||
});
|
||
}, [signalTab, DEF, handleEditorStateChange]);
|
||
|
||
const applySidewaysFilter = useCallback((filterId: string) => {
|
||
const newNode = buildSidewaysFilterNode(filterId, DEF, sidewaysPalette);
|
||
if (!newNode) return;
|
||
const root = currentRoot;
|
||
if (!root) setCurrentRoot(newNode);
|
||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||
scheduleStrategyPersist();
|
||
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]);
|
||
|
||
const applyTrendLine = useCallback((item: { indicatorType: string; leftField?: string }, lookback: number) => {
|
||
const newNode = buildTrendLineConditionNode(item, buySellTab(signalTab), DEF, lookback);
|
||
handleEditorStateChange(prev => {
|
||
const root = prev.root;
|
||
if (!root) return updateBranchRoot(prev, START_NODE_ID, newNode);
|
||
return updateBranchRoot(prev, START_NODE_ID, mergeAtRoot(root, newNode, false));
|
||
});
|
||
}, [signalTab, DEF, handleEditorStateChange]);
|
||
|
||
const templateRows = useMemo(
|
||
() => buildStrategyTemplatePaletteRows({
|
||
def: DEF,
|
||
userTemplates,
|
||
hiddenBuiltinTemplateIds,
|
||
compositePalette,
|
||
sidewaysPalette,
|
||
}),
|
||
[DEF, userTemplates, hiddenBuiltinTemplateIds, compositePalette, sidewaysPalette],
|
||
);
|
||
|
||
const filteredTemplateRows = useMemo(
|
||
() => filterStrategyTemplatePaletteRows(templateRows, templateSearch),
|
||
[templateRows, templateSearch],
|
||
);
|
||
|
||
const applyAiStrategy = useCallback((buyDsl: LogicNode | null, sellDsl: LogicNode | null) => {
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
|
||
const buyDecoded = decodeConditionForEditor(buyDsl);
|
||
const sellDecoded = decodeConditionForEditor(sellDsl);
|
||
|
||
setBuyCondition(buyDecoded.root);
|
||
setSellCondition(sellDecoded.root);
|
||
setBuyOrphans([]);
|
||
setSellOrphans([]);
|
||
setSelectedNodeId(null);
|
||
setBuyLayout(prev => ({
|
||
...prev,
|
||
startCombineOp: normalizeStartCombineOp(buyDecoded.startCombineOp),
|
||
}));
|
||
setSellLayout(prev => ({
|
||
...prev,
|
||
startCombineOp: normalizeStartCombineOp(sellDecoded.startCombineOp),
|
||
}));
|
||
|
||
resetFlowLayout(layoutStrategyKey, 'buy');
|
||
resetFlowLayout(layoutStrategyKey, 'sell');
|
||
|
||
const targetTab: BuySellTab = buyDsl ? 'buy' : (sellDsl ? 'sell' : buySellTab(signalTab));
|
||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||
scheduleStrategyPersist();
|
||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
|
||
|
||
const handleTemplate = useCallback((tmpl: StrategyTemplateDef) => {
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
|
||
const targetTab = tmpl.signal;
|
||
const nextRoot = tmpl.kind === 'composite' ? tmpl.build() : simpleTemplateToNode(tmpl);
|
||
|
||
setBuyCondition(null);
|
||
setSellCondition(null);
|
||
setSelectedNodeId(null);
|
||
|
||
if (targetTab === 'buy') setBuyCondition(nextRoot);
|
||
else setSellCondition(nextRoot);
|
||
|
||
resetFlowLayout(layoutStrategyKey, targetTab);
|
||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||
scheduleStrategyPersist();
|
||
|
||
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
|
||
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
|
||
|
||
const applyUserTemplate = useCallback((tmpl: UserStrategyTemplate) => {
|
||
if (editorMode === 'graph') layoutFlushRef.current?.();
|
||
|
||
const targetTab = tmpl.signal;
|
||
const layoutFromState = {
|
||
startMeta: tmpl.editorState.startMeta,
|
||
extraStartIds: tmpl.editorState.extraStartIds,
|
||
extraRoots: tmpl.editorState.extraRoots ?? {},
|
||
startCombineOp: normalizeStartCombineOp(tmpl.editorState.startCombineOp),
|
||
positions: {},
|
||
edgeHandles: {},
|
||
};
|
||
|
||
setBuyCondition(null);
|
||
setSellCondition(null);
|
||
setBuyOrphans([]);
|
||
setSellOrphans([]);
|
||
setSelectedNodeId(null);
|
||
|
||
if (targetTab === 'buy') {
|
||
setBuyCondition(tmpl.editorState.root);
|
||
setBuyLayout(prev => ({ ...prev, ...layoutFromState }));
|
||
setBuyOrphans(tmpl.orphans);
|
||
setSellLayout(normalizeTabLayoutState(emptySignalFlowLayout()));
|
||
} else {
|
||
setSellCondition(tmpl.editorState.root);
|
||
setSellLayout(prev => ({ ...prev, ...layoutFromState }));
|
||
setSellOrphans(tmpl.orphans);
|
||
setBuyLayout(normalizeTabLayoutState(emptySignalFlowLayout()));
|
||
}
|
||
|
||
bumpLayoutSeed(layoutStrategyKey, targetTab);
|
||
if (targetTab !== signalTab) setSignalTab(targetTab);
|
||
scheduleStrategyPersist();
|
||
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
|
||
}, [
|
||
editorMode,
|
||
layoutStrategyKey,
|
||
bumpLayoutSeed,
|
||
signalTab,
|
||
scheduleStrategyPersist,
|
||
]);
|
||
|
||
const handleDeleteUserTemplate = useCallback((id: string, label: string) => {
|
||
if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return;
|
||
setUserTemplates(deleteUserStrategyTemplate(id));
|
||
showSnack('템플릿을 삭제했습니다');
|
||
}, []);
|
||
|
||
const handleDeleteBuiltinTemplate = useCallback((id: string, label: string) => {
|
||
if (!window.confirm(`"${label}" 템플릿을 목록에서 삭제할까요?`)) return;
|
||
setHiddenBuiltinTemplateIds(hideBuiltinTemplate(id));
|
||
showSnack('템플릿을 목록에서 제거했습니다');
|
||
}, []);
|
||
|
||
const openTemplateAddModal = useCallback(() => {
|
||
const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0;
|
||
if (!hasContent) {
|
||
showSnack('저장할 조건이 없습니다', false);
|
||
return;
|
||
}
|
||
setTemplateModalMode('add');
|
||
setTemplateEditId(null);
|
||
setTemplateSaveUpdateContent(false);
|
||
setTemplateSaveLabel(stratName.trim() || `${signalTab === 'buy' ? '매수' : '매도'} 템플릿`);
|
||
setTemplateSaveDesc('');
|
||
setTemplateSaveLabelError(false);
|
||
setTemplateSaveOpen(true);
|
||
}, [currentEditorState, currentOrphans.length, stratName, signalTab]);
|
||
|
||
const openTemplateEditModal = useCallback(() => {
|
||
if (!selectedTemplateKey) {
|
||
window.alert('수정할 템플릿을 먼저 선택해 주세요.');
|
||
return;
|
||
}
|
||
const row = templateRows.find(r => r.key === selectedTemplateKey);
|
||
if (!row) return;
|
||
if (row.source !== 'user' || !row.user) {
|
||
window.alert('내 템플릿만 수정할 수 있습니다.');
|
||
return;
|
||
}
|
||
setTemplateModalMode('edit');
|
||
setTemplateEditId(row.user.id);
|
||
setTemplateSaveUpdateContent(false);
|
||
setTemplateSaveLabel(row.user.label);
|
||
setTemplateSaveDesc(row.user.description ?? '');
|
||
setTemplateSaveLabelError(false);
|
||
setTemplateSaveOpen(true);
|
||
}, [selectedTemplateKey, templateRows]);
|
||
|
||
const handleTemplateToolbarDelete = useCallback(() => {
|
||
if (!selectedTemplateKey) {
|
||
window.alert('삭제할 템플릿을 먼저 선택해 주세요.');
|
||
return;
|
||
}
|
||
const row = templateRows.find(r => r.key === selectedTemplateKey);
|
||
if (!row) return;
|
||
if (row.source === 'user' && row.user) {
|
||
handleDeleteUserTemplate(row.user.id, row.label);
|
||
} else if (row.source === 'builtin' && row.builtinId) {
|
||
handleDeleteBuiltinTemplate(row.builtinId, row.label);
|
||
} else if (row.source === 'composite-palette' || row.source === 'sideways-palette') {
|
||
handleDeleteBuiltinTemplate(row.key, row.label);
|
||
}
|
||
setSelectedTemplateKey(null);
|
||
}, [selectedTemplateKey, templateRows, handleDeleteUserTemplate, handleDeleteBuiltinTemplate]);
|
||
|
||
const handleTemplateApply = useCallback((row: TemplatePaletteRow) => {
|
||
if (row.source === 'user' && row.user) {
|
||
applyUserTemplate(row.user);
|
||
return;
|
||
}
|
||
if (row.source === 'builtin' && row.builtin) {
|
||
handleTemplate(row.builtin);
|
||
return;
|
||
}
|
||
if (row.source === 'composite-palette' && row.paletteItem) {
|
||
applyPaletteItem(row.paletteItem);
|
||
showSnack(`"${row.label}" 조건이 추가됐습니다`);
|
||
return;
|
||
}
|
||
if (row.source === 'sideways-palette' && row.sidewaysFilterId) {
|
||
applySidewaysFilter(row.sidewaysFilterId);
|
||
showSnack(`"${row.label}" 필터가 추가됐습니다`);
|
||
}
|
||
}, [applyUserTemplate, handleTemplate, applyPaletteItem, applySidewaysFilter]);
|
||
|
||
const handleTemplateSaveConfirm = useCallback(() => {
|
||
const label = templateSaveLabel.trim();
|
||
if (!label) {
|
||
setTemplateSaveLabelError(true);
|
||
return;
|
||
}
|
||
if (templateModalMode === 'edit' && templateEditId) {
|
||
const patch: Parameters<typeof updateUserStrategyTemplate>[1] = {
|
||
label,
|
||
description: templateSaveDesc.trim() || undefined,
|
||
};
|
||
if (templateSaveUpdateContent) {
|
||
const hasContent = encodeConditionForSave(currentEditorState) != null || currentOrphans.length > 0;
|
||
if (!hasContent) {
|
||
showSnack('갱신할 조건이 없습니다', false);
|
||
return;
|
||
}
|
||
patch.signal = buySellTab(signalTab);
|
||
patch.editorState = toEditorState(currentRoot, currentLayout);
|
||
patch.orphans = currentOrphans;
|
||
}
|
||
setUserTemplates(updateUserStrategyTemplate(templateEditId, patch));
|
||
setTemplateSaveOpen(false);
|
||
showSnack(`"${label}" 템플릿을 수정했습니다`);
|
||
return;
|
||
}
|
||
const saved = addUserStrategyTemplate({
|
||
label,
|
||
description: templateSaveDesc.trim() || undefined,
|
||
signal: buySellTab(signalTab),
|
||
editorState: toEditorState(currentRoot, currentLayout),
|
||
orphans: currentOrphans,
|
||
});
|
||
setUserTemplates(prev => [...prev, saved]);
|
||
setSelectedTemplateKey(saved.id);
|
||
setTemplateSaveOpen(false);
|
||
setRightTab('templates');
|
||
showSnack(`"${saved.label}" 템플릿을 저장했습니다`);
|
||
}, [
|
||
templateSaveLabel,
|
||
templateSaveDesc,
|
||
templateModalMode,
|
||
templateEditId,
|
||
templateSaveUpdateContent,
|
||
signalTab,
|
||
currentRoot,
|
||
currentLayout,
|
||
currentOrphans,
|
||
currentEditorState,
|
||
]);
|
||
|
||
const handleQuickRun = useCallback(async () => {
|
||
if (!selectedId) {
|
||
window.alert('백테스팅할 전략을 먼저 선택해주세요.');
|
||
return;
|
||
}
|
||
setQbLoading(true);
|
||
setQbError(null);
|
||
try {
|
||
const selectedStrategy = strategies.find(s => s.id === selectedId);
|
||
const strategyTimeframe = resolveStrategyPrimaryTimeframe(
|
||
selectedStrategy,
|
||
{ buy: buyEditorState, sell: sellEditorState },
|
||
);
|
||
const execTimeframe = resolveBacktestExecTimeframe(qbTimeframe, strategyTimeframe);
|
||
const [settings, indicatorParams] = await Promise.all([
|
||
loadBacktestSettings(),
|
||
loadIndicatorSettings(),
|
||
]);
|
||
const { bars, evaluationBarCount } = await fetchBacktestCandleBundle({
|
||
market: qbMarket,
|
||
timeframe: execTimeframe as import('../types').Timeframe,
|
||
buyDsl: selectedStrategy?.buyCondition,
|
||
sellDsl: selectedStrategy?.sellCondition,
|
||
indicatorParams,
|
||
});
|
||
if (bars.length < 5) {
|
||
throw new Error('캔들 데이터가 부족합니다. 타임프레임을 변경하거나 잠시 후 다시 시도해주세요.');
|
||
}
|
||
const result = await runBacktest({
|
||
strategyId: selectedId,
|
||
bars,
|
||
timeframe: execTimeframe,
|
||
settings,
|
||
indicatorParams,
|
||
symbol: qbMarket,
|
||
strategyName: selectedStrategy?.name,
|
||
evaluationBarCount,
|
||
});
|
||
if (!result) throw new Error('백테스팅 실행에 실패했습니다.');
|
||
if (result.resultId) {
|
||
try { sessionStorage.setItem(BACKTEST_FOCUS_KEY, String(result.resultId)); } catch { /* ignore */ }
|
||
}
|
||
onNavigateToBacktest?.();
|
||
} catch (e) {
|
||
setQbError(e instanceof Error ? e.message : '실행 중 오류가 발생했습니다.');
|
||
} finally {
|
||
setQbLoading(false);
|
||
}
|
||
}, [selectedId, strategies, qbMarket, qbTimeframe, buyEditorState, sellEditorState, onNavigateToBacktest]);
|
||
|
||
const qbStrategyTimeframe = useMemo(
|
||
() => resolveStrategyPrimaryTimeframe(
|
||
selectedId ? strategies.find(s => s.id === selectedId) : undefined,
|
||
{ buy: buyEditorState, sell: sellEditorState },
|
||
),
|
||
[selectedId, strategies, buyEditorState, sellEditorState],
|
||
);
|
||
const qbTimeframeOptions = useMemo(
|
||
() => buildQuickRunTimeframeSelectOptions(qbStrategyTimeframe),
|
||
[qbStrategyTimeframe],
|
||
);
|
||
|
||
useEffect(() => {
|
||
void loadBacktestSettings().then(s => {
|
||
if (s?.initialCapital) setEvalInitialCapital(s.initialCapital);
|
||
setEvalCommissionRate(resolveEvaluationCommissionRate(s));
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (signalTab !== 'eval') {
|
||
setEvalBacktestSignals([]);
|
||
setEvalBars([]);
|
||
setEvalSignalScanRunning(false);
|
||
}
|
||
}, [signalTab]);
|
||
|
||
const evalAnalysisSummary = useMemo(() => {
|
||
if (signalTab !== 'eval' || evalBacktestSignals.length === 0) return null;
|
||
const { evalBars: slice } = resolveEvaluationBarSlice(evalBars, evalWindowMeta.evaluationBarCount);
|
||
const lastEvalBar = slice[slice.length - 1];
|
||
return buildStrategyEvaluationToolbarSummary(evalBacktestSignals, {
|
||
initialCapital: evalInitialCapital,
|
||
symbol: evalMarket.replace(/^KRW-/, ''),
|
||
commissionRate: evalCommissionRate,
|
||
lastBarClose: lastEvalBar?.close,
|
||
lastBarTimeSec: lastEvalBar?.time,
|
||
});
|
||
}, [
|
||
signalTab,
|
||
evalBacktestSignals,
|
||
evalBars,
|
||
evalWindowMeta.evaluationBarCount,
|
||
evalInitialCapital,
|
||
evalMarket,
|
||
evalCommissionRate,
|
||
]);
|
||
|
||
const buildAiContext = useCallback((): AiStrategyContext => {
|
||
const buyEncoded = encodeConditionForSave(buyEditorState);
|
||
const sellEncoded = encodeConditionForSave(sellEditorState);
|
||
|
||
const evaluation = evalBacktestSignals.length > 0 || signalTab === 'eval'
|
||
? {
|
||
market: evalMarket,
|
||
timeframe: qbStrategyTimeframe,
|
||
barCount: evalBars.length,
|
||
evaluationBarCount: evalWindowMeta.evaluationBarCount,
|
||
scanRunning: evalSignalScanRunning,
|
||
signalsTotal: evalBacktestSignals.length,
|
||
buyCount: evalBacktestSignals.filter(s => s.type === 'BUY').length,
|
||
sellCount: evalBacktestSignals.filter(s => s.type === 'SELL').length,
|
||
recentSignals: evalBacktestSignals.slice(-12).map(s => ({
|
||
type: s.type,
|
||
time: s.time,
|
||
timeLabel: new Date(s.time * 1000).toLocaleString('ko-KR'),
|
||
price: s.price,
|
||
barIndex: s.barIndex,
|
||
})),
|
||
summary: evalAnalysisSummary ?? null,
|
||
}
|
||
: null;
|
||
|
||
return {
|
||
buyCondition: buyEncoded,
|
||
sellCondition: sellEncoded,
|
||
signalTab: buySellTab(signalTab),
|
||
candleType: currentEditorState.startMeta[START_NODE_ID]?.candleType,
|
||
evaluation,
|
||
};
|
||
}, [
|
||
buyEditorState,
|
||
sellEditorState,
|
||
signalTab,
|
||
currentEditorState,
|
||
evalBacktestSignals,
|
||
evalMarket,
|
||
qbStrategyTimeframe,
|
||
evalBars,
|
||
evalWindowMeta.evaluationBarCount,
|
||
evalSignalScanRunning,
|
||
evalAnalysisSummary,
|
||
]);
|
||
|
||
const handleEvalSignalsChange = useCallback((signals: BacktestSignal[]) => {
|
||
setEvalBacktestSignals(signals);
|
||
}, []);
|
||
|
||
const handleEvalBarsLoaded = useCallback((bars: OHLCVBar[]) => {
|
||
setEvalBars(bars);
|
||
}, []);
|
||
|
||
const handleEvalWindowMetaChange = useCallback((meta: StrategyEvaluationWindowMeta) => {
|
||
setEvalWindowMeta(meta);
|
||
}, []);
|
||
|
||
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 q = paletteSearch.trim().toLowerCase();
|
||
const match = (label: string, desc?: string) =>
|
||
!q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false);
|
||
|
||
const strategySearchQuery = strategyListSearch.trim().toLowerCase();
|
||
const filteredStrategies = useMemo(() => {
|
||
if (!strategySearchQuery) return strategies;
|
||
return strategies.filter(s => {
|
||
const name = s.name?.toLowerCase() ?? '';
|
||
const desc = s.description?.toLowerCase() ?? '';
|
||
return name.includes(strategySearchQuery) || desc.includes(strategySearchQuery);
|
||
});
|
||
}, [strategies, strategySearchQuery]);
|
||
|
||
const paletteKey = (type: string, id: string) => `${type}:${id}`;
|
||
const selectPalette = (type: string, id: string) => {
|
||
setSelectedPaletteKey(paletteKey(type, id));
|
||
};
|
||
const isPaletteSelected = (type: string, id: string) =>
|
||
selectedPaletteKey === paletteKey(type, id);
|
||
|
||
return (
|
||
<div className={`se-page se-page--${theme}${variant === 'modal' ? ' se-page--modal' : ''}`}>
|
||
{needsPointerPaletteDrag() ? <PaletteDragOverlay /> : null}
|
||
{saveToast && (
|
||
<div className="se-save-toast">전략이 DB에 성공적으로 저장되었습니다.</div>
|
||
)}
|
||
|
||
<header className="se-header">
|
||
<div className="se-header-left">
|
||
<h1 className="se-title">전략 빌더</h1>
|
||
<span className="se-subtitle">
|
||
Strategy Builder
|
||
{!settingsLoaded && (
|
||
<span className="se-subtitle-hint"> · 보조지표 설정 로드 중…</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 빠른 백테스팅 실행 툴바 */}
|
||
<div className="se-quick-run">
|
||
<button
|
||
ref={qbMarketBtnRef}
|
||
type="button"
|
||
className="se-qr-market-btn"
|
||
disabled={qbLoading}
|
||
title="종목 검색"
|
||
onClick={() => { setQbMarketQ(''); setQbMarketOpen(v => !v); }}
|
||
>
|
||
<span className="se-qr-market-ko">{getKoreanName(qbMarket)}</span>
|
||
<span className="se-qr-market-sym">{qbMarket.replace('KRW-', '')}</span>
|
||
<svg className="se-qr-market-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||
<polyline points="6 9 12 15 18 9"/>
|
||
</svg>
|
||
</button>
|
||
{qbMarketOpen && ReactDOM.createPortal(
|
||
<MarketSearchPanel
|
||
currentMarket={qbMarket}
|
||
query={qbMarketQ}
|
||
onQueryChange={setQbMarketQ}
|
||
onSelect={market => { setQbMarket(market); setQbMarketOpen(false); setQbMarketQ(''); }}
|
||
onClose={() => { setQbMarketOpen(false); setQbMarketQ(''); }}
|
||
anchorRect={qbMarketBtnRef.current?.getBoundingClientRect() ?? null}
|
||
anchorPlacement="dropdown"
|
||
/>,
|
||
document.body,
|
||
)}
|
||
<select
|
||
className="se-qr-select"
|
||
value={qbTimeframe}
|
||
onChange={e => setQbTimeframe(parseBacktestRunTimeframeChoice(e.target.value))}
|
||
disabled={qbLoading}
|
||
title="백테스팅 실행 시간봉"
|
||
>
|
||
{qbTimeframeOptions.map(tf => (
|
||
<option key={tf.value} value={tf.value}>{tf.label}</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
className="se-qr-select se-qr-select--strategy"
|
||
value={selectedId ?? ''}
|
||
onChange={e => {
|
||
const id = e.target.value ? Number(e.target.value) : null;
|
||
if (!id) return;
|
||
const s = strategies.find(st => st.id === id);
|
||
if (s) handleSelectStrategy(s);
|
||
}}
|
||
disabled={qbLoading}
|
||
title="전략 선택"
|
||
>
|
||
<option value="">전략 선택</option>
|
||
{strategies.map(s => (
|
||
<option key={s.id} value={s.id}>{s.name}</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
className={`se-qr-run-btn${qbLoading ? ' se-qr-run-btn--loading' : ''}`}
|
||
disabled={!selectedId || qbLoading}
|
||
onClick={() => { void handleQuickRun(); }}
|
||
title={selectedId ? '백테스팅 실행' : '전략을 먼저 선택하세요'}
|
||
>
|
||
{qbLoading ? (
|
||
<span className="se-qr-spinner" />
|
||
) : (
|
||
<svg viewBox="0 0 16 16" width="13" height="13" fill="currentColor" aria-hidden="true">
|
||
<path d="M3 2.5l10 5.5-10 5.5V2.5z"/>
|
||
</svg>
|
||
)}
|
||
</button>
|
||
{qbError && (
|
||
<span className="se-qr-error" title={qbError}>⚠ {qbError}</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="se-header-actions">
|
||
{variant === 'modal' && onClose && (
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--ghost se-btn--icon se-btn--modal-close"
|
||
title="닫기"
|
||
aria-label="전략 편집 닫기"
|
||
onClick={onClose}
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--ghost se-btn--icon se-btn--desc"
|
||
title="전략 설명 — 현재 조건을 서술형으로 보기"
|
||
aria-label="전략 설명"
|
||
onClick={() => setDescOpen(true)}
|
||
>
|
||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden className="se-desc-icon">
|
||
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" strokeWidth="1.75" />
|
||
<path
|
||
fill="currentColor"
|
||
d="M11.25 10.5h1.5V17h-1.5V10.5zm0-3.25h1.5V9h-1.5V6.25z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--ghost se-btn--icon"
|
||
title="현재 전략 JSON 내보내기"
|
||
onClick={handleExport}
|
||
disabled={!buyCondition && !sellCondition}
|
||
>
|
||
↓
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--ghost se-btn--icon"
|
||
title="JSON 파일에서 전략 가져오기"
|
||
onClick={() => { void handleImport(); }}
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--ghost se-btn--icon"
|
||
title="전체 전략 목록 JSON 내보내기"
|
||
onClick={handleExportAll}
|
||
disabled={strategies.length === 0}
|
||
>
|
||
⬇
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--ghost se-btn--icon"
|
||
title="전체 전략 목록 JSON 가져오기"
|
||
onClick={() => { void handleImportAll(); }}
|
||
>
|
||
⬆
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div className="se-body" style={bodyStyle}>
|
||
<aside className="se-left" style={{ width: leftWidth }}>
|
||
<div className="se-strat-panel">
|
||
<div className="se-strat-panel-head">
|
||
<h2 className="se-panel-title">전략 목록</h2>
|
||
<input
|
||
type="search"
|
||
className="se-strat-search"
|
||
placeholder="전략 검색…"
|
||
value={strategyListSearch}
|
||
onChange={e => setStrategyListSearch(e.target.value)}
|
||
aria-label="전략 목록 검색"
|
||
/>
|
||
</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>
|
||
) : filteredStrategies.length === 0 ? (
|
||
<p className="se-empty">검색 결과가 없습니다</p>
|
||
) : (
|
||
filteredStrategies.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' && signalTab !== 'eval' ? ' se-center-work--list' : ''}${signalTab === 'eval' ? ' se-center-work--eval' : ''}`}>
|
||
<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>
|
||
<button
|
||
type="button"
|
||
className={`se-signal-tab${signalTab === 'eval' ? ' se-signal-tab--eval-on' : ''}`}
|
||
onClick={() => switchSignalTab('eval')}
|
||
>
|
||
평가
|
||
</button>
|
||
</div>
|
||
{stratName && <span className="se-editing-name">{stratName}</span>}
|
||
{signalTab === 'eval' && (
|
||
evalSignalScanRunning ? (
|
||
<span className="se-eval-return-summary seval-header-analysis-busy">시그널 계산 중…</span>
|
||
) : evalAnalysisSummary ? (
|
||
<span
|
||
className="se-eval-return-summary seval-analysis-summary"
|
||
title="일괄매수(최초 전액) · 분할매수(40/30/잔여) · 전략평가와 동일 기준"
|
||
>
|
||
<span className={`seval-analysis-item seval-analysis-return${
|
||
evalAnalysisSummary.fullBuyReturnPct > 0
|
||
? ' seval-analysis-return--pos'
|
||
: evalAnalysisSummary.fullBuyReturnPct < 0
|
||
? ' seval-analysis-return--neg'
|
||
: ''
|
||
}`}>
|
||
일괄:<strong>{formatEvaluationReturnPct(evalAnalysisSummary.fullBuyReturnPct)}</strong>
|
||
</span>
|
||
<span className="seval-analysis-sep" aria-hidden>·</span>
|
||
<span className={`seval-analysis-item seval-analysis-return${
|
||
evalAnalysisSummary.splitBuyReturnPct > 0
|
||
? ' seval-analysis-return--pos'
|
||
: evalAnalysisSummary.splitBuyReturnPct < 0
|
||
? ' seval-analysis-return--neg'
|
||
: ''
|
||
}`}>
|
||
분할:<strong>{formatEvaluationReturnPct(evalAnalysisSummary.splitBuyReturnPct)}</strong>
|
||
</span>
|
||
</span>
|
||
) : null
|
||
)}
|
||
{signalTab !== 'eval' && hasMultipleStartSections(currentEditorState) && (
|
||
<StartCombineOpControl
|
||
value={normalizeStartCombineOp(currentEditorState.startCombineOp)}
|
||
onChange={handleStartCombineOpChange}
|
||
/>
|
||
)}
|
||
<div className="se-center-head-actions">
|
||
<button type="button" className="se-btn se-btn--ghost se-btn--sm" onClick={handleNew}>+ 새 전략</button>
|
||
{signalTab !== 'eval' && (
|
||
<div className="se-editor-mode se-editor-mode--compact" role="group" aria-label="편집 방식">
|
||
<button
|
||
type="button"
|
||
className={`se-editor-mode-btn${editorMode === 'graph' ? ' se-editor-mode-btn--on' : ''}`}
|
||
onClick={() => handleEditorModeChange('graph')}
|
||
>
|
||
그래프 방식
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`se-editor-mode-btn${editorMode === 'list' ? ' se-editor-mode-btn--on' : ''}`}
|
||
onClick={() => handleEditorModeChange('list')}
|
||
>
|
||
목록 방식
|
||
</button>
|
||
</div>
|
||
)}
|
||
{signalTab !== 'eval' && (
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--ghost se-btn--sm"
|
||
onClick={openTemplateAddModal}
|
||
title="현재 탭(매수/매도) 조건을 템플릿 목록에 저장"
|
||
>
|
||
템플릿 저장
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--gold se-btn--sm"
|
||
onClick={() => openSaveDialog(selectedId ? 'update' : 'create')}
|
||
>
|
||
저장하기
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="se-btn se-btn--ghost se-btn--sm"
|
||
onClick={() => openSaveDialog('saveAs')}
|
||
title="현재 조건을 다른 이름으로 새 전략으로 저장 (기존 전략은 유지)"
|
||
>
|
||
새로저장
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{signalTab === 'eval' ? (
|
||
<StrategyEditorEvaluationPanel
|
||
theme={theme}
|
||
stratName={stratName}
|
||
stratDesc={stratDesc}
|
||
buyEditorState={buyEditorState}
|
||
sellEditorState={sellEditorState}
|
||
onSignalsChange={handleEvalSignalsChange}
|
||
onBarsLoaded={handleEvalBarsLoaded}
|
||
onEvalWindowMetaChange={handleEvalWindowMetaChange}
|
||
onBacktestRunningChange={setEvalSignalScanRunning}
|
||
onMarketChange={setEvalMarket}
|
||
/>
|
||
) : editorMode === 'graph' ? (
|
||
<>
|
||
<ReactFlowProvider>
|
||
<StrategyEditorCanvas
|
||
ref={canvasRef}
|
||
theme={theme}
|
||
root={currentRoot}
|
||
orphans={currentOrphans}
|
||
onOrphansChange={handleOrphansChange}
|
||
def={DEF}
|
||
signalTab={buySellTab(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">
|
||
<div className="se-node-config-head">
|
||
<span className="se-node-config-label">
|
||
{stochPairEditForSelected && !stochPairEditForSelected.stochLocked
|
||
? 'Stoch 과열×보조'
|
||
: selectedLogicNode.condition.indicatorType}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="se-node-config-del"
|
||
title="삭제"
|
||
aria-label="지표 삭제"
|
||
onClick={handleDeleteSelectedNode}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<CondEditor
|
||
cond={selectedLogicNode.condition}
|
||
signalType={buySellTab(signalTab)}
|
||
def={DEF}
|
||
stochPairEdit={stochPairEditForSelected}
|
||
onChange={c => {
|
||
if (!selectedNodeId) return;
|
||
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
|
||
if (inOrphans) {
|
||
handleOrphansChange(currentOrphans.map(o => (
|
||
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
|
||
)));
|
||
return;
|
||
}
|
||
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
|
||
if (findLogicNode(currentRoot, [], selectedNodeId)) {
|
||
handleRootChange(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
|
||
return;
|
||
}
|
||
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
|
||
if (branch && findLogicNode(branch, [], selectedNodeId)) {
|
||
handleExtraRootsChange({
|
||
...(currentLayout.extraRoots ?? {}),
|
||
[startId]: updateNode(branch, selectedNodeId, n => ({ ...n, condition: c })),
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
<span className="se-sync-tip">전략 조건 전용 설정 · 차트 보조지표와 무관</span>
|
||
</div>
|
||
)}
|
||
|
||
{selectedLogicNode && isStochOverboughtPairRoot(selectedLogicNode) && selectedLogicNode.stochPair && (
|
||
<div className="se-node-config-bar se-node-config-bar--stoch-pair">
|
||
<div className="se-node-config-head">
|
||
<span className="se-node-config-label">Stoch 과열×보조</span>
|
||
<button
|
||
type="button"
|
||
className="se-node-config-del"
|
||
title="삭제"
|
||
aria-label="지표 삭제"
|
||
onClick={handleDeleteSelectedNode}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<StochPairNodeSettings
|
||
variant="inline"
|
||
secondaryIndicator={selectedLogicNode.stochPair.secondaryIndicatorType}
|
||
onChange={next => applyStochPairSecondary(selectedLogicNode.id, next)}
|
||
onClose={() => {}}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{selectedLogicNode && isPriceExtremeBreakoutPairRoot(selectedLogicNode) && selectedLogicNode.priceExtremePair && (
|
||
<div className="se-node-config-bar se-node-config-bar--price-extreme">
|
||
<div className="se-node-config-head">
|
||
<span className="se-node-config-label">9·20 신고가/신저가 필터</span>
|
||
<button
|
||
type="button"
|
||
className="se-node-config-del"
|
||
title="삭제"
|
||
aria-label="지표 삭제"
|
||
onClick={handleDeleteSelectedNode}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<PriceExtremePairNodeSettings
|
||
variant="inline"
|
||
mode={selectedLogicNode.priceExtremePair.mode}
|
||
shortPeriod={selectedLogicNode.priceExtremePair.shortPeriod}
|
||
longPeriod={selectedLogicNode.priceExtremePair.longPeriod}
|
||
filterLevel={inferPriceExtremeFilterLevel(selectedLogicNode)}
|
||
onChange={next => applyPriceExtremeFilterLevel(selectedLogicNode.id, next)}
|
||
onClose={() => {}}
|
||
/>
|
||
</div>
|
||
)}
|
||
{selectedLogicNode && isInflection33PairRoot(selectedLogicNode) && selectedLogicNode.inflection33Pair && (
|
||
<div className="se-node-config-bar se-node-config-bar--inflection33">
|
||
<div className="se-node-config-head">
|
||
<span className="se-node-config-label">33변곡 필터</span>
|
||
<button
|
||
type="button"
|
||
className="se-node-config-del"
|
||
title="삭제"
|
||
aria-label="지표 삭제"
|
||
onClick={handleDeleteSelectedNode}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<Inflection33PairNodeSettings
|
||
variant="inline"
|
||
mode={selectedLogicNode.inflection33Pair.mode}
|
||
period={selectedLogicNode.inflection33Pair.period}
|
||
filterLevel={inferInflection33FilterLevel(selectedLogicNode)}
|
||
onChange={next => applyInflection33FilterLevel(selectedLogicNode.id, next)}
|
||
onClose={() => {}}
|
||
/>
|
||
</div>
|
||
)}
|
||
{selectedLogicNode && isStableStrategyPairRoot(selectedLogicNode) && selectedLogicNode.stableStrategyPair && (
|
||
<div className="se-node-config-bar se-node-config-bar--stable-strategy">
|
||
<div className="se-node-config-head">
|
||
<span className="se-node-config-label">추천 전략 필터</span>
|
||
<button
|
||
type="button"
|
||
className="se-node-config-del"
|
||
title="삭제"
|
||
aria-label="지표 삭제"
|
||
onClick={handleDeleteSelectedNode}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<StableStrategyPairNodeSettings
|
||
variant="inline"
|
||
strategyId={selectedLogicNode.stableStrategyPair.strategyId as StableStrategyId}
|
||
mode={selectedLogicNode.stableStrategyPair.mode}
|
||
filterLevel={inferStableStrategyFilterLevel(selectedLogicNode)}
|
||
def={DEF}
|
||
onChange={next => applyStableStrategyFilterLevel(selectedLogicNode.id, next)}
|
||
onClose={() => {}}
|
||
/>
|
||
</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
<StrategyListEditor
|
||
editorState={currentEditorState}
|
||
signalTab={buySellTab(signalTab)}
|
||
def={DEF}
|
||
onEditorStateChange={handleEditorStateChange}
|
||
onAddStart={handleAddStartSection}
|
||
orphans={currentOrphans}
|
||
onOrphansChange={handleOrphansChange}
|
||
/>
|
||
)}
|
||
|
||
</div>
|
||
|
||
{signalTab !== 'eval' && (
|
||
<>
|
||
<div
|
||
className="se-splitter se-splitter--h"
|
||
role="separator"
|
||
aria-orientation="horizontal"
|
||
aria-label="Logic Expression 높이 조절"
|
||
onPointerDown={onTerminalSplitter}
|
||
/>
|
||
|
||
<footer className="se-terminal" style={{ height: terminalHeight }}>
|
||
<div className="se-terminal-scroll">
|
||
<section className="se-terminal-section">
|
||
<div className="se-terminal-label">LOGIC EXPRESSION</div>
|
||
<LogicExpressionPreview
|
||
buyCondition={buyCondition}
|
||
sellCondition={sellCondition}
|
||
buyEditorState={buyEditorState}
|
||
sellEditorState={sellEditorState}
|
||
orphanCount={orphanTotal}
|
||
def={DEF}
|
||
/>
|
||
</section>
|
||
<section className="se-terminal-section se-terminal-section--narrative">
|
||
<div className="se-terminal-label se-terminal-label--guide">조건 설명</div>
|
||
<LogicExpressionNarrative
|
||
name={stratName}
|
||
description={stratDesc}
|
||
buyCondition={buyCondition}
|
||
sellCondition={sellCondition}
|
||
buyEditorState={buyEditorState}
|
||
sellEditorState={sellEditorState}
|
||
orphanCount={orphanTotal}
|
||
def={DEF}
|
||
compact
|
||
/>
|
||
</section>
|
||
</div>
|
||
</footer>
|
||
</>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
{rightOpen && (
|
||
<div
|
||
className="se-splitter se-splitter--v"
|
||
role="separator"
|
||
aria-orientation="vertical"
|
||
aria-label="우측 패널 너비 조절"
|
||
onPointerDown={onRightSplitter}
|
||
/>
|
||
)}
|
||
|
||
<div className={`se-side-wrap se-side-wrap--right${rightOpen ? ' se-side-wrap--open' : ''}`}>
|
||
<SePanelCollapseHandle
|
||
side="right"
|
||
open={rightOpen}
|
||
onToggle={toggleRightPanel}
|
||
label="지표 패널"
|
||
/>
|
||
<aside className={`se-right se-right--collapsible${rightOpen ? ' se-right--collapsible--open' : ''}`}>
|
||
<div className="se-palette-panel">
|
||
<div className="se-right-tabs">
|
||
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}>지표</button>
|
||
<button type="button" className={rightTab === 'templates' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('templates')}>템플릿</button>
|
||
<button type="button" className={rightTab === 'ai' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('ai')}>AI 전략</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">
|
||
{BAND_PALETTE_CHIP_ITEMS.filter(i => match(i.label, i.desc)).map(item => (
|
||
<PaletteChip
|
||
key={item.value}
|
||
{...item}
|
||
period={getIndicatorPeriodLabel(item.value, DEF)}
|
||
selected={isPaletteSelected(item.type, item.value)}
|
||
onSelect={() => selectPalette(item.type, item.value)}
|
||
onAdd={() => applyPalette(item.type, item.value, item.label)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="se-palette-subtabs">
|
||
<button
|
||
type="button"
|
||
className={`se-palette-subtab se-palette-subtab--aux${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
|
||
onClick={() => {
|
||
setIndicatorSubTab('auxiliary');
|
||
setSelectedPaletteKey(null);
|
||
}}
|
||
>
|
||
보조지표
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`se-palette-subtab se-palette-subtab--composite${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
|
||
onClick={() => {
|
||
setIndicatorSubTab('composite');
|
||
setSelectedPaletteKey(null);
|
||
}}
|
||
>
|
||
복합지표
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`se-palette-subtab se-palette-subtab--range${indicatorSubTab === 'range' ? ' se-palette-subtab--on' : ''}`}
|
||
onClick={() => {
|
||
setIndicatorSubTab('range');
|
||
setSelectedPaletteKey(null);
|
||
}}
|
||
>
|
||
횡보필터
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`se-palette-subtab se-palette-subtab--trendline${indicatorSubTab === 'trendline' ? ' se-palette-subtab--on' : ''}`}
|
||
onClick={() => {
|
||
setIndicatorSubTab('trendline');
|
||
setSelectedPaletteKey(null);
|
||
}}
|
||
>
|
||
추세
|
||
</button>
|
||
</div>
|
||
{indicatorSubTab === 'auxiliary' ? (
|
||
<IndicatorPaletteTab
|
||
kind="auxiliary"
|
||
items={auxiliaryPalette}
|
||
onItemsChange={setAuxiliaryPalette}
|
||
def={DEF}
|
||
searchQuery={paletteSearch}
|
||
selectedItemId={
|
||
selectedPaletteKey?.startsWith('auxiliary:')
|
||
? selectedPaletteKey.slice('auxiliary:'.length)
|
||
: null
|
||
}
|
||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('auxiliary', id) : null)}
|
||
onAddToCanvas={item => {
|
||
selectPalette('auxiliary', item.id);
|
||
applyPaletteItem(item);
|
||
}}
|
||
/>
|
||
) : indicatorSubTab === 'composite' ? (
|
||
<IndicatorPaletteTab
|
||
kind="composite"
|
||
items={compositePalette}
|
||
onItemsChange={setCompositePalette}
|
||
def={DEF}
|
||
searchQuery={paletteSearch}
|
||
selectedItemId={
|
||
selectedPaletteKey?.startsWith('composite:')
|
||
? selectedPaletteKey.slice('composite:'.length)
|
||
: null
|
||
}
|
||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('composite', id) : null)}
|
||
onAddToCanvas={item => {
|
||
selectPalette('composite', item.id);
|
||
applyPaletteItem(item);
|
||
}}
|
||
/>
|
||
) : indicatorSubTab === 'range' ? (
|
||
<SidewaysFilterPaletteTab
|
||
def={DEF}
|
||
items={sidewaysPalette}
|
||
onItemsChange={setSidewaysPalette}
|
||
searchQuery={paletteSearch}
|
||
selectedItemId={
|
||
selectedPaletteKey?.startsWith('range:')
|
||
? selectedPaletteKey.slice('range:'.length)
|
||
: null
|
||
}
|
||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('range', id) : null)}
|
||
onAddToCanvas={item => {
|
||
selectPalette('range', item.id);
|
||
applySidewaysFilter(item.id);
|
||
}}
|
||
/>
|
||
) : (
|
||
<TrendLinePaletteTab
|
||
signalType={buySellTab(signalTab)}
|
||
searchQuery={paletteSearch}
|
||
selectedItemId={
|
||
selectedPaletteKey?.startsWith('trendline:')
|
||
? selectedPaletteKey.slice('trendline:'.length)
|
||
: null
|
||
}
|
||
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('trendline', id) : null)}
|
||
onAddToCanvas={(item, lookback) => {
|
||
selectPalette('trendline', item.id);
|
||
applyTrendLine(item, lookback);
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
{rightTab === 'templates' && (
|
||
<TemplatePaletteTab
|
||
rows={filteredTemplateRows}
|
||
searchQuery={templateSearch}
|
||
onSearchChange={setTemplateSearch}
|
||
selectedKey={selectedTemplateKey}
|
||
onSelectKey={setSelectedTemplateKey}
|
||
onApply={handleTemplateApply}
|
||
onAdd={openTemplateAddModal}
|
||
onEdit={openTemplateEditModal}
|
||
onDelete={handleTemplateToolbarDelete}
|
||
/>
|
||
)}
|
||
{rightTab === 'ai' && (
|
||
<AiStrategyPanel
|
||
getContext={buildAiContext}
|
||
onApplyStrategy={applyAiStrategy}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{templateSaveOpen && (
|
||
<DraggableModalFrame
|
||
onClose={() => { setTemplateSaveLabelError(false); setTemplateSaveOpen(false); }}
|
||
title={templateModalMode === 'edit' ? '템플릿 수정' : '템플릿 저장'}
|
||
>
|
||
<div className="se-modal-body">
|
||
<p className="se-template-save-hint">
|
||
{templateModalMode === 'edit'
|
||
? <>선택한 <strong>내 템플릿</strong>의 이름·설명을 수정합니다.</>
|
||
: <>현재 <strong>{signalTab === 'buy' ? '매수' : '매도'}</strong> 탭 조건을 템플릿 목록에 저장합니다.</>}
|
||
</p>
|
||
<label className="se-field-lbl">템플릿명 *</label>
|
||
<input
|
||
className={`se-field-inp${templateSaveLabelError ? ' se-field-inp--error' : ''}`}
|
||
value={templateSaveLabel}
|
||
onChange={e => { setTemplateSaveLabel(e.target.value); setTemplateSaveLabelError(false); }}
|
||
placeholder="예: RSI 과매도 + MA 골든"
|
||
/>
|
||
{templateSaveLabelError && (
|
||
<p className="se-field-error" role="alert">템플릿 이름을 입력하세요</p>
|
||
)}
|
||
<label className="se-field-lbl">설명</label>
|
||
<textarea
|
||
className="se-field-ta"
|
||
value={templateSaveDesc}
|
||
onChange={e => setTemplateSaveDesc(e.target.value)}
|
||
rows={2}
|
||
placeholder="템플릿 설명 (선택)"
|
||
/>
|
||
{templateModalMode === 'edit' && (
|
||
<label className="se-template-save-update">
|
||
<input
|
||
type="checkbox"
|
||
checked={templateSaveUpdateContent}
|
||
onChange={e => setTemplateSaveUpdateContent(e.target.checked)}
|
||
/>
|
||
<span>현재 {signalTab === 'buy' ? '매수' : '매도'} 탭 조건으로 내용 갱신</span>
|
||
</label>
|
||
)}
|
||
<div className="se-modal-actions">
|
||
<button type="button" className="se-btn se-btn--ghost" onClick={() => setTemplateSaveOpen(false)}>취소</button>
|
||
<button type="button" className="se-btn se-btn--gold" onClick={handleTemplateSaveConfirm}>
|
||
{templateModalMode === 'edit' ? '수정' : '저장'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</DraggableModalFrame>
|
||
)}
|
||
|
||
{saveOpen && (
|
||
<DraggableModalFrame
|
||
onClose={() => { setSaveNameError(false); setSaveDuplicateError(false); setSaveOpen(false); }}
|
||
title={saveMode === 'saveAs' ? '새 이름으로 저장' : '전략 저장'}
|
||
>
|
||
<div className="se-modal-body">
|
||
{saveMode === 'saveAs' && selectedId && (
|
||
<p className="se-template-save-hint">
|
||
현재 편집 중인 조건을 <strong>새 전략</strong>으로 저장합니다.
|
||
기존 전략「{strategies.find(s => s.id === selectedId)?.name ?? stratName}」은 변경되지 않습니다.
|
||
</p>
|
||
)}
|
||
{saveMode === 'saveAs' && !selectedId && (
|
||
<p className="se-template-save-hint">
|
||
현재 조건을 <strong>새 전략</strong>으로 저장합니다.
|
||
</p>
|
||
)}
|
||
<label className="se-field-lbl">전략명 *</label>
|
||
<input
|
||
className={`se-field-inp${saveNameError || saveDuplicateError ? ' se-field-inp--error' : ''}`}
|
||
value={saveDraftName}
|
||
onChange={e => {
|
||
setSaveDraftName(e.target.value);
|
||
setSaveNameError(false);
|
||
setSaveDuplicateError(false);
|
||
}}
|
||
placeholder="예: Golden_RSI_V1"
|
||
/>
|
||
{saveNameError && (
|
||
<p className="se-field-error" role="alert">전략 이름을 입력하세요</p>
|
||
)}
|
||
{saveDuplicateError && (
|
||
<p className="se-field-error" role="alert">같은 이름의 전략이 이미 있습니다</p>
|
||
)}
|
||
<label className="se-field-lbl">설명</label>
|
||
<textarea
|
||
className="se-field-ta"
|
||
value={saveDraftDesc}
|
||
onChange={e => setSaveDraftDesc(e.target.value)}
|
||
rows={2}
|
||
/>
|
||
<div className="se-modal-actions">
|
||
<button type="button" className="se-btn se-btn--ghost" onClick={() => setSaveOpen(false)}>취소</button>
|
||
<button type="button" className="se-btn se-btn--gold" disabled={isSaving} onClick={handleSave}>
|
||
{isSaving ? '저장 중…' : (saveMode === 'saveAs' ? '새로저장' : '저장')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</DraggableModalFrame>
|
||
)}
|
||
|
||
{deleteOpen && (
|
||
<DraggableModalFrame onClose={() => setDeleteOpen(false)} title="전략 삭제">
|
||
<p>이 전략을 삭제하시겠습니까?</p>
|
||
<div className="se-modal-actions">
|
||
<button type="button" className="se-btn se-btn--ghost" onClick={() => setDeleteOpen(false)}>취소</button>
|
||
<button type="button" className="se-btn se-btn--danger" onClick={handleDeleteConfirm}>삭제</button>
|
||
</div>
|
||
</DraggableModalFrame>
|
||
)}
|
||
|
||
{descOpen && (
|
||
<StrategyDescriptionModal
|
||
onClose={() => setDescOpen(false)}
|
||
name={stratName}
|
||
description={stratDesc}
|
||
buyEditorState={buyEditorState}
|
||
sellEditorState={sellEditorState}
|
||
buyCondition={buyCondition}
|
||
sellCondition={sellCondition}
|
||
orphanCount={orphanTotal}
|
||
def={DEF}
|
||
/>
|
||
)}
|
||
|
||
{snack && <div className={`se-snack${snack.ok ? '' : ' se-snack--err'}`}>{snack.msg}</div>}
|
||
</div>
|
||
);
|
||
}
|