전략빌더 상단 툴바에 빠른 백테스팅 실행 기능 추가

- StrategyEditorPage: 종목 검색(MarketSearchPanel), 타임프레임 select, 실행 버튼 UI 추가
- 백테스팅 완료 후 백테스팅 화면으로 자동 이동 (onNavigateToBacktest)
- BacktestHistoryPage: sessionStorage backtest_focus_id로 신규 결과 자동 선택
- App.tsx: StrategyEditorPage에 onNavigateToBacktest prop 전달
- strategyEditor.css: se-quick-run 툴바 스타일 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-06-11 16:15:30 +09:00
parent f23b9a2724
commit 7f1c63ef87
4 changed files with 244 additions and 3 deletions
@@ -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);
}, []);
+136 -1
View File
@@ -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<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<Timeframe>('1D');
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 [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) {
)}
</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(e.target.value as Timeframe)}
disabled={qbLoading}
title="타임프레임"
>
{QUICK_RUN_TIMEFRAMES.map(tf => (
<option key={tf.value} value={tf.value}>{tf.label}</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">
<div className="se-editor-mode" role="group" aria-label="편집 방식">
<button