feat: Enhance signal engine with exit confirmation logic and improve performance metrics calculation

This commit is contained in:
Luke Mosoti
2026-05-09 20:00:05 +03:00
parent 74be802b09
commit 7e2b8ee1fd
3 changed files with 56 additions and 6 deletions
Binary file not shown.
+32 -4
View File
@@ -156,7 +156,16 @@ def compute_purged_ts_score(
rf = min(fold_result.get("recovery_factor", 0.0), 5.0)
pf = min(fold_result.get("profit_factor", 1.0), 3.0)
sharpe = fold_result.get("sharpe_ratio", 0.0)
fold_scores.append(float(rf * 0.4 + pf * 0.3 + sharpe * 0.3))
wr = fold_result.get("win_rate", 0.0)
logger.info(
" Fold %s→%s: n=%d WR=%.1f%% Sharpe=%.3f RF=%.2f PF=%.2f",
is_end, oos_end or "latest", fold_n, wr * 100, sharpe, rf, pf,
)
# Cap at 2.0 — prevents a single anomalous period (e.g. COVID bull run)
# from dominating the cross-fold average. Raw formula can hit 2.2+
# when one period has exceptional RF + PF + Sharpe simultaneously.
fold_score = min(float(rf * 0.4 + pf * 0.3 + sharpe * 0.3), 2.0)
fold_scores.append(fold_score)
except Exception as exc:
logger.warning("Purged TS fold (%s→%s) failed: %s", is_end, oos_end or "latest", exc)
@@ -166,7 +175,24 @@ def compute_purged_ts_score(
if len(valid_scores) < 2:
return -50.0
mean_score = float(sum(valid_scores) / len(valid_scores))
# Hard reject if any evaluated fold is negative — a robust strategy must not
# lose money in any of the three tested macro-regime periods.
if any(s < 0.0 for s in valid_scores):
logger.warning(
"Purged TS: %d fold(s) negative — rejecting (folds: %s)",
sum(1 for s in valid_scores if s < 0.0),
[f"{s:.3f}" for s in valid_scores],
)
return -50.0
# Variance-penalised mean: subtract half the population std from the mean.
# A strategy that aces one anomalous period (e.g. COVID bull run) but is
# mediocre in others scores lower than one that performs consistently across
# all three folds. This prevents single-period outliers from winning the study.
_n_v = len(valid_scores)
_mean_v = sum(valid_scores) / _n_v
_std_v = (sum((s - _mean_v) ** 2 for s in valid_scores) / _n_v) ** 0.5
mean_score = float(_mean_v - 0.5 * _std_v)
# Walk-Forward Efficiency: recent fold performance relative to first fold.
# Rewards models that hold up in the most recent macro-regime.
@@ -186,8 +212,10 @@ def compute_purged_ts_score(
else:
return -50.0
# Consistency bonus: all valid folds positive
if all(s > 0 for s in valid_scores):
# Consistency bonus: all folds show meaningful positive performance (> 0.30).
# Threshold raised from > 0.0 — ensures a single barely-passing fold doesn't
# silently collect the bonus when the other folds are carrying the strategy.
if all(s > 0.30 for s in valid_scores):
mean_score *= 1.05
logger.info(
+24 -2
View File
@@ -12,6 +12,13 @@ import numpy as np
MIN_CONFIRMATION_BARS = {"H1": 2, "M15": 2, "M5": 2}
MIN_CHOP_CONFIRM_BARS = {"H1": 2, "M15": 2, "M5": 2}
# Minimum consecutive bars in a NEW (different) regime before regime-reversal
# exit fires. Mirrors the entry confirmation logic so single-bar regime blips
# (e.g. one Chop bar interrupting a Bull trend) don't cause premature exits.
# Without this, the engine enters after 2 confirmation bars, then exits on the
# FIRST bar of any regime change → 1-bar trades → spread dominates → WR ~25%.
MIN_EXIT_CONFIRM_BARS = {"H1": 2, "M15": 2, "M5": 3}
# XGBoost probability threshold for trend and MR entries
ENTRY_PROB = {"H1": 0.58, "M15": 0.55, "M5": 0.52}
MR_ENTRY_PROB = {"H1": 0.55, "M15": 0.52, "M5": 0.50}
@@ -57,6 +64,7 @@ class SignalEngine:
self.in_trade: bool = False
self.entry_regime: int | None = None
self.bars_in_trade: int = 0
self._reversal_bars: int = 0 # consecutive bars in non-entry regime
# ── Core API ──────────────────────────────────────────────────────────────
@@ -79,6 +87,14 @@ class SignalEngine:
else:
stability = 0.70
# Track consecutive bars in a non-entry regime for exit confirmation.
# Reset to zero when we return to the entry regime (blip resolved).
if self.in_trade and self.entry_regime is not None:
if new_state != self.entry_regime:
self._reversal_bars += 1
else:
self._reversal_bars = 0
return {
"state": new_state,
"bars_in_regime": self.bars_in_regime,
@@ -148,9 +164,12 @@ class SignalEngine:
"""
state = regime_info["state"]
# Regime reversal — regime has shifted away from the entry regime
# Regime reversal — require MIN_EXIT_CONFIRM_BARS consecutive bars in the
# new regime before exiting. Single-bar blips (e.g. one Chop bar in a Bull
# trend) reset _reversal_bars and keep the trade alive.
if self.entry_regime is not None and state != self.entry_regime:
return True, "regime_reversal"
if self._reversal_bars >= MIN_EXIT_CONFIRM_BARS.get(self.tf, 2):
return True, "regime_reversal"
# Persistence collapse — regime is no longer stable
if regime_info["stability"] < PERSISTENCE_MIN.get(self.tf, 0.55):
@@ -173,12 +192,14 @@ class SignalEngine:
self.in_trade = True
self.entry_regime = regime_state
self.bars_in_trade = 0
self._reversal_bars = 0
def on_trade_closed(self) -> None:
"""Call after a trade is fully closed."""
self.in_trade = False
self.entry_regime = None
self.bars_in_trade = 0
self._reversal_bars = 0
def reset(self) -> None:
"""Full state reset — use between independent backtest runs."""
@@ -187,3 +208,4 @@ class SignalEngine:
self.in_trade = False
self.entry_regime = None
self.bars_in_trade = 0
self._reversal_bars = 0