feat: Add backtest UI with ML training data generation and strategy testing
This commit is contained in:
417
frontend/src/pages/Backtest.tsx
Normal file
417
frontend/src/pages/Backtest.tsx
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import api from '../lib/api'
|
||||||
|
import HtsPage from '../components/HtsPage'
|
||||||
|
import { Download, Play, TrendingUp, Database, BarChart3, AlertCircle } from 'lucide-react'
|
||||||
|
import { Line } from 'react-chartjs-2'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend)
|
||||||
|
|
||||||
|
// ── 타입 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface BacktestResult {
|
||||||
|
total_return_pct: number
|
||||||
|
mdd_pct: number
|
||||||
|
win_rate: number
|
||||||
|
sharpe_ratio: number
|
||||||
|
total_trades: number
|
||||||
|
equity_curve: Array<{ date: string; value: number; return_pct: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ML 학습 데이터 생성 탭 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MLTrainingTab() {
|
||||||
|
const [stockCodes, setStockCodes] = useState('005930,000660,035720') // 삼성전자, SK하이닉스, 카카오
|
||||||
|
const [startDate, setStartDate] = useState('2023-01-01')
|
||||||
|
const [endDate, setEndDate] = useState('2025-12-31')
|
||||||
|
const [forwardDays, setForwardDays] = useState(5)
|
||||||
|
const [upThreshold, setUpThreshold] = useState(3.0)
|
||||||
|
const [downThreshold, setDownThreshold] = useState(-3.0)
|
||||||
|
const [result, setResult] = useState<any>(null)
|
||||||
|
|
||||||
|
const generateMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await api.post('/backtest/generate-training-data', {
|
||||||
|
stock_codes: stockCodes.split(',').map(c => c.trim()),
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
forward_days: forwardDays,
|
||||||
|
up_threshold: upThreshold / 100,
|
||||||
|
down_threshold: downThreshold / 100,
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setResult(data)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const trainMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await api.post('/backtest/train-model')
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
const res = await api.get('/backtest/download-training-data', { responseType: 'blob' })
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data]))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.setAttribute('download', 'training_data.csv')
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* 설정 */}
|
||||||
|
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<Database size={20} className="text-blue-400" />
|
||||||
|
ML 학습 데이터 생성 설정
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">종목 코드 (쉼표 구분)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={stockCodes}
|
||||||
|
onChange={(e) => setStockCodes(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
placeholder="005930,000660,035720"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">시작일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">종료일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">미래 예측 기간 (N일 후)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={forwardDays}
|
||||||
|
onChange={(e) => setForwardDays(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">UP 레이블 임계값 (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={upThreshold}
|
||||||
|
onChange={(e) => setUpThreshold(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">DOWN 레이블 임계값 (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={downThreshold}
|
||||||
|
onChange={(e) => setDownThreshold(Number(e.target.value))}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-5">
|
||||||
|
<button
|
||||||
|
onClick={() => generateMutation.mutate()}
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
{generateMutation.isPending ? '생성 중...' : '데이터 생성'}
|
||||||
|
</button>
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium"
|
||||||
|
>
|
||||||
|
<Download size={16} />
|
||||||
|
CSV 다운로드
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => trainMutation.mutate()}
|
||||||
|
disabled={trainMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<TrendingUp size={16} />
|
||||||
|
{trainMutation.isPending ? '학습 중...' : '모델 학습 시작'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 */}
|
||||||
|
{result && (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4">생성 결과</h3>
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-slate-800 rounded-lg p-4">
|
||||||
|
<p className="text-xs text-slate-400">총 샘플 수</p>
|
||||||
|
<p className="text-2xl font-bold text-white mt-1">{result.total_samples?.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
{Object.entries(result.label_distribution || {}).map(([label, count]: any) => (
|
||||||
|
<div key={label} className="bg-slate-800 rounded-lg p-4">
|
||||||
|
<p className="text-xs text-slate-400">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-white mt-1">{count.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
{((count / result.total_samples) * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg text-sm text-blue-400">
|
||||||
|
<AlertCircle size={14} className="inline mr-2" />
|
||||||
|
{result.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 전략 성과 테스트 탭 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function StrategyTestTab() {
|
||||||
|
const [stockCodes, setStockCodes] = useState('005930,000660,035720')
|
||||||
|
const [startDate, setStartDate] = useState('2024-01-01')
|
||||||
|
const [endDate, setEndDate] = useState('2024-12-31')
|
||||||
|
const [compareResults, setCompareResults] = useState<any>(null)
|
||||||
|
|
||||||
|
const compareMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const res = await api.post('/backtest/compare-presets', {
|
||||||
|
stock_codes: stockCodes.split(',').map(c => c.trim()),
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate,
|
||||||
|
presets: ['conservative', 'balanced', 'aggressive'],
|
||||||
|
})
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCompareResults(data)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartData = compareResults ? {
|
||||||
|
labels: compareResults.presets?.conservative?.equity_curve?.map((p: any) => p.date) || [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '보수적',
|
||||||
|
data: compareResults.presets?.conservative?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
|
||||||
|
borderColor: 'rgb(34, 197, 94)',
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '균형형',
|
||||||
|
data: compareResults.presets?.balanced?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
|
||||||
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '공격적',
|
||||||
|
data: compareResults.presets?.aggressive?.equity_curve?.map((p: any) => p.return_pct * 100) || [],
|
||||||
|
borderColor: 'rgb(239, 68, 68)',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* 설정 */}
|
||||||
|
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||||
|
<BarChart3 size={20} className="text-purple-400" />
|
||||||
|
프리셋 비교 백테스트
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">종목 코드</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={stockCodes}
|
||||||
|
onChange={(e) => setStockCodes(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">시작일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-slate-400 mb-1">종료일</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => compareMutation.mutate()}
|
||||||
|
disabled={compareMutation.isPending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium mt-4 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Play size={16} />
|
||||||
|
{compareMutation.isPending ? '실행 중...' : '백테스트 실행'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 비교 */}
|
||||||
|
{compareResults && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{Object.entries(compareResults.presets || {}).map(([key, result]: any) => (
|
||||||
|
<div key={key} className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||||
|
<h4 className="text-lg font-bold text-white mb-4">{result.name}</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">수익률</p>
|
||||||
|
<p className={`text-2xl font-bold ${
|
||||||
|
result.total_return_pct > 0 ? 'text-green-400' : 'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{(result.total_return_pct * 100).toFixed(2)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">MDD</p>
|
||||||
|
<p className="text-lg text-red-400">{(result.mdd_pct * 100).toFixed(2)}%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">승률</p>
|
||||||
|
<p className="text-lg text-white">{(result.win_rate * 100).toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Sharpe</p>
|
||||||
|
<p className="text-lg text-white">{result.sharpe_ratio.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">거래 횟수</p>
|
||||||
|
<p className="text-lg text-white">{result.total_trades}회</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 차트 */}
|
||||||
|
{chartData && (
|
||||||
|
<div className="bg-slate-900/60 border border-slate-800 rounded-xl p-5">
|
||||||
|
<h3 className="text-lg font-bold text-white mb-4">수익 곱선 비교</h3>
|
||||||
|
<Line
|
||||||
|
data={chartData}
|
||||||
|
options={{
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'top' },
|
||||||
|
title: { display: false },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
title: { display: true, text: '수익률 (%)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 메인 컴포넌트 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Backtest = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'ml' | 'strategy'>('ml')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HtsPage>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white">백테스트</h2>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
ML 학습 데이터 생성 및 전략 성과 검증
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-2 border-b border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('ml')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'ml'
|
||||||
|
? 'text-blue-400 border-b-2 border-blue-400'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Database size={16} className="inline mr-2" />
|
||||||
|
ML 학습 데이터 생성
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('strategy')}
|
||||||
|
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'strategy'
|
||||||
|
? 'text-purple-400 border-b-2 border-purple-400'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<TrendingUp size={16} className="inline mr-2" />
|
||||||
|
전략 성과 테스트
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컨텐츠 */}
|
||||||
|
{activeTab === 'ml' ? <MLTrainingTab /> : <StrategyTestTab />}
|
||||||
|
</div>
|
||||||
|
</HtsPage>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Backtest
|
||||||
Reference in New Issue
Block a user