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.
This commit is contained in:
Harwinder
2026-05-07 18:38:49 -04:00
parent fb1eb5b73f
commit 2e5d66db1b
5 changed files with 139 additions and 1 deletions
+71 -1
View File
@@ -14,8 +14,9 @@ import itertools
import logging
import multiprocessing as mp
import os
from collections import deque
from dataclasses import replace
from typing import Dict, List, Optional, Tuple
from typing import Deque, Dict, List, Optional, Tuple
import pandas as pd
@@ -123,6 +124,13 @@ class WalkForwardBacktester:
]
trades: List[Trade] = []
# Auto-session feedback: rolling pnl_pct over last K closed trades.
# When mean falls below threshold, the session filter activates;
# once expectancy recovers, it releases.
self._recent_pnl_pcts: Deque[float] = deque(
maxlen=max(1, bt.auto_session_lookback_trades)
)
open_position: Optional[Dict] = None
# Optional progress bar
@@ -148,6 +156,7 @@ class WalkForwardBacktester:
trades.append(closed)
balance += closed.pnl_currency
equity_points.append((next_bar.name, balance))
self._recent_pnl_pcts.append(float(closed.pnl_pct))
open_position = None
# ── Step 2: maybe open a new position ──────────────────
@@ -166,6 +175,7 @@ class WalkForwardBacktester:
and report.trade_setup is not None
and t + 1 < len(primary_df)
and self._regime_passes(decision_time)
and self._session_passes(decision_time)
):
next_bar = primary_df.iloc[t + 1]
open_position = self._open_trade(report, next_bar, balance)
@@ -244,6 +254,25 @@ class WalkForwardBacktester:
return False
return float(last) >= bt.regime_filter_min_adx
def _session_passes(self, decision_time: pd.Timestamp) -> bool:
"""UTC hour gate — decision_time = entry bar's open time.
Filter is active when EITHER manual `session_filter` is on, OR
`auto_session_filter` is on and the rolling mean pnl_pct over the
last N closed trades is below threshold.
"""
bt = self.config.backtest
active = bt.session_filter
if not active and bt.auto_session_filter:
recent = getattr(self, "_recent_pnl_pcts", None)
if recent is not None and len(recent) >= bt.auto_session_lookback_trades:
mean_pnl = sum(recent) / len(recent)
active = mean_pnl < bt.auto_session_threshold_pct
if not active:
return True
h = int(decision_time.hour)
return bt.session_start_hour <= h < bt.session_end_hour
@staticmethod
def _compute_adx_series(df: pd.DataFrame, period: int = 14) -> pd.Series:
"""Wilder's ADX series — same algorithm as engine._compute_adx."""
@@ -325,6 +354,20 @@ class WalkForwardBacktester:
risk_pips = max(ts.risk_pips, 1e-9)
pip_currency_value = risk_amount / risk_pips # $ per pip
# ── Diagnostic snapshot at entry ─────────────────────────
analysis = report.primary_analysis
adx_val = 0.0
atr_pct = 0.0
if analysis is not None:
adx_sig = analysis.indicators.get("adx")
if adx_sig is not None:
adx_val = float(adx_sig.value)
atr_pct = float(analysis.key_levels.get("atr_pct", 0.0))
entry_ts = next_bar.name
entry_hour = int(entry_ts.hour) if hasattr(entry_ts, "hour") else -1
entry_dow = int(entry_ts.dayofweek) if hasattr(entry_ts, "dayofweek") else -1
return {
"direction": direction,
"entry_time": next_bar.name,
@@ -338,6 +381,14 @@ class WalkForwardBacktester:
"score_at_entry": float(report.final_score),
"confidence_at_entry": float(report.final_confidence),
"bar_index_open": next_bar.name,
# Diagnostics
"entry_hour": entry_hour,
"entry_dow": entry_dow,
"adx_at_entry": adx_val,
"atr_pct_at_entry": atr_pct,
"market_regime": report.market_regime or "",
"volatility_regime": report.volatility_regime or "",
"mtf_agreement": bool(report.mtf_agreement),
}
def _check_exit(
@@ -419,6 +470,13 @@ class WalkForwardBacktester:
grade=position["grade"],
score_at_entry=position["score_at_entry"],
confidence_at_entry=position["confidence_at_entry"],
entry_hour=position.get("entry_hour", -1),
entry_dow=position.get("entry_dow", -1),
adx_at_entry=position.get("adx_at_entry", 0.0),
atr_pct_at_entry=position.get("atr_pct_at_entry", 0.0),
market_regime=position.get("market_regime", ""),
volatility_regime=position.get("volatility_regime", ""),
mtf_agreement=position.get("mtf_agreement", False),
)
@@ -444,6 +502,18 @@ def _apply_overrides(config: PlatformConfig, overrides: Dict[str, float]) -> Pla
new_config.backtest.regime_filter = bool(overrides["regime_filter"])
if "regime_filter_min_adx" in overrides:
new_config.backtest.regime_filter_min_adx = float(overrides["regime_filter_min_adx"])
if "session_filter" in overrides:
new_config.backtest.session_filter = bool(overrides["session_filter"])
if "session_start_hour" in overrides:
new_config.backtest.session_start_hour = int(overrides["session_start_hour"])
if "session_end_hour" in overrides:
new_config.backtest.session_end_hour = int(overrides["session_end_hour"])
if "auto_session_filter" in overrides:
new_config.backtest.auto_session_filter = bool(overrides["auto_session_filter"])
if "auto_session_lookback_trades" in overrides:
new_config.backtest.auto_session_lookback_trades = int(overrides["auto_session_lookback_trades"])
if "auto_session_threshold_pct" in overrides:
new_config.backtest.auto_session_threshold_pct = float(overrides["auto_session_threshold_pct"])
if "risk_per_trade_pct" in overrides:
new_config.backtest.risk_per_trade_pct = float(overrides["risk_per_trade_pct"])
if "disable_indicator" in overrides: