diff --git a/frontend/src/app/backtest/[id]/page.tsx b/frontend/src/app/backtest/[id]/page.tsx new file mode 100644 index 0000000..e38b5b7 --- /dev/null +++ b/frontend/src/app/backtest/[id]/page.tsx @@ -0,0 +1,428 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import Sidebar from '@/components/layout/Sidebar'; +import Header from '@/components/layout/Header'; +import { api } from '@/lib/api'; + +interface User { + id: number; + username: string; +} + +interface BacktestMetrics { + total_return: number; + cagr: number; + mdd: number; + sharpe_ratio: number; + volatility: number; + benchmark_return: number; + excess_return: number; +} + +interface BacktestDetail { + id: number; + strategy_type: string; + strategy_params: Record; + start_date: string; + end_date: string; + rebalance_period: string; + initial_capital: number; + commission_rate: number; + slippage_rate: number; + benchmark: string; + status: string; + created_at: string; + completed_at: string | null; + error_message: string | null; + result: BacktestMetrics | null; +} + +interface EquityCurvePoint { + date: string; + portfolio_value: number; + benchmark_value: number; + drawdown: number; +} + +interface HoldingItem { + ticker: string; + name: string; + weight: number; + shares: number; + price: number; +} + +interface RebalanceHoldings { + rebalance_date: string; + holdings: HoldingItem[]; +} + +interface TransactionItem { + id: number; + date: string; + ticker: string; + action: string; + shares: number; + price: number; + commission: number; +} + +const strategyLabels: Record = { + multi_factor: '멀티 팩터', + quality: '슈퍼 퀄리티', + value_momentum: '밸류 모멘텀', +}; + +const periodLabels: Record = { + monthly: '월별', + quarterly: '분기별', + semi_annual: '반기별', + annual: '연별', +}; + +export default function BacktestDetailPage() { + const router = useRouter(); + const params = useParams(); + const backtestId = params.id as string; + + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [backtest, setBacktest] = useState(null); + const [equityCurve, setEquityCurve] = useState([]); + const [holdings, setHoldings] = useState([]); + const [transactions, setTransactions] = useState([]); + const [activeTab, setActiveTab] = useState<'holdings' | 'transactions'>('holdings'); + const [selectedRebalance, setSelectedRebalance] = useState(null); + + const fetchBacktest = useCallback(async () => { + try { + const data = await api.get(`/api/backtest/${backtestId}`); + setBacktest(data); + + if (data.status === 'completed') { + const [curveData, holdingsData, txData] = await Promise.all([ + api.get(`/api/backtest/${backtestId}/equity-curve`), + api.get(`/api/backtest/${backtestId}/holdings`), + api.get(`/api/backtest/${backtestId}/transactions`), + ]); + setEquityCurve(curveData); + setHoldings(holdingsData); + setTransactions(txData); + if (holdingsData.length > 0) { + setSelectedRebalance(holdingsData[holdingsData.length - 1].rebalance_date); + } + } + } catch (err) { + console.error('Failed to fetch backtest:', err); + } + }, [backtestId]); + + useEffect(() => { + const init = async () => { + try { + const userData = await api.getCurrentUser() as User; + setUser(userData); + await fetchBacktest(); + } catch { + router.push('/login'); + } finally { + setLoading(false); + } + }; + init(); + }, [router, fetchBacktest]); + + // Poll for status if pending/running + useEffect(() => { + if (!backtest) return; + if (backtest.status === 'pending' || backtest.status === 'running') { + const interval = setInterval(() => { + fetchBacktest(); + }, 3000); + return () => clearInterval(interval); + } + }, [backtest, fetchBacktest]); + + const formatNumber = (value: number | null | undefined, decimals: number = 2) => { + if (value === null || value === undefined) return '-'; + return value.toFixed(decimals); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('ko-KR').format(value); + }; + + const getStatusBadge = (status: string) => { + const styles: Record = { + pending: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + }; + const labels: Record = { + pending: '대기중', + running: '실행중', + completed: '완료', + failed: '실패', + }; + return ( + + {labels[status] || status} + + ); + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + if (!backtest) { + return ( +
+
Backtest not found
+
+ ); + } + + const selectedHoldings = holdings.find((h) => h.rebalance_date === selectedRebalance); + + return ( +
+ +
+
+
+ {/* Header */} +
+
+

+ {strategyLabels[backtest.strategy_type] || backtest.strategy_type} 백테스트 +

+

+ {backtest.start_date} ~ {backtest.end_date} | {periodLabels[backtest.rebalance_period]} +

+
+
+ {getStatusBadge(backtest.status)} +
+
+ + {/* Status Messages */} + {backtest.status === 'pending' && ( +
+ 백테스트가 대기 중입니다... +
+ )} + + {backtest.status === 'running' && ( +
+ 백테스트가 실행 중입니다... 잠시만 기다려주세요. +
+ )} + + {backtest.status === 'failed' && ( +
+ 백테스트 실패: {backtest.error_message} +
+ )} + + {/* Results (only show when completed) */} + {backtest.status === 'completed' && backtest.result && ( + <> + {/* Metrics Cards */} +
+
+
총 수익률
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(backtest.result.total_return)}% +
+
+
+
CAGR
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(backtest.result.cagr)}% +
+
+
+
MDD
+
+ {formatNumber(backtest.result.mdd)}% +
+
+
+
샤프 비율
+
+ {formatNumber(backtest.result.sharpe_ratio)} +
+
+
+
변동성
+
+ {formatNumber(backtest.result.volatility)}% +
+
+
+
벤치마크
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(backtest.result.benchmark_return)}% +
+
+
+
초과 수익
+
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatNumber(backtest.result.excess_return)}% +
+
+
+ + {/* Equity Curve */} +
+

