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

- 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
+1 -1
View File
@@ -225,7 +225,7 @@ function AppMainContent({
formalLogin formalLogin
? ( ? (
<Suspense fallback={<PageLoadFallback />}> <Suspense fallback={<PageLoadFallback />}>
<StrategyEditorPage theme={theme} /> <StrategyEditorPage theme={theme} onNavigateToBacktest={() => setMenuPage('backtest')} />
</Suspense> </Suspense>
) )
: <FormalLoginGate onLogin={requireFormalLogin} /> : <FormalLoginGate onLogin={requireFormalLogin} />
@@ -52,6 +52,7 @@ import { enrichStrategyNameMap, strategyNamesFromList } from '../utils/strategyN
const LEFT_KEY = 'btd-left-width'; const LEFT_KEY = 'btd-left-width';
const RIGHT_KEY = 'btd-right-width'; const RIGHT_KEY = 'btd-right-width';
const BACKTEST_FOCUS_KEY = 'backtest_focus_id';
const LEFT_DEFAULT = 380; const LEFT_DEFAULT = 380;
const RIGHT_DEFAULT = 440; const RIGHT_DEFAULT = 440;
@@ -182,8 +183,18 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
setStrategies(strategies); setStrategies(strategies);
setRecords(enrichedRecords); setRecords(enrichedRecords);
setLiveItems(live); 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)); setSelectedLive(prev => (prev && live.some(x => x.id === prev.id) ? prev : live[0] ?? null));
if (focusRecord) setTab('backtest');
setLoading(false); setLoading(false);
}, []); }, []);
+136 -1
View File
@@ -84,9 +84,30 @@ import {
import PaletteChip from './strategyEditor/PaletteChip'; import PaletteChip from './strategyEditor/PaletteChip';
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize'; import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal'; 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/strategyEditor.css';
import '../styles/strategyEditorTheme.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_MIN = 220;
const LEFT_PANEL_MAX = 520; const LEFT_PANEL_MAX = 520;
const LEFT_PANEL_DEFAULT = 280; const LEFT_PANEL_DEFAULT = 280;
@@ -96,6 +117,7 @@ const TERMINAL_DEFAULT = 140;
interface Props { interface Props {
theme: Theme; theme: Theme;
onNavigateToBacktest?: () => void;
} }
function toEditorState( 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 { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo( const DEF = useMemo(
() => buildStrategyEditorDefFromSettings(getParams, getVisualConfig), () => buildStrategyEditorDefFromSettings(getParams, getVisualConfig),
@@ -176,6 +198,15 @@ export default function StrategyEditorPage({ theme }: Props) {
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]); const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null); const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
const [saveToast, setSaveToast] = useState(false); 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 [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT)); const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth); const leftWidthRef = useRef(leftWidth);
@@ -941,6 +972,50 @@ export default function StrategyEditorPage({ theme }: Props) {
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`); showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]); }, [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 = [ const operators = [
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' }, { 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: 'OR', label: 'OR', color: 'logic-or' },
@@ -982,6 +1057,66 @@ export default function StrategyEditorPage({ theme }: Props) {
)} )}
</span> </span>
</div> </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-header-actions">
<div className="se-editor-mode" role="group" aria-label="편집 방식"> <div className="se-editor-mode" role="group" aria-label="편집 방식">
<button <button
+95
View File
@@ -59,6 +59,101 @@
.se-subtitle { font-size: 0.72rem; color: var(--se-text-muted); } .se-subtitle { font-size: 0.72rem; color: var(--se-text-muted); }
.se-subtitle-hint { color: var(--se-accent, #7aa2f7); font-weight: 500; } .se-subtitle-hint { color: var(--se-accent, #7aa2f7); font-weight: 500; }
/* ── 빠른 백테스팅 실행 툴바 (se-quick-run) ─────────────────────────────── */
.se-quick-run {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
min-width: 0;
}
.se-qr-market-btn {
display: flex;
align-items: center;
gap: 4px;
background: var(--se-btn-bg, var(--bg2));
border: 1px solid var(--se-border);
color: var(--se-text);
border-radius: 7px;
padding: 0 8px;
font-size: 0.76rem;
cursor: pointer;
height: 30px;
outline: none;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
min-width: 88px;
max-width: 140px;
}
.se-qr-market-btn:hover:not(:disabled) {
border-color: var(--se-accent, #3d9be9);
background: color-mix(in srgb, var(--se-accent, #3d9be9) 8%, var(--se-btn-bg, var(--bg2)));
}
.se-qr-market-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.se-qr-market-ko { font-weight: 600; font-size: 0.76rem; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.se-qr-market-sym { font-size: 0.68rem; color: var(--se-text-muted); flex-shrink: 0; }
.se-qr-market-caret { flex-shrink: 0; opacity: 0.55; margin-left: 2px; }
.se-qr-select {
background: var(--se-btn-bg, var(--bg2));
border: 1px solid var(--se-border);
color: var(--se-text);
border-radius: 7px;
padding: 5px 8px;
font-size: 0.76rem;
cursor: pointer;
height: 30px;
outline: none;
transition: border-color 0.15s;
}
.se-qr-select:focus { border-color: var(--se-accent, #3d9be9); }
.se-qr-select:disabled { opacity: 0.45; cursor: not-allowed; }
.se-qr-run-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--se-accent, #3d9be9);
background: color-mix(in srgb, var(--se-accent, #3d9be9) 18%, transparent);
color: var(--se-accent, #3d9be9);
cursor: pointer;
transition: background 0.15s, transform 0.1s;
flex-shrink: 0;
}
.se-qr-run-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--se-accent, #3d9be9) 32%, transparent);
transform: scale(1.08);
}
.se-qr-run-btn:active:not(:disabled) { transform: scale(0.95); }
.se-qr-run-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.se-qr-run-btn--loading { border-color: var(--se-text-muted); color: var(--se-text-muted); }
.se-qr-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: se-spin 0.7s linear infinite;
}
@keyframes se-spin { to { transform: rotate(360deg); } }
.se-qr-error {
font-size: 0.71rem;
color: var(--se-danger, #f23645);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
/* ─────────────────────────────────────────────────────────────────────────── */
.se-header-actions { display: flex; align-items: center; gap: 8px; } .se-header-actions { display: flex; align-items: center; gap: 8px; }
.se-editor-mode { .se-editor-mode {