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:
+71
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user