Spaces:
Running
Running
File size: 8,601 Bytes
d9d92a7 facb33b d9d92a7 facb33b d9d92a7 facb33b d9d92a7 facb33b d9d92a7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | 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>
)
}
|