From 7f1c63ef87a2b685fc96e621c6b34eb91d81ff3c Mon Sep 17 00:00:00 2001 From: Macbook Date: Thu, 11 Jun 2026 16:15:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=EB=B9=8C=EB=8D=94=20?= =?UTF-8?q?=EC=83=81=EB=8B=A8=20=ED=88=B4=EB=B0=94=EC=97=90=20=EB=B9=A0?= =?UTF-8?q?=EB=A5=B8=20=EB=B0=B1=ED=85=8C=EC=8A=A4=ED=8C=85=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StrategyEditorPage: 종목 검색(MarketSearchPanel), 타임프레임 select, 실행 버튼 UI 추가 - 백테스팅 완료 후 백테스팅 화면으로 자동 이동 (onNavigateToBacktest) - BacktestHistoryPage: sessionStorage backtest_focus_id로 신규 결과 자동 선택 - App.tsx: StrategyEditorPage에 onNavigateToBacktest prop 전달 - strategyEditor.css: se-quick-run 툴바 스타일 추가 Co-authored-by: Cursor --- frontend/src/App.tsx | 2 +- .../src/components/BacktestHistoryPage.tsx | 13 +- .../src/components/StrategyEditorPage.tsx | 137 +++++++++++++++++- frontend/src/styles/strategyEditor.css | 95 ++++++++++++ 4 files changed, 244 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 89de17b..9ac7b8c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -225,7 +225,7 @@ function AppMainContent({ formalLogin ? ( }> - + setMenuPage('backtest')} /> ) : diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index 196e350..a69af47 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -52,6 +52,7 @@ import { enrichStrategyNameMap, strategyNamesFromList } from '../utils/strategyN const LEFT_KEY = 'btd-left-width'; const RIGHT_KEY = 'btd-right-width'; +const BACKTEST_FOCUS_KEY = 'backtest_focus_id'; const LEFT_DEFAULT = 380; const RIGHT_DEFAULT = 440; @@ -182,8 +183,18 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) { setStrategies(strategies); setRecords(enrichedRecords); setLiveItems(live); - setSelectedBacktest(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null)); + + // 전략빌더에서 백테스팅 실행 후 자동 선택 + let focusId: number | null = null; + try { + const stored = sessionStorage.getItem(BACKTEST_FOCUS_KEY); + if (stored) { focusId = Number(stored); sessionStorage.removeItem(BACKTEST_FOCUS_KEY); } + } catch { /* ignore */ } + const focusRecord = focusId ? list.find(r => r.id === focusId) ?? null : null; + + setSelectedBacktest(prev => focusRecord ?? (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null)); setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null)); + if (focusRecord) setTab('backtest'); setLoading(false); }, []); diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 63044a6..72a22d4 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -84,9 +84,30 @@ import { import PaletteChip from './strategyEditor/PaletteChip'; import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal'; +import ReactDOM from 'react-dom'; +import { MarketSearchPanel } from './MarketSearchPanel'; +import { runBacktest, loadBacktestSettings, API_BASE } from '../utils/backendApi'; +import { timeframeToCandleType } from '../utils/chartCandleType'; +import { getKoreanName } from '../utils/marketNameCache'; +import type { Timeframe } from '../types'; import '../styles/strategyEditor.css'; import '../styles/strategyEditorTheme.css'; +const QUICK_RUN_TIMEFRAMES: { value: Timeframe; label: string }[] = [ + { value: '1m', label: '1분' }, + { value: '3m', label: '3분' }, + { value: '5m', label: '5분' }, + { value: '10m', label: '10분' }, + { value: '15m', label: '15분' }, + { value: '30m', label: '30분' }, + { value: '1h', label: '1시간' }, + { value: '4h', label: '4시간' }, + { value: '1D', label: '일봉' }, + { value: '1W', label: '주봉' }, +]; + +const BACKTEST_FOCUS_KEY = 'backtest_focus_id'; + const LEFT_PANEL_MIN = 220; const LEFT_PANEL_MAX = 520; const LEFT_PANEL_DEFAULT = 280; @@ -96,6 +117,7 @@ const TERMINAL_DEFAULT = 140; interface Props { theme: Theme; + onNavigateToBacktest?: () => void; } function toEditorState( @@ -144,7 +166,7 @@ function applyFlowLayoutFromStore( } } -export default function StrategyEditorPage({ theme }: Props) { +export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Props) { const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings(); const DEF = useMemo( () => buildStrategyEditorDefFromSettings(getParams, getVisualConfig), @@ -176,6 +198,15 @@ export default function StrategyEditorPage({ theme }: Props) { const [sellOrphans, setSellOrphans] = useState([]); 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('1D'); + const [qbLoading, setQbLoading] = useState(false); + const [qbError, setQbError] = useState(null); + const [qbMarketOpen, setQbMarketOpen] = useState(false); + const [qbMarketQ, setQbMarketQ] = useState(''); + const qbMarketBtnRef = useRef(null); const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT)); const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT)); const leftWidthRef = useRef(leftWidth); @@ -941,6 +972,50 @@ export default function StrategyEditorPage({ theme }: Props) { showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); }, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]); + const handleQuickRun = useCallback(async () => { + if (!selectedId) { + window.alert('백테스팅할 전략을 먼저 선택해주세요.'); + return; + } + setQbLoading(true); + setQbError(null); + try { + const selectedStrategy = strategies.find(s => s.id === selectedId); + const [settings, barsRes] = await Promise.all([ + loadBacktestSettings(), + fetch(`${API_BASE}/candles/history?${new URLSearchParams({ + market: qbMarket, + type: timeframeToCandleType(qbTimeframe), + count: '800', + })}`), + ]); + if (!barsRes.ok) throw new Error(`캔들 로딩 실패 (${barsRes.status})`); + const bars = (await barsRes.json()) as Array<{ + time: number; open: number; high: number; low: number; close: number; volume: number; + }>; + if (bars.length < 5) { + throw new Error('캔들 데이터가 부족합니다. 타임프레임을 변경하거나 잠시 후 다시 시도해주세요.'); + } + const result = await runBacktest({ + strategyId: selectedId, + bars, + timeframe: qbTimeframe, + settings, + symbol: qbMarket, + strategyName: selectedStrategy?.name, + }); + if (!result) throw new Error('백테스팅 실행에 실패했습니다.'); + if (result.resultId) { + try { sessionStorage.setItem(BACKTEST_FOCUS_KEY, String(result.resultId)); } catch { /* ignore */ } + } + onNavigateToBacktest?.(); + } catch (e) { + setQbError(e instanceof Error ? e.message : '실행 중 오류가 발생했습니다.'); + } finally { + setQbLoading(false); + } + }, [selectedId, strategies, qbMarket, qbTimeframe, onNavigateToBacktest]); + const operators = [ { type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' }, { type: 'operator' as const, value: 'OR', label: 'OR', color: 'logic-or' }, @@ -982,6 +1057,66 @@ export default function StrategyEditorPage({ theme }: Props) { )} + + {/* 빠른 백테스팅 실행 툴바 */} +
+ + {qbMarketOpen && ReactDOM.createPortal( + { setQbMarket(market); setQbMarketOpen(false); setQbMarketQ(''); }} + onClose={() => { setQbMarketOpen(false); setQbMarketQ(''); }} + anchorRect={qbMarketBtnRef.current?.getBoundingClientRect() ?? null} + anchorPlacement="dropdown" + />, + document.body, + )} + + + {qbError && ( + ⚠ {qbError} + )} +
+