백테스팅 화면 상단 툴바에 빠른 실행 기능 추가
- 헤더 중앙에 종목·타임프레임·전략 드롭다운 + 실행 버튼 추가 - 실행 클릭 시 /api/candles/history에서 캔들 로드 → runBacktest 호출 - 완료 후 결과 목록 자동 갱신 및 백테스팅 탭 활성화 - 실행 중 스피너 표시, 오류 시 인라인 에러 메시지 표시 - btd-header-run·btd-run-select·btd-run-btn·btd-run-spinner CSS 추가 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,12 +9,17 @@ import {
|
|||||||
loadPaperTrades,
|
loadPaperTrades,
|
||||||
loadPaperSummary,
|
loadPaperSummary,
|
||||||
loadStrategies,
|
loadStrategies,
|
||||||
|
runBacktest,
|
||||||
|
loadBacktestSettings,
|
||||||
|
API_BASE,
|
||||||
type BacktestResultRecord,
|
type BacktestResultRecord,
|
||||||
type BacktestAnalysis,
|
type BacktestAnalysis,
|
||||||
type BacktestSignal,
|
type BacktestSignal,
|
||||||
type StrategyDto,
|
type StrategyDto,
|
||||||
} from '../utils/backendApi';
|
} from '../utils/backendApi';
|
||||||
import type { Theme } from '../types';
|
import { UPBIT_MARKETS } from '../utils/upbitApi';
|
||||||
|
import { timeframeToCandleType } from '../utils/chartCandleType';
|
||||||
|
import type { Theme, Timeframe } from '../types';
|
||||||
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
import { buildEquityFromSignals } from '../utils/backtestEquity';
|
||||||
import {
|
import {
|
||||||
formatChartTypeKo,
|
formatChartTypeKo,
|
||||||
@@ -67,6 +72,19 @@ interface Props {
|
|||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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: '주봉' },
|
||||||
|
];
|
||||||
|
|
||||||
export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
||||||
const [tab, setTab] = useState<ExecutionListTab>('backtest');
|
const [tab, setTab] = useState<ExecutionListTab>('backtest');
|
||||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||||
@@ -77,6 +95,13 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
|||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
|
||||||
|
|
||||||
|
// ── 빠른 백테스팅 실행 ──────────────────────────────────────────────────────
|
||||||
|
const [runMarket, setRunMarket] = useState<string>(UPBIT_MARKETS[0]);
|
||||||
|
const [runTimeframe, setRunTimeframe] = useState<Timeframe>('1D');
|
||||||
|
const [runStrategyId, setRunStrategyId] = useState<number | null>(null);
|
||||||
|
const [runLoading, setRunLoading] = useState(false);
|
||||||
|
const [runError, setRunError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
const [leftWidth, setLeftWidth] = useState(() => readMinWidth(LEFT_KEY, LEFT_DEFAULT));
|
||||||
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
const [rightWidth, setRightWidth] = useState(() => readMinWidth(RIGHT_KEY, RIGHT_DEFAULT));
|
||||||
const leftRef = useRef(leftWidth);
|
const leftRef = useRef(leftWidth);
|
||||||
@@ -173,6 +198,49 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
|||||||
await fetchAll();
|
await fetchAll();
|
||||||
}, [records, fetchAll]);
|
}, [records, fetchAll]);
|
||||||
|
|
||||||
|
const handleQuickRun = useCallback(async () => {
|
||||||
|
if (!runStrategyId) {
|
||||||
|
window.alert('전략을 선택해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRunLoading(true);
|
||||||
|
setRunError(null);
|
||||||
|
try {
|
||||||
|
const [settings, barsRes] = await Promise.all([
|
||||||
|
loadBacktestSettings(),
|
||||||
|
fetch(`${API_BASE}/candles/history?${new URLSearchParams({
|
||||||
|
market: runMarket,
|
||||||
|
type: timeframeToCandleType(runTimeframe),
|
||||||
|
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 strategy = strategies.find(s => s.id === runStrategyId);
|
||||||
|
const result = await runBacktest({
|
||||||
|
strategyId: runStrategyId,
|
||||||
|
bars,
|
||||||
|
timeframe: runTimeframe,
|
||||||
|
settings,
|
||||||
|
symbol: runMarket,
|
||||||
|
strategyName: strategy?.name,
|
||||||
|
});
|
||||||
|
if (!result) throw new Error('백테스팅 실행에 실패했습니다.');
|
||||||
|
// 결과 목록 갱신 후 새 결과 자동 선택
|
||||||
|
await fetchAll();
|
||||||
|
setTab('backtest');
|
||||||
|
} catch (e) {
|
||||||
|
setRunError(e instanceof Error ? e.message : '실행 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setRunLoading(false);
|
||||||
|
}
|
||||||
|
}, [runMarket, runTimeframe, runStrategyId, strategies, fetchAll]);
|
||||||
|
|
||||||
const backtestDetail = selectedBacktest ? parseDetail(selectedBacktest) : null;
|
const backtestDetail = selectedBacktest ? parseDetail(selectedBacktest) : null;
|
||||||
|
|
||||||
const activeSignals = useMemo((): BacktestSignal[] => {
|
const activeSignals = useMemo((): BacktestSignal[] => {
|
||||||
@@ -289,7 +357,7 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
|||||||
<span className="btd-header-sub">Golden Analysis Command Center</span>
|
<span className="btd-header-sub">Golden Analysis Command Center</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="btd-loading">분석 데이터 로딩…</div>
|
<div className="btd-loading">분석 데이터 로딩 중…</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -301,6 +369,63 @@ export function BacktestHistoryPage({ theme = 'dark' }: Props) {
|
|||||||
<h1 className="btd-header-title">백테스팅</h1>
|
<h1 className="btd-header-title">백테스팅</h1>
|
||||||
<span className="btd-header-sub">Golden Analysis Command Center</span>
|
<span className="btd-header-sub">Golden Analysis Command Center</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 빠른 실행 툴바 */}
|
||||||
|
<div className="btd-header-run">
|
||||||
|
<select
|
||||||
|
className="btd-run-select"
|
||||||
|
value={runMarket}
|
||||||
|
onChange={e => setRunMarket(e.target.value)}
|
||||||
|
disabled={runLoading}
|
||||||
|
title="종목"
|
||||||
|
>
|
||||||
|
{UPBIT_MARKETS.map(m => (
|
||||||
|
<option key={m} value={m}>{m.replace('KRW-', '')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="btd-run-select"
|
||||||
|
value={runTimeframe}
|
||||||
|
onChange={e => setRunTimeframe(e.target.value as Timeframe)}
|
||||||
|
disabled={runLoading}
|
||||||
|
title="타임프레임"
|
||||||
|
>
|
||||||
|
{QUICK_RUN_TIMEFRAMES.map(tf => (
|
||||||
|
<option key={tf.value} value={tf.value}>{tf.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="btd-run-select btd-run-select--strategy"
|
||||||
|
value={runStrategyId ?? ''}
|
||||||
|
onChange={e => setRunStrategyId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
disabled={runLoading}
|
||||||
|
title="전략"
|
||||||
|
>
|
||||||
|
<option value="">전략 선택</option>
|
||||||
|
{strategies.map(s => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btd-run-btn${runLoading ? ' btd-run-btn--loading' : ''}`}
|
||||||
|
disabled={!runStrategyId || runLoading}
|
||||||
|
onClick={() => void handleQuickRun()}
|
||||||
|
title="백테스팅 실행"
|
||||||
|
>
|
||||||
|
{runLoading ? (
|
||||||
|
<span className="btd-run-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>
|
||||||
|
{runError && (
|
||||||
|
<span className="btd-run-error" title={runError}>⚠ {runError}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="btd-header-actions">
|
<div className="btd-header-actions">
|
||||||
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}>새로고침</button>
|
<button type="button" className="btd-btn btd-btn--ghost" onClick={() => void fetchAll()}>새로고침</button>
|
||||||
{tab === 'backtest' && (
|
{tab === 'backtest' && (
|
||||||
|
|||||||
@@ -838,6 +838,93 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 빠른 백테스팅 실행 툴바 ────────────────────────────────────────────── */
|
||||||
|
.btd-header-run {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-run-select {
|
||||||
|
background: var(--se-bg-elevated, 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;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.btd-run-select:focus {
|
||||||
|
border-color: var(--se-accent, #3d9be9);
|
||||||
|
}
|
||||||
|
.btd-run-select:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btd-run-select--strategy {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-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, border-color 0.15s, transform 0.1s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btd-run-btn:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--se-accent, #3d9be9) 32%, transparent);
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
.btd-run-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.btd-run-btn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btd-run-btn--loading {
|
||||||
|
border-color: var(--se-text-muted);
|
||||||
|
color: var(--se-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-run-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: btd-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes btd-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btd-run-error {
|
||||||
|
font-size: 0.71rem;
|
||||||
|
color: var(--se-sell, #f23645);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
.btd-btn {
|
.btd-btn {
|
||||||
border: 1px solid var(--se-border);
|
border: 1px solid var(--se-border);
|
||||||
background: var(--se-bg-elevated, var(--bg2));
|
background: var(--se-bg-elevated, var(--bg2));
|
||||||
|
|||||||
Reference in New Issue
Block a user