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