자산 추이

+
+ {equityCurve.length > 0 ? ( +
+
+ 시작: {formatCurrency(equityCurve[0]?.portfolio_value || 0)}원 + 종료: {formatCurrency(equityCurve[equityCurve.length - 1]?.portfolio_value || 0)}원 +
+
+ (차트 라이브러리 연동 필요 - {equityCurve.length}개 데이터 포인트) +
+
+ ) : ( + '데이터 없음' + )} +
+
+ + {/* Tabs */} +
+
+ +
+ + {/* Holdings Tab */} + {activeTab === 'holdings' && ( +
+
+ + +
+ {selectedHoldings && ( + + + + + + + + + + + {selectedHoldings.holdings.map((h) => ( + + + + + + + ))} + +
종목비중수량가격
+
{h.ticker}
+
{h.name}
+
{formatNumber(h.weight)}%{formatCurrency(h.shares)}{formatCurrency(h.price)}
+ )} +
+ )} + + {/* Transactions Tab */} + {activeTab === 'transactions' && ( +
+ + + + + + + + + + + + + {transactions.map((t) => ( + + + + + + + + + ))} + {transactions.length === 0 && ( + + + + )} + +
날짜종목구분수량가격수수료
{t.date}{t.ticker} + + {t.action === 'buy' ? '매수' : '매도'} + + {formatCurrency(t.shares)}{formatCurrency(t.price)}{formatCurrency(t.commission)}
+ 거래 내역이 없습니다. +
+
+ )} +
+ + )} +
+
+
+ ); +} diff --git a/frontend/src/app/backtest/page.tsx b/frontend/src/app/backtest/page.tsx new file mode 100644 index 0000000..f05813c --- /dev/null +++ b/frontend/src/app/backtest/page.tsx @@ -0,0 +1,502 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import Sidebar from '@/components/layout/Sidebar'; +import Header from '@/components/layout/Header'; +import { api } from '@/lib/api'; + +interface User { + id: number; + username: string; +} + +interface BacktestListItem { + id: number; + strategy_type: string; + start_date: string; + end_date: string; + rebalance_period: string; + status: string; + created_at: string; + total_return: number | null; + cagr: number | null; + mdd: number | null; +} + +const strategyOptions = [ + { value: 'multi_factor', label: '멀티 팩터' }, + { value: 'quality', label: '슈퍼 퀄리티' }, + { value: 'value_momentum', label: '밸류 모멘텀' }, +]; + +const periodOptions = [ + { value: 'monthly', label: '월별' }, + { value: 'quarterly', label: '분기별' }, + { value: 'semi_annual', label: '반기별' }, + { value: 'annual', label: '연별' }, +]; + +export default function BacktestPage() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [backtests, setBacktests] = useState([]); + const [error, setError] = useState(null); + + // Form state + const [strategyType, setStrategyType] = useState('multi_factor'); + const [startDate, setStartDate] = useState('2020-01-01'); + const [endDate, setEndDate] = useState('2024-12-31'); + const [rebalancePeriod, setRebalancePeriod] = useState('quarterly'); + const [initialCapital, setInitialCapital] = useState(100000000); + const [topN, setTopN] = useState(30); + const [showAdvanced, setShowAdvanced] = useState(false); + const [commissionRate, setCommissionRate] = useState(0.00015); + const [slippageRate, setSlippageRate] = useState(0.001); + + // Strategy-specific params + const [valueWeight, setValueWeight] = useState(0.33); + const [qualityWeight, setQualityWeight] = useState(0.33); + const [momentumWeight, setMomentumWeight] = useState(0.34); + const [minFscore, setMinFscore] = useState(7); + const [vmValueWeight, setVmValueWeight] = useState(0.5); + const [vmMomentumWeight, setVmMomentumWeight] = useState(0.5); + + useEffect(() => { + const init = async () => { + try { + const userData = await api.getCurrentUser() as User; + setUser(userData); + await fetchBacktests(); + } catch { + router.push('/login'); + } finally { + setLoading(false); + } + }; + init(); + }, [router]); + + const fetchBacktests = async () => { + try { + const data = await api.get('/api/backtest'); + setBacktests(data); + } catch (err) { + console.error('Failed to fetch backtests:', err); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + setError(null); + + try { + let strategyParams = {}; + if (strategyType === 'multi_factor') { + strategyParams = { + weights: { + value: valueWeight, + quality: qualityWeight, + momentum: momentumWeight, + low_vol: 0, + }, + }; + } else if (strategyType === 'quality') { + strategyParams = { min_fscore: minFscore }; + } else if (strategyType === 'value_momentum') { + strategyParams = { + value_weight: vmValueWeight, + momentum_weight: vmMomentumWeight, + }; + } + + const response = await api.post<{ id: number }>('/api/backtest', { + strategy_type: strategyType, + strategy_params: strategyParams, + start_date: startDate, + end_date: endDate, + rebalance_period: rebalancePeriod, + initial_capital: initialCapital, + commission_rate: commissionRate, + slippage_rate: slippageRate, + top_n: topN, + }); + + router.push(`/backtest/${response.id}`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create backtest'); + } finally { + setSubmitting(false); + } + }; + + const getStatusBadge = (status: string) => { + const styles: Record = { + pending: 'bg-yellow-100 text-yellow-800', + running: 'bg-blue-100 text-blue-800', + completed: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + }; + const labels: Record = { + pending: '대기중', + running: '실행중', + completed: '완료', + failed: '실패', + }; + return ( + + {labels[status] || status} + + ); + }; + + const formatNumber = (value: number | null, decimals: number = 2) => { + if (value === null) return '-'; + return value.toFixed(decimals); + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('ko-KR').format(value); + }; + + if (loading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+ +
+
+
+

