diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 9ee4a1c..6af5805 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -18,6 +18,18 @@ after D to record the realized outcome. The report contains: Limitation: sentiment and fundamentals have no point-in-time history, so they're held neutral here — this calibrates the price/S-R machinery only. + +Environment variables (see also run_backtest_snapshot.py): + Production / general use: + BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD # disjoint train/test split + BACKTEST_SNAPSHOT_OFFLINE=1 + BACKTEST_ALLOW_SPAWN=1 # for Windows multiprocessing + + Research / diagnostic only (retired experiments — do not use for live decisions): + BACKTEST_ATR_TARGET_FALLBACK=3 + BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1 + BACKTEST_RESEARCH_EXITS=1 + BACKTEST_MIN_RR_SWEEP=1 """ from __future__ import annotations @@ -67,7 +79,6 @@ from app.services.qualification import ( from app.services.recommendation_service import ( _choose_recommended_action, _classify_by_probability, - _gate_eligible_levels, _prune_floor_pinned_targets, _risk_level_from_conflicts, _select_primary_target, @@ -145,13 +156,20 @@ def validate_backtest_target_model(value: str) -> str: return normalized +# --------------------------------------------------------------------------- +# RESEARCH / DIAGNOSTIC FALLBACKS (retired experiments) +# +# These implement behavior from experiments that were rejected for production +# (clear-air synthetic targets, blanket ATR fallbacks). They are OFF by default +# and exist only to reproduce historical research results or run future ablations. +# See docs/research/sr-levels-and-exits.md. +# Do NOT enable for production decision making. +# --------------------------------------------------------------------------- + def _atr_target_fallback_k() -> float | None: - """Research ablation: k for a synthetic k*ATR target when a direction has no - S/R level to aim at. Off (None) by default, which is production behavior — - no resistance above means no long setup at all. That veto lands hardest on - names at 52-week highs (clear air above), i.e. exactly what the momentum gate - selects, so this flag exists to measure what the veto costs. Set - BACKTEST_ATR_TARGET_FALLBACK=3 to enable. See docs/research/sr-levels-and-exits.md.""" + """RESEARCH DIAGNOSTIC: k for a synthetic k*ATR target when no S/R level. + Off (None) by default (production behavior). Set BACKTEST_ATR_TARGET_FALLBACK=3 + to enable. See docs/research/sr-levels-and-exits.md.""" raw = os.getenv("BACKTEST_ATR_TARGET_FALLBACK", "").strip() if not raw: return None @@ -163,13 +181,8 @@ def _atr_target_fallback_k() -> float | None: def _fallback_clear_air_only() -> bool: - """Restrict the fallback to setups with genuinely NO structure ahead. - - Without this, the fallback also fires when levels DO exist ahead but - ``TargetGenerator``'s distance filters rejected them (nearer than 1 ATR, or - past ``max_atr_multiple``). Measured on the snapshot, that's 65% of what the - fallback admits — a different population from the clear-air breakouts, which - confounds the famine test. Set BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1.""" + """RESEARCH DIAGNOSTIC: restrict fallback to genuine clear-air cases only. + Set BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1.""" return os.getenv("BACKTEST_FALLBACK_CLEAR_AIR_ONLY", "").strip().lower() in { "1", "true", "yes", "on", } @@ -190,11 +203,7 @@ def _has_structure_ahead(direction: str, entry: float, sr_levels: list[Any]) -> def _atr_fallback_target( direction: str, entry: float, stop: float, atr: float, k: float ) -> dict: - """A synthetic target k*ATR from entry, shaped like a TargetGenerator row. - - ``sr_strength`` is 50 (neutral) so the probability model's strength magnet - contributes nothing — the target stands on distance alone. - """ + """RESEARCH DIAGNOSTIC: synthetic target k*ATR (neutral strength).""" price = entry + k * atr if direction == "long" else entry - k * atr distance = abs(price - entry) risk = abs(entry - stop) @@ -254,7 +263,7 @@ def _window_setups( if not sr_levels: return [] - gate_levels = _gate_eligible_levels(sr_levels) + gate_levels = list(sr_levels) technical = (compute_technical_from_arrays(highs, lows, closes, volumes)[0]) or 50.0 momentum = (compute_momentum_from_closes(closes)[0]) or 50.0 @@ -280,8 +289,9 @@ def _window_setups( fallback_k = _atr_target_fallback_k() if fallback_k is None: continue + # RESEARCH DIAGNOSTIC only (see _atr_target_fallback_k etc.) if _fallback_clear_air_only() and _has_structure_ahead(direction, entry, sr_levels): - continue # structure exists ahead; the distance filters rejected it, not the famine + continue targets = [_atr_fallback_target(direction, entry, stop, atr, fallback_k)] for t in targets: t["probability"] = probability_estimator.estimate_probability( @@ -2452,7 +2462,7 @@ def _sharpe_key(row: dict) -> float: def _build_research_recommendation(report: dict) -> dict: - """Advisory rules for the remaining research variants after residual promotion.""" + """Build advisory notes from any strategy variants present in the report.""" variants = { v.get("variant"): v for v in (report.get("strategy_variants") or {}).get("variants", []) diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index d2d6f87..d5e48a5 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -56,38 +56,6 @@ def _clamp(value: float, low: float, high: float) -> float: return max(low, min(high, value)) -def _gate_eligible_levels( - sr_levels: list[Any], - *, - confirmed_rounds_only: bool = False, - exclude_standalone_rounds: bool = False, - min_round_rejections: int = 2, -) -> list[Any]: - """Return structures allowed to influence entry qualification. - - Round numbers remain useful visual landmarks, but an untouched standalone - round number is not observed market structure. Research variants can require - either confluence with a pivot/volume source or distinct rejection clusters - before such a level is allowed to manufacture a gate target. - """ - if not confirmed_rounds_only and not exclude_standalone_rounds: - return list(sr_levels) - - eligible: list[Any] = [] - for level in sr_levels: - sources = set(getattr(level, "sources", None) or [ - getattr(level, "detection_method", "unknown") - ]) - is_round_only = sources == {"round_number"} - rejections = int(getattr(level, "rejection_count", 0) or 0) - if exclude_standalone_rounds and is_round_only: - continue - if confirmed_rounds_only and is_round_only and rejections < min_round_rejections: - continue - eligible.append(level) - return eligible - - def _zone_representative_levels( sr_levels: list[SRLevel], entry_price: float, diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index baf4428..d1cf8ca 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -263,7 +263,7 @@ export function BacktestPanel() { )} {' '}· target model:{' '} - {report.params.target_model_label ?? 'Legacy report (model not recorded)'} + {report.params.target_model_label ?? 'Unknown (legacy report)'}

diff --git a/tests/unit/test_recommendation_service.py b/tests/unit/test_recommendation_service.py index e2c41b3..8fe36b3 100644 --- a/tests/unit/test_recommendation_service.py +++ b/tests/unit/test_recommendation_service.py @@ -5,7 +5,6 @@ from dataclasses import dataclass from app.services.recommendation_service import ( _build_reasoning, _choose_recommended_action, - _gate_eligible_levels, _prune_floor_pinned_targets, _select_primary_target, direction_analyzer, @@ -354,26 +353,3 @@ def test_zone_representative_levels_soft_strength_avoids_resaturation(): assert reps[0].strength == 65 assert set(reps[0].sources) == {"pivot_point", "round_number"} assert reps[0].rejection_count == 3 - - -def test_gate_requires_confirmation_for_standalone_round_number(): - from types import SimpleNamespace - - untouched = SimpleNamespace( - detection_method="round_number", sources=["round_number"], - rejection_count=1, - ) - confirmed = SimpleNamespace( - detection_method="round_number", sources=["round_number"], - rejection_count=2, - ) - confluent = SimpleNamespace( - detection_method="merged", sources=["round_number", "pivot_point"], - rejection_count=0, - ) - assert _gate_eligible_levels( - [untouched, confirmed, confluent], confirmed_rounds_only=True - ) == [confirmed, confluent] - assert _gate_eligible_levels( - [untouched, confirmed, confluent], exclude_standalone_rounds=True - ) == [confluent]