278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useMemo, useState } from 'react';
|
||
import { EquityCurveChart } from '@/components/EquityCurveChart';
|
||
import { runBacktest, type BacktestResponse, type BacktestTrade } from '@/lib/analytics-api';
|
||
|
||
type TradeRecord = {
|
||
id: string;
|
||
date: string;
|
||
odds: number;
|
||
isWin: boolean;
|
||
stake: number;
|
||
altitude: number;
|
||
handicap: number;
|
||
weather: string;
|
||
recentWinRate: number;
|
||
market: string;
|
||
};
|
||
|
||
const history: TradeRecord[] = [
|
||
{ id: 'T-001', date: '2026-01-05', odds: 1.95, isWin: true, stake: 100, altitude: 1700, handicap: -1, weather: '乾燥', recentWinRate: 0.56, market: '讓球' },
|
||
{ id: 'T-002', date: '2026-01-10', odds: 2.1, isWin: false, stake: 100, altitude: 1200, handicap: -0.5, weather: '潮濕', recentWinRate: 0.49, market: '讓球' },
|
||
{ id: 'T-003', date: '2026-01-12', odds: 1.82, isWin: true, stake: 100, altitude: 1600, handicap: 0, weather: '高溫', recentWinRate: 0.68, market: '大小分' },
|
||
{ id: 'T-004', date: '2026-01-19', odds: 2.25, isWin: true, stake: 100, altitude: 2100, handicap: -1.5, weather: '乾燥', recentWinRate: 0.62, market: '讓球' },
|
||
{ id: 'T-005', date: '2026-01-27', odds: 1.74, isWin: false, stake: 100, altitude: 2200, handicap: 0.5, weather: '低溫', recentWinRate: 0.44, market: '1x2' },
|
||
{ id: 'T-006', date: '2026-02-01', odds: 2.02, isWin: true, stake: 100, altitude: 1100, handicap: -0.5, weather: '中高濕', recentWinRate: 0.58, market: '讓球' },
|
||
{ id: 'T-007', date: '2026-02-09', odds: 1.91, isWin: true, stake: 100, altitude: 1800, handicap: -1, weather: '乾燥', recentWinRate: 0.61, market: '讓球' },
|
||
];
|
||
|
||
function toLocalISOString(date: string): string {
|
||
return new Date(`${date}T15:00:00+08:00`).toISOString();
|
||
}
|
||
|
||
function fallbackRun(trades: TradeRecord[]) {
|
||
let capital = 10000;
|
||
const points: { ts: string; capital: number }[] = [{ ts: 'start', capital }];
|
||
let wins = 0;
|
||
|
||
for (const trade of trades) {
|
||
const pnl = trade.isWin ? trade.stake * (trade.odds - 1) : -trade.stake;
|
||
capital += pnl;
|
||
if (trade.isWin) wins += 1;
|
||
points.push({ ts: trade.date, capital: Number(capital.toFixed(2)) });
|
||
}
|
||
|
||
const hitRate = trades.length > 0 ? (wins / trades.length) * 100 : 0;
|
||
const stakeTotal = trades.length * 100;
|
||
const roi = stakeTotal > 0 ? ((capital - 10000) / stakeTotal) * 100 : 0;
|
||
const maxDrawdown = points.reduce((acc, point, idx) => {
|
||
const maxBefore = Math.max(...points.slice(0, idx + 1).map((item) => item.capital));
|
||
const dd = ((maxBefore - point.capital) / maxBefore) * 100;
|
||
return Math.max(acc, dd);
|
||
}, 0);
|
||
|
||
return {
|
||
matched: trades.length,
|
||
total: trades.length,
|
||
hit_count: wins,
|
||
win_rate: Number(hitRate.toFixed(4)),
|
||
final_capital: Number(capital.toFixed(4)),
|
||
net_profit: Number((capital - 10000).toFixed(4)),
|
||
roi_percent: Number(roi.toFixed(4)),
|
||
max_drawdown_percent: Number(maxDrawdown.toFixed(4)),
|
||
equity_curve: points,
|
||
} as BacktestResponse;
|
||
}
|
||
|
||
export default function BacktestingPage() {
|
||
const [altMin, setAltMin] = useState(1400);
|
||
const [altMax, setAltMax] = useState(2200);
|
||
const [handicapMin, setHandicapMin] = useState(-2);
|
||
const [handicapMax, setHandicapMax] = useState(0);
|
||
const [weather, setWeather] = useState('全部');
|
||
const [minRecentWinRate, setMinRecentWinRate] = useState(0.5);
|
||
const [loading, setLoading] = useState(true);
|
||
const [errorMessage, setErrorMessage] = useState('');
|
||
const [result, setResult] = useState<BacktestResponse>({
|
||
matched: 0,
|
||
total: 0,
|
||
hit_count: 0,
|
||
win_rate: 0,
|
||
final_capital: 10000,
|
||
net_profit: 0,
|
||
roi_percent: 0,
|
||
max_drawdown_percent: 0,
|
||
equity_curve: [{ ts: 'start', capital: 10000 }],
|
||
});
|
||
|
||
const filtered = useMemo(() => {
|
||
return history.filter((trade) => {
|
||
if (trade.altitude < altMin || trade.altitude > altMax) return false;
|
||
if (trade.handicap < handicapMin || trade.handicap > handicapMax) return false;
|
||
if (weather !== '全部' && trade.weather !== weather) return false;
|
||
if (trade.recentWinRate < minRecentWinRate) return false;
|
||
return true;
|
||
});
|
||
}, [altMin, altMax, handicapMin, handicapMax, weather, minRecentWinRate]);
|
||
|
||
const requestPayload = useMemo(
|
||
() => ({
|
||
initial_capital: 10000,
|
||
strategy: {
|
||
weather: weather === '全部' ? null : weather,
|
||
altitude_min_meters: altMin,
|
||
altitude_max_meters: altMax,
|
||
handicap_min: handicapMin,
|
||
handicap_max: handicapMax,
|
||
recent_win_rate_min: minRecentWinRate,
|
||
recent_win_rate_max: null,
|
||
market_types: null,
|
||
start_at: null,
|
||
end_at: null,
|
||
},
|
||
historical_trades: history.map<BacktestTrade>((trade) => ({
|
||
trade_id: trade.id,
|
||
settled_at: toLocalISOString(trade.date),
|
||
odds: trade.odds,
|
||
is_win: trade.isWin,
|
||
stake: trade.stake,
|
||
altitude_meters: trade.altitude,
|
||
handicap: trade.handicap,
|
||
weather: trade.weather,
|
||
recent_form_win_rate: trade.recentWinRate,
|
||
market_type: trade.market,
|
||
selection: 'home',
|
||
})),
|
||
}),
|
||
[altMin, altMax, handicapMin, handicapMax, weather, minRecentWinRate],
|
||
);
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
setLoading(true);
|
||
|
||
const execute = async () => {
|
||
try {
|
||
const data = await runBacktest(requestPayload);
|
||
if (active) {
|
||
setResult(data);
|
||
setErrorMessage('');
|
||
}
|
||
} catch (error) {
|
||
if (!active) return;
|
||
setErrorMessage(error instanceof Error ? error.message : '回測服務無法連線,改用本機試算');
|
||
setResult(fallbackRun(filtered));
|
||
} finally {
|
||
if (active) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
execute();
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [filtered, requestPayload]);
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<h2 className="dot-matrix text-2xl text-[#7d2a15]">自訂策略回測引擎</h2>
|
||
|
||
<section className="panel-glow rounded-2xl p-4">
|
||
<h3 className="dot-matrix text-lg text-[#7d2a15]">策略條件設定</h3>
|
||
<div className="mt-3 grid gap-3 md:grid-cols-2">
|
||
<label className="text-sm text-[#7a5b46]">
|
||
海拔下限(m)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
value={altMin}
|
||
onChange={(event) => setAltMin(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
海拔上限(m)
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
value={altMax}
|
||
onChange={(event) => setAltMax(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
讓球下限
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
value={handicapMin}
|
||
onChange={(event) => setHandicapMin(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
讓球上限
|
||
<input
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
type="number"
|
||
value={handicapMax}
|
||
onChange={(event) => setHandicapMax(Number(event.target.value))}
|
||
/>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
天氣
|
||
<select
|
||
className="mt-2 w-full rounded-lg border border-[#e0c6a8] bg-white/70 px-3 py-2"
|
||
value={weather}
|
||
onChange={(event) => setWeather(event.target.value)}
|
||
>
|
||
<option value="全部">全部</option>
|
||
<option value="乾燥">乾燥</option>
|
||
<option value="潮濕">潮濕</option>
|
||
<option value="中高濕">中高濕</option>
|
||
</select>
|
||
</label>
|
||
<label className="text-sm text-[#7a5b46]">
|
||
近期勝率下限
|
||
<input
|
||
className="mt-2 w-full"
|
||
type="range"
|
||
min={0}
|
||
max={1}
|
||
step={0.01}
|
||
value={minRecentWinRate}
|
||
onChange={(event) => setMinRecentWinRate(Number(event.target.value))}
|
||
/>
|
||
<p className="text-xs text-[#7a5b46]">{(minRecentWinRate * 100).toFixed(0)}%</p>
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid gap-4 md:grid-cols-3">
|
||
<article className="panel-glow rounded-2xl p-4">
|
||
<p className="text-sm text-[#7a5b46]">樣本數</p>
|
||
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.matched}</p>
|
||
</article>
|
||
<article className="panel-glow rounded-2xl p-4">
|
||
<p className="text-sm text-[#7a5b46]">勝率</p>
|
||
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.win_rate.toFixed(1)}%</p>
|
||
</article>
|
||
<article className="panel-glow rounded-2xl p-4">
|
||
<p className="text-sm text-[#7a5b46]">ROI</p>
|
||
<p className="mt-2 text-2xl font-semibold text-[#7d2a15]">{result.roi_percent.toFixed(2)}%</p>
|
||
</article>
|
||
</section>
|
||
|
||
<section className="panel-glow rounded-2xl p-4">
|
||
<p className="dot-matrix text-sm text-[#7d2a15]">
|
||
命中:{result.hit_count} / {result.total}
|
||
{' '}
|
||
| 最終資金:{result.final_capital.toFixed(2)}
|
||
| 淨利:{result.net_profit.toFixed(2)}
|
||
</p>
|
||
{loading ? <p className="mt-1 text-xs text-[#a16b4f]">回測引擎計算中…</p> : null}
|
||
{errorMessage ? <p className="mt-1 text-xs text-[#8c2f2f]">{errorMessage}</p> : null}
|
||
</section>
|
||
|
||
<EquityCurveChart
|
||
title="資金成長曲線(Equity Curve)"
|
||
points={result.equity_curve}
|
||
maxDrawdown={result.max_drawdown_percent}
|
||
/>
|
||
|
||
<section className="panel-glow rounded-2xl p-4">
|
||
<h3 className="dot-matrix text-lg text-[#7d2a15]">交易明細(依條件)</h3>
|
||
<ul className="mt-3 space-y-2 text-sm text-[#7a5b46]">
|
||
{filtered.map((trade) => (
|
||
<li key={trade.id} className="rounded-lg bg-white/70 p-3">
|
||
{trade.id}|{trade.date}|{trade.market}|讓球 {trade.handicap}|海拔 {trade.altitude}m|
|
||
{trade.isWin ? '勝' : '敗'}|賠率 {trade.odds}
|
||
</li>
|
||
))}
|
||
{filtered.length === 0 ? <li className="rounded-lg bg-white/70 p-3">此條件下目前無筆交易</li> : null}
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|