Files
ForexBot2/metrics.py
T
Harwinder 636f0304ae 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>
2026-05-07 17:15:41 -04:00

156 lines
5.0 KiB
Python

"""
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])