Spaces:
Running
Running
| 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> | |
| ) | |
| } | |