전략빌더 상단 툴바에 빠른 백테스팅 실행 기능 추가
- 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:
@@ -225,7 +225,7 @@ function AppMainContent({
|
||||
formalLogin
|
||||
? (
|
||||
<Suspense fallback={<PageLoadFallback />}>
|
||||
<StrategyEditorPage theme={theme} />
|
||||
<StrategyEditorPage theme={theme} onNavigateToBacktest={() => setMenuPage('backtest')} />
|
||||
</Suspense>
|
||||
)
|
||||
: <FormalLoginGate onLogin={requireFormalLogin} />
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,101 @@
|
||||
.se-subtitle { font-size: 0.72rem; color: var(--se-text-muted); }
|
||||
.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-editor-mode {
|
||||
|
||||
Reference in New Issue
Block a user