2e5d66db1b
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.
165 lines
5.5 KiB
Python
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])
|