feat: Add backtest UI with ML training data generation and strategy testing

This commit is contained in:
2026-03-06 21:28:16 +09:00
parent 7a31651cc5
commit 38ba5bfbb0

View 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