전략빌더 상단 툴바에 빠른 백테스팅 실행 기능 추가
- 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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user