Refocus on USD/JPY and add walk-forward backtester with regime filter
- Strip multi-pair plumbing; drop synthetic-data fallback - Add BacktestConfig + walk-forward replay through the live signal pipeline - Add metrics module (PF, Sharpe, Sortino, max DD, trade journal) - Add parallel grid optimizer with disk parquet cache - Add H4 ADX regime gate (--regime-filter, --regime-min-adx) — cuts chop losses ~84% in 2023 H1 while preserving trend edge Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
ForexBot2 — Backtest Metrics
|
||||
==============================
|
||||
Pure functions over Trade records & equity curves.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
"""A single closed trade."""
|
||||
entry_time: pd.Timestamp
|
||||
exit_time: pd.Timestamp
|
||||
direction: str # "BUY" / "SELL"
|
||||
entry_price: float
|
||||
exit_price: float
|
||||
stop_loss: float
|
||||
take_profit: float
|
||||
exit_reason: str # "tp" / "sl" / "end_of_data"
|
||||
risk_pips: float
|
||||
pnl_pips: float
|
||||
pnl_pct: float # P&L as % of equity at trade open
|
||||
pnl_currency: float # P&L in account currency (units = balance)
|
||||
duration_bars: int
|
||||
grade: str # signal grade at entry
|
||||
score_at_entry: float
|
||||
confidence_at_entry: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestResult:
|
||||
"""Complete backtest output."""
|
||||
trades: List[Trade] = field(default_factory=list)
|
||||
equity_curve: pd.Series = field(default_factory=lambda: pd.Series(dtype=float))
|
||||
initial_balance: float = 0.0
|
||||
final_balance: float = 0.0
|
||||
metrics: Dict[str, float] = field(default_factory=dict)
|
||||
params: Dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def compute_metrics(
|
||||
trades: List[Trade],
|
||||
equity_curve: pd.Series,
|
||||
initial_balance: float,
|
||||
elapsed_days: Optional[float] = None,
|
||||
) -> Dict[str, float]:
|
||||
"""All summary metrics from a list of closed trades + equity curve."""
|
||||
if not trades:
|
||||
return {
|
||||
"total_trades": 0,
|
||||
"win_rate": 0.0,
|
||||
"profit_factor": 0.0,
|
||||
"expectancy_per_trade": 0.0,
|
||||
"expectancy_pips": 0.0,
|
||||
"total_return_pct": 0.0,
|
||||
"cagr": 0.0,
|
||||
"max_drawdown_pct": 0.0,
|
||||
"sharpe": 0.0,
|
||||
"sortino": 0.0,
|
||||
"avg_trade_duration_bars": 0.0,
|
||||
"longest_winning_streak": 0,
|
||||
"longest_losing_streak": 0,
|
||||
"final_balance": initial_balance,
|
||||
}
|
||||
|
||||
pnl_currency = np.array([t.pnl_currency for t in trades])
|
||||
pnl_pips = np.array([t.pnl_pips for t in trades])
|
||||
|
||||
wins = pnl_currency > 0
|
||||
losses = pnl_currency < 0
|
||||
|
||||
total_wins = pnl_currency[wins].sum() if wins.any() else 0.0
|
||||
total_losses = -pnl_currency[losses].sum() if losses.any() else 0.0
|
||||
|
||||
win_rate = wins.sum() / len(trades) * 100
|
||||
profit_factor = (total_wins / total_losses) if total_losses > 0 else float("inf")
|
||||
expectancy_per_trade = pnl_currency.mean()
|
||||
expectancy_pips = pnl_pips.mean()
|
||||
|
||||
final_balance = float(equity_curve.iloc[-1]) if len(equity_curve) else initial_balance
|
||||
total_return_pct = (final_balance / initial_balance - 1) * 100
|
||||
|
||||
# CAGR
|
||||
if elapsed_days and elapsed_days > 0 and final_balance > 0:
|
||||
years = elapsed_days / 365.25
|
||||
cagr = ((final_balance / initial_balance) ** (1 / years) - 1) * 100 if years > 0 else 0.0
|
||||
else:
|
||||
cagr = 0.0
|
||||
|
||||
# Max drawdown
|
||||
if len(equity_curve):
|
||||
running_max = equity_curve.cummax()
|
||||
drawdown = (equity_curve - running_max) / running_max
|
||||
max_dd = float(drawdown.min()) * 100
|
||||
else:
|
||||
max_dd = 0.0
|
||||
|
||||
# Sharpe / Sortino — daily returns from equity curve
|
||||
sharpe, sortino = 0.0, 0.0
|
||||
if len(equity_curve) > 2:
|
||||
daily = equity_curve.resample("1D").last().ffill().pct_change().dropna()
|
||||
if len(daily) > 1 and daily.std() > 0:
|
||||
sharpe = float(daily.mean() / daily.std() * np.sqrt(252))
|
||||
downside = daily[daily < 0]
|
||||
if len(downside) > 1 and downside.std() > 0:
|
||||
sortino = float(daily.mean() / downside.std() * np.sqrt(252))
|
||||
|
||||
# Streaks
|
||||
win_loss_seq = [1 if t.pnl_currency > 0 else (-1 if t.pnl_currency < 0 else 0) for t in trades]
|
||||
longest_win = _longest_streak(win_loss_seq, 1)
|
||||
longest_loss = _longest_streak(win_loss_seq, -1)
|
||||
|
||||
avg_duration = float(np.mean([t.duration_bars for t in trades]))
|
||||
|
||||
return {
|
||||
"total_trades": len(trades),
|
||||
"wins": int(wins.sum()),
|
||||
"losses": int(losses.sum()),
|
||||
"win_rate": float(win_rate),
|
||||
"profit_factor": float(profit_factor),
|
||||
"expectancy_per_trade": float(expectancy_per_trade),
|
||||
"expectancy_pips": float(expectancy_pips),
|
||||
"total_return_pct": float(total_return_pct),
|
||||
"cagr": float(cagr),
|
||||
"max_drawdown_pct": float(max_dd),
|
||||
"sharpe": float(sharpe),
|
||||
"sortino": float(sortino),
|
||||
"avg_trade_duration_bars": avg_duration,
|
||||
"longest_winning_streak": longest_win,
|
||||
"longest_losing_streak": longest_loss,
|
||||
"final_balance": final_balance,
|
||||
}
|
||||
|
||||
|
||||
def _longest_streak(seq: List[int], target: int) -> int:
|
||||
best = current = 0
|
||||
for x in seq:
|
||||
if x == target:
|
||||
current += 1
|
||||
best = max(best, current)
|
||||
else:
|
||||
current = 0
|
||||
return best
|
||||
|
||||
|
||||
def trades_to_dataframe(trades: List[Trade]) -> pd.DataFrame:
|
||||
"""Convert trade journal to a DataFrame for CSV/JSON export."""
|
||||
if not trades:
|
||||
return pd.DataFrame()
|
||||
return pd.DataFrame([asdict(t) for t in trades])
|
||||
Reference in New Issue
Block a user