Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
277
platform/web/app/backtesting/page.tsx
Normal file
277
platform/web/app/backtesting/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user