Initial commit with 2026 World Cup Quant Platform core modules and CI/CD

This commit is contained in:
QuantBot
2026-06-13 23:18:18 +08:00
commit 073abf98c1
155 changed files with 19539 additions and 0 deletions

View 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>
);
}