DockerSpace / frontend /src /components /BacktestPanel.tsx
DennisChan0909's picture
feat: walk-forward backtest, US currency, mobile RWD, Gemini news summary
facb33b
Raw
History Blame Contribute Delete
8.6 kB
import React, { useState } from 'react'
import { FlaskConical, TrendingUp, TrendingDown, Minus, CheckCircle, XCircle, Loader2 } from 'lucide-react'
import { BacktestResult, BacktestTrade } from '../api/stockApi'
interface BacktestPanelProps {
stockNo: string
onRun: (months: number) => Promise<void>
result: BacktestResult | null
loading: boolean
error: string | null
currency?: string
}
const StatCard: React.FC<{
label: string
value: string
sub?: string
color?: string
}> = ({ label, value, sub, color = 'text-white' }) => (
<div className="bg-slate-800 rounded-xl p-4 space-y-1">
<p className="text-xs text-slate-400">{label}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
{sub && <p className="text-xs text-slate-500">{sub}</p>}
</div>
)
const SignalBadge: React.FC<{ signal: string }> = ({ signal }) => {
const styles: Record<string, string> = {
BUY: 'bg-green-900/50 text-green-400',
SELL: 'bg-red-900/50 text-red-400',
HOLD: 'bg-slate-700 text-slate-400',
}
return (
<span className={`text-xs font-bold px-2 py-0.5 rounded ${styles[signal] ?? styles.HOLD}`}>
{signal}
</span>
)
}
export const BacktestPanel: React.FC<BacktestPanelProps> = ({
stockNo, onRun, result, loading, error, currency = 'NT$'
}) => {
const [months, setMonths] = useState(12)
const [showAll, setShowAll] = useState(false)
const s = result?.stats
const trades = result?.trades ?? []
const displayed = showAll ? trades : trades.slice(-30).reverse()
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 space-y-5">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<FlaskConical size={18} className="text-indigo-400" />
<h3 className="font-bold text-white">歷史回測</h3>
<span className="text-xs text-slate-500">Walk-Forward(滾動訓練,無未來偷看)</span>
</div>
<div className="flex items-center gap-2">
<select
value={months}
onChange={e => setMonths(Number(e.target.value))}
className="bg-slate-800 border border-slate-700 rounded-lg px-2 py-1.5 text-sm text-white focus:outline-none"
>
<option value={3}>3個月</option>
<option value={6}>6個月</option>
<option value={12}>12個月</option>
<option value={24}>24個月</option>
</select>
<button
onClick={() => onRun(months)}
disabled={loading}
className="flex items-center gap-1.5 bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-800 disabled:cursor-not-allowed text-white rounded-lg px-4 py-1.5 text-sm font-medium transition-colors"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <FlaskConical size={14} />}
開始回測
</button>
</div>
</div>
{error && (
<div className="bg-red-900/30 border border-red-700 rounded-lg p-3 text-red-300 text-sm">
{error}
</div>
)}
{loading && (
<div className="flex items-center gap-3 py-8 justify-center text-slate-400">
<Loader2 size={24} className="animate-spin text-indigo-400" />
<span>正在套用模型到歷史資料...</span>
</div>
)}
{s && !loading && (
<>
{/* Stats cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard
label="買入勝率"
value={`${s.buy_win_rate}%`}
sub={`${s.buy_signals} 次買入信號`}
color={s.buy_win_rate >= 50 ? 'text-green-400' : 'text-red-400'}
/>
<StatCard
label="賣出勝率"
value={`${s.sell_win_rate}%`}
sub={`${s.sell_signals} 次賣出信號`}
color={s.sell_win_rate >= 50 ? 'text-green-400' : 'text-red-400'}
/>
<StatCard
label="買入平均5日報酬"
value={`${s.avg_return_on_buy > 0 ? '+' : ''}${s.avg_return_on_buy}%`}
sub="每次買入信號後5日"
color={s.avg_return_on_buy >= 0 ? 'text-green-400' : 'text-red-400'}
/>
<StatCard
label="累積報酬(全買入)"
value={`${s.total_return_if_followed > 0 ? '+' : ''}${s.total_return_if_followed}%`}
sub={`${result?.months}個月 · ${s.total_days} 交易日`}
color={s.total_return_if_followed >= 0 ? 'text-green-400' : 'text-red-400'}
/>
</div>
{/* Signal distribution */}
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500 text-xs">信號分布:</span>
<span className="flex items-center gap-1.5 text-green-400">
<TrendingUp size={13} /> BUY {s.buy_signals}
</span>
<span className="flex items-center gap-1.5 text-red-400">
<TrendingDown size={13} /> SELL {s.sell_signals}
</span>
<span className="flex items-center gap-1.5 text-slate-400">
<Minus size={13} /> HOLD {s.hold_signals}
</span>
</div>
{/* Trade history table */}
{trades.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-500 font-medium">交易紀錄(最近 {displayed.length} 筆)</p>
<button
onClick={() => setShowAll(v => !v)}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
{showAll ? '收起' : `顯示全部 ${trades.length} 筆`}
</button>
</div>
<div className="overflow-auto max-h-72 rounded-lg border border-slate-800 -mx-1 sm:mx-0">
<table className="w-full text-xs min-w-[480px]">
<thead className="sticky top-0 bg-slate-800 text-slate-400">
<tr>
<th className="text-left px-3 py-2">日期</th>
<th className="text-center px-3 py-2">信號</th>
<th className="text-right px-3 py-2">入場價</th>
<th className="text-right px-3 py-2">5日後</th>
<th className="text-right px-3 py-2">漲跌</th>
<th className="text-center px-3 py-2">結果</th>
</tr>
</thead>
<tbody>
{displayed.map((t: BacktestTrade) => (
<tr key={t.date} className="border-t border-slate-800 hover:bg-slate-800/50">
<td className="px-3 py-1.5 text-slate-400 font-mono">{t.date}</td>
<td className="px-3 py-1.5 text-center">
<SignalBadge signal={t.signal} />
</td>
<td className="px-3 py-1.5 text-right text-white font-mono">
{t.close.toFixed(2)}
</td>
<td className="px-3 py-1.5 text-right text-white font-mono">
{t.future_close.toFixed(2)}
</td>
<td className={`px-3 py-1.5 text-right font-mono font-medium ${
t.actual_return_pct > 0 ? 'text-green-400' :
t.actual_return_pct < 0 ? 'text-red-400' : 'text-slate-400'
}`}>
{t.actual_return_pct > 0 ? '+' : ''}{t.actual_return_pct.toFixed(2)}%
</td>
<td className="px-3 py-1.5 text-center">
{t.signal === 'HOLD' ? (
<Minus size={13} className="text-slate-600 mx-auto" />
) : t.correct ? (
<CheckCircle size={13} className="text-green-400 mx-auto" />
) : (
<XCircle size={13} className="text-red-400 mx-auto" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
)
}