'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({ 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((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 (

自訂策略回測引擎

策略條件設定

樣本數

{result.matched}

勝率

{result.win_rate.toFixed(1)}%

ROI

{result.roi_percent.toFixed(2)}%

命中:{result.hit_count} / {result.total} {' '} | 最終資金:{result.final_capital.toFixed(2)} | 淨利:{result.net_profit.toFixed(2)}

{loading ?

回測引擎計算中…

: null} {errorMessage ?

{errorMessage}

: null}

交易明細(依條件)

    {filtered.map((trade) => (
  • {trade.id}|{trade.date}|{trade.market}|讓球 {trade.handicap}|海拔 {trade.altitude}m| {trade.isWin ? '勝' : '敗'}|賠率 {trade.odds}
  • ))} {filtered.length === 0 ?
  • 此條件下目前無筆交易
  • : null}
); }