diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index b763c88..5f00bf6 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -121,6 +121,7 @@ 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 { @@ -178,6 +179,13 @@ import { import '../styles/strategyEditor.css'; import '../styles/strategyEditorTheme.css'; +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; @@ -301,7 +309,7 @@ export default function StrategyEditorPage({ const [selectedId, setSelectedId] = useState(null); const [buyCondition, setBuyCondition] = useState(null); const [sellCondition, setSellCondition] = useState(null); - const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy'); + const [signalTab, setSignalTab] = useState('buy'); const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators'); const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary'); const [auxiliaryPalette, setAuxiliaryPalette] = useState(() => loadPaletteItems('auxiliary')); @@ -610,7 +618,7 @@ export default function StrategyEditorPage({ ...prev, extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'), })); - bumpLayoutSeed(layoutStrategyKey, signalTab); + if (signalTab !== 'eval') bumpLayoutSeed(layoutStrategyKey, buySellTab(signalTab)); if (selectedId != null && stratName.trim()) scheduleStrategyPersist(); }, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed, selectedId, stratName, scheduleStrategyPersist]); @@ -647,7 +655,7 @@ export default function StrategyEditorPage({ const handleExtraStartIdsChange = useCallback((ids: string[]) => { setCurrentLayout(prev => ({ ...prev, extraStartIds: ids })); scheduleStrategyPersist(); - bumpLayoutSeed(layoutStrategyKey, signalTab); + if (signalTab !== 'eval') bumpLayoutSeed(layoutStrategyKey, buySellTab(signalTab)); }, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]); const handleExtraRootsChange = useCallback((roots: Record) => { @@ -814,14 +822,17 @@ export default function StrategyEditorPage({ handleEditorStateChange(prev => updateStartCombineOp(prev, op)); }, [handleEditorStateChange]); - const switchSignalTab = useCallback((tab: 'buy' | 'sell') => { - if (editorMode === 'graph') layoutFlushRef.current?.(); - flushStrategyPersist(); - layoutRevisionRef.current += 1; - setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`); + 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); - setSelectedNodeId(null); - }, [layoutStrategyKey, flushStrategyPersist, editorMode]); + if (tab !== 'eval') setSelectedNodeId(null); + }, [layoutStrategyKey, flushStrategyPersist, editorMode, signalTab]); const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => { if (mode === editorMode) return; @@ -991,7 +1002,7 @@ export default function StrategyEditorPage({ startCombineOp: normalizeStartCombineOp(stored?.sell.startCombineOp ?? sellDecoded.startCombineOp), }); setSelectedNodeId(null); - bumpLayoutSeed(String(s.id), signalTab); + bumpLayoutSeed(String(s.id), buySellTab(signalTab)); if (!s.flowLayout && stored) { void (async () => { @@ -1144,7 +1155,7 @@ export default function StrategyEditorPage({ } setStratName(name); setStratDesc(saveDraftDesc); - bumpLayoutSeed(String(dbId), signalTab); + bumpLayoutSeed(String(dbId), buySellTab(signalTab)); setSaveOpen(false); setSaveToast(true); setTimeout(() => setSaveToast(false), 2500); @@ -1175,7 +1186,7 @@ export default function StrategyEditorPage({ notifyStrategiesChanged(); }; - const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: 'buy' | 'sell' = signalTab) => { + const applyImportedFlowLayout = useCallback((layout: StrategyFlowLayoutStore | undefined, tab: BuySellTab = buySellTab(signalTab)) => { if (layout) { setBuyLayout({ positions: layout.buy.positions ?? {}, @@ -1346,7 +1357,7 @@ export default function StrategyEditorPage({ const handleAddStartSection = useCallback(() => { handleEditorStateChange(prev => addExtraStartSection(prev)); - bumpLayoutSeed(layoutStrategyKey, signalTab); + if (signalTab !== 'eval') bumpLayoutSeed(layoutStrategyKey, buySellTab(signalTab)); }, [handleEditorStateChange, bumpLayoutSeed, layoutStrategyKey, signalTab]); const applyPalette = useCallback((type: string, value: string, _label: string, composite = false) => { @@ -1354,7 +1365,7 @@ export default function StrategyEditorPage({ handleAddStartSection(); return; } - const newNode = makeNode(type, value, signalTab, DEF, composite ? { composite: true } : undefined); + 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); @@ -1385,7 +1396,7 @@ export default function StrategyEditorPage({ ? buildStableStrategyTree(item.value, DEF) : ichimokuSituation ? buildIchimokuSituationTree(item.value, DEF) - : makeNode('indicator', item.value, signalTab, DEF, composite ? { composite: true } : undefined); + : makeNode('indicator', item.value, buySellTab(signalTab), DEF, composite ? { composite: true } : undefined); if (!newNode) return; handleEditorStateChange(prev => { const root = prev.root; @@ -1583,7 +1594,7 @@ export default function StrategyEditorPage({ showSnack('갱신할 조건이 없습니다', false); return; } - patch.signal = signalTab; + patch.signal = buySellTab(signalTab); patch.editorState = toEditorState(currentRoot, currentLayout); patch.orphans = currentOrphans; } @@ -1595,7 +1606,7 @@ export default function StrategyEditorPage({ const saved = addUserStrategyTemplate({ label, description: templateSaveDesc.trim() || undefined, - signal: signalTab, + signal: buySellTab(signalTab), editorState: toEditorState(currentRoot, currentLayout), orphans: currentOrphans, }); @@ -1946,7 +1957,7 @@ export default function StrategyEditorPage({
-
+
+
{stratName && {stratName}} - {hasMultipleStartSections(currentEditorState) && ( + {signalTab !== 'eval' && hasMultipleStartSections(currentEditorState) && ( + {signalTab !== 'eval' && (
+ )} + {signalTab !== 'eval' && ( + )}
- {editorMode === 'graph' ? ( + {signalTab === 'eval' ? ( + + ) : editorMode === 'graph' ? ( <> { @@ -2195,7 +2225,7 @@ export default function StrategyEditorPage({ ) : ( + {signalTab !== 'eval' && ( + <>
+ + )}
diff --git a/frontend/src/components/strategyEditor/StrategyEditorEvaluationPanel.tsx b/frontend/src/components/strategyEditor/StrategyEditorEvaluationPanel.tsx new file mode 100644 index 0000000..4650b57 --- /dev/null +++ b/frontend/src/components/strategyEditor/StrategyEditorEvaluationPanel.tsx @@ -0,0 +1,125 @@ +/** + * 전략편집기 — 작성 중 전략 평가 차트 (StrategyEvaluationChart 재사용) + */ +import '../../styles/backtestDashboard.css'; +import '../../styles/strategyEvaluation.css'; +import React, { useEffect, useMemo, useState } from 'react'; +import type { Theme, Timeframe } from '../../types'; +import StrategyEvaluationChart from '../strategyEvaluation/StrategyEvaluationChart'; +import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; +import { useMarketTicker } from '../../hooks/useMarketTicker'; +import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; +import { + BACKTEST_STRATEGY_TIMEFRAME, + buildQuickRunTimeframeSelectOptions, + resolveBacktestExecTimeframe, + type BacktestRunTimeframeChoice, +} from '../../utils/backtestRunTimeframe'; +import { + buildDraftStrategyForEvaluation, + DRAFT_EVAL_STRATEGY_ID, +} from '../../utils/strategyEditorEvaluation'; +import type { EditorConditionState } from '../../utils/strategyConditionSerde'; + +interface Props { + theme?: Theme; + stratName: string; + stratDesc: string; + buyEditorState: EditorConditionState; + sellEditorState: EditorConditionState; +} + +const StrategyEditorEvaluationPanel: React.FC = ({ + theme = 'dark', + stratName, + stratDesc, + buyEditorState, + sellEditorState, +}) => { + const { getParams, getVisualConfig, settingsRevision } = useIndicatorSettings(); + const [market, setMarket] = useState('KRW-BTC'); + const [timeframeChoice, setTimeframeChoice] = useState(BACKTEST_STRATEGY_TIMEFRAME); + const [selectedBarIndex, setSelectedBarIndex] = useState(0); + const [paramsRevision, setParamsRevision] = useState(0); + + const draftStrategy = useMemo( + () => buildDraftStrategyForEvaluation({ + name: stratName, + description: stratDesc, + buyEditorState, + sellEditorState, + }), + [stratName, stratDesc, buyEditorState, sellEditorState], + ); + + const strategyPrimaryTimeframe = useMemo( + () => (draftStrategy ? resolveStrategyPrimaryTimeframe(draftStrategy) : undefined), + [draftStrategy], + ); + + const chartTimeframe = useMemo((): Timeframe => { + return resolveBacktestExecTimeframe(timeframeChoice, strategyPrimaryTimeframe ?? '3m'); + }, [timeframeChoice, strategyPrimaryTimeframe]); + + const tfOptions = useMemo( + () => buildQuickRunTimeframeSelectOptions(strategyPrimaryTimeframe), + [strategyPrimaryTimeframe], + ); + + const draftRevisionKey = useMemo( + () => `${draftStrategy?.buyCondition ?? ''}|${draftStrategy?.sellCondition ?? ''}`, + [draftStrategy?.buyCondition, draftStrategy?.sellCondition], + ); + + useEffect(() => { + setParamsRevision(v => v + 1); + }, [draftRevisionKey, settingsRevision]); + + const { + tickers: marketTickers, + marketInfos, + loading: marketLoading, + refreshAllTickers: refreshMarketTickers, + } = useMarketTicker(); + + if (!draftStrategy) { + return ( +
+

매수·매도 조건을 추가하면 작성 중인 전략으로 평가할 수 있습니다.

+
+ ); + } + + const chartStrategies = [draftStrategy]; + + return ( +
+ {}} + onMarketChange={setMarket} + onTimeframeChoiceChange={setTimeframeChoice} + theme={theme} + strategy={draftStrategy} + selectedBarIndex={selectedBarIndex} + onSelectedBarIndexChange={setSelectedBarIndex} + paramsRevision={paramsRevision} + getParamsOverride={getParams} + getVisualConfigOverride={getVisualConfig} + marketTickers={marketTickers} + marketInfos={marketInfos} + marketLoading={marketLoading} + refreshMarketTickers={refreshMarketTickers} + hideStrategySelect + useDraftDsl + /> +
+ ); +}; + +export default StrategyEditorEvaluationPanel; diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index 1d3158c..e9a8ce2 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -126,6 +126,10 @@ interface Props { /** 캔들 오버레이(SMA·볼린저·일목·캔들) 표시 — 미전달 시 내부 state */ overlayVisibility?: ChartOverlayVisibility; onSetOverlayVisibility?: (key: ChartOverlayToggleKey, visible: boolean) => void; + /** 전략편집기 평가 탭 — 전략 선택 드롭다운 숨김 */ + hideStrategySelect?: boolean; + /** 미저장 draft DSL로 시그널 계산 */ + useDraftDsl?: boolean; } const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; @@ -174,6 +178,8 @@ const StrategyEvaluationChart: React.FC = ({ onMarketSearchOpenChange, overlayVisibility: overlayVisibilityProp, onSetOverlayVisibility, + hideStrategySelect = false, + useDraftDsl = false, }) => { const { getParams: baseGetParams, getVisualConfig: baseGetVisual, settingsRevision } = useIndicatorSettings(); const getParams = getParamsOverride ?? baseGetParams; @@ -417,6 +423,7 @@ const StrategyEvaluationChart: React.FC = ({ bars: barData, evaluationBarCount: evaluationBarCountRef.current, getParams, + useDraftDsl, }); if (gen !== backtestGenRef.current) return; setSignals(nextSignals); @@ -429,7 +436,7 @@ const StrategyEvaluationChart: React.FC = ({ } finally { if (gen === backtestGenRef.current) setBacktestRunning(false); } - }, [strategy, market, timeframe, getParams, applyMarkers]); + }, [strategy, market, timeframe, getParams, applyMarkers, useDraftDsl]); useEffect(() => { if (!strategy?.id || loading || bars.length < 10) { @@ -444,7 +451,7 @@ const StrategyEvaluationChart: React.FC = ({ } void runSignalScanForBars(bars); return () => { ++backtestGenRef.current; }; - }, [strategy?.id, strategy?.buyCondition, market, timeframe, paramsRevision, indicatorParamsKey, bars, loading, runSignalScanForBars]); + }, [strategy?.id, strategy?.buyCondition, strategy?.sellCondition, market, timeframe, paramsRevision, indicatorParamsKey, bars, loading, runSignalScanForBars]); useEffect(() => { applyMarkers(); @@ -523,6 +530,8 @@ const StrategyEvaluationChart: React.FC = ({ market, timeframe, strategy?.id, + strategy?.buyCondition, + strategy?.sellCondition, paramsRevision, indicatorParamsKey, windowAnchor, @@ -793,6 +802,7 @@ const StrategyEvaluationChart: React.FC = ({ ))} + {!hideStrategySelect && ( + )} {chartFullscreen && selectedBar && (