백테스트

+ + {error && ( +
+ {error} +
+ )} + + {/* New Backtest Form */} +
+

새 백테스트

+
+
+
+ + +
+
+ + setStartDate(e.target.value)} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setEndDate(e.target.value)} + className="w-full px-3 py-2 border rounded" + /> +
+
+ +
+
+ + +
+
+ + setInitialCapital(parseInt(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setTopN(parseInt(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + {/* Strategy-specific params */} + {strategyType === 'multi_factor' && ( +
+
+ + setValueWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setQualityWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setMomentumWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ )} + + {strategyType === 'quality' && ( +
+ + setMinFscore(parseInt(e.target.value))} + className="w-32 px-3 py-2 border rounded" + /> +
+ )} + + {strategyType === 'value_momentum' && ( +
+
+ + setVmValueWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setVmMomentumWeight(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ )} + + {/* Advanced options */} +
+ +
+ + {showAdvanced && ( +
+
+ + setCommissionRate(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ + setSlippageRate(parseFloat(e.target.value))} + className="w-full px-3 py-2 border rounded" + /> +
+
+ )} + + +
+
+ + {/* Backtest List */} +
+
+

백테스트 목록

+
+
+ + + + + + + + + + + + + + + {backtests.map((bt) => ( + + + + + + + + + + + ))} + {backtests.length === 0 && ( + + + + )} + +
전략기간주기수익률CAGRMDD상태생성일
+ + {strategyOptions.find((s) => s.value === bt.strategy_type)?.label || bt.strategy_type} + + + {bt.start_date} ~ {bt.end_date} + + {periodOptions.find((p) => p.value === bt.rebalance_period)?.label || bt.rebalance_period} + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {bt.total_return !== null ? `${formatNumber(bt.total_return)}%` : '-'} + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {bt.cagr !== null ? `${formatNumber(bt.cagr)}%` : '-'} + + {bt.mdd !== null ? `${formatNumber(bt.mdd)}%` : '-'} + + {getStatusBadge(bt.status)} + + {new Date(bt.created_at).toLocaleDateString('ko-KR')} +
+ 아직 백테스트가 없습니다. +
+
+
+
+
+
+ ); +}