Files
ForexBot2/metrics.py
T
Harwinder b38ed17c96 Add session filter + auto-toggle, extend trade journal diagnostics
Session filter restricts trades to a UTC hour window (default 07-16,
London + NY) — opt-in via --session-filter, with --session-start /
--session-end overrides. Wired into both the backtester (cheap hour
check next to the regime gate) and live mode (downgrades signal to
NEUTRAL/F when current UTC hour is outside the window).

Auto-toggle (--auto-session-filter) tracks rolling mean pnl_pct over
the last N closed trades; when it falls below threshold, the session
filter activates, releasing once expectancy recovers. Default lookback
10 trades, threshold -0.3%.

Trade dataclass extended with diagnostic fields captured at entry:
entry_hour, entry_dow, ADX, ATR%, market_regime, volatility_regime,
mtf_agreement. These let --journal-csv exports surface loss-pattern
clusters when a window underperforms.

Validated on 2024 trend / 2023 chop / 2026 recent windows. Auto-toggle
preserves ~50% of trend edge while still cutting ~25% of recent-regime
losses — pure regime filter is still best on cumulative, so both
session flags ship default-off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 18:38:49 -04:00

165 lines
5.5 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
# Diagnostic fields captured at entry — used to mine for loss-pattern
# filters when a window underperforms.
entry_hour: int = -1 # UTC hour 0-23
entry_dow: int = -1 # 0=Mon, 6=Sun
adx_at_entry: float = 0.0
atr_pct_at_entry: float = 0.0
market_regime: str = "" # trending_up / trending_down / ranging
volatility_regime: str = "" # low / medium / high
mtf_agreement: bool = False
@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])