Files
signal-platform/tests/unit/test_prod_strategy_parity.py
T
dennisthiessenandClaude Opus 4.8 9789e3d762 UI: frame the setup as what it is — a momentum signal with a trailing exit
The UI told a swing-trade story (entry -> target -> stop) while the engine runs
a momentum portfolio (buy strength, trail out, re-rank). The selection was
honest; everything around it was borrowed from a strategy we don't run.

The target is never an exit under `atr_trailing`: `_atr_trailing_close()` does
not even take it as a parameter. It exists only to compute the R:R and touch
odds that admit a setup through the activation gate. Backtested exit reasons for
the production strategy: 144 initial stop, 98 trailing stop, 78 max hold —
target 0. See docs/research/sr-levels-and-exits.md.

What changed:

- New ExitPlanPanel on every setup card states the rules that actually close the
  trade: initial stop (1R), the price at which the 3x ATR trail takes over from
  it, the trail width in R, and the max hold. Derived in lib/exitPlan.ts from the
  live exit policy, so it follows Admin rather than hardcoding the default.
- New BaseRatesPanel replaces per-target "probability" as the answer to "what
  usually happens": win rate, average hold, best/worst R, and how trades actually
  ended — measured under the real exit, from the backtest report.
- "Target"/"target probability" relabelled to "level"/"touch odds" and grouped as
  gate metrics, with the R:R. On the dashboard focus card, residual momentum
  (the actual signal) takes the headline stat those two used to occupy.
- The take-trade dialog no longer offers a target dropdown whose value the exit
  ignores; it states the trailing plan instead. The picker returns only when the
  live policy is mode='target', where the choice is real. The stored target is
  now the setup's own, not whichever row was last clicked while exploring.
- "Played out" is gone. A setup was declared dead once price reached the target —
  backwards under a trailing exit, where reaching a level is the good case and
  the trade keeps running. Only the stop invalidates a setup now; running past
  the entry is an "extended" warning, measured in R (you'd be chasing).

The levels ladder, the price rail and the chart overlay all stay fully
explorable — clicking a level still drives them. It is framed as overhead
structure, which is what it is, rather than a menu of exits.

Adds a parity guard: the UI recovers ATR as |entry - stop| / 1.5, so the test
fails if the scanner's stop width ever moves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:29:21 +02:00

106 lines
4.4 KiB
Python

"""Parity guards: the backtest's production strategy must equal the live setup.
The portfolio monitor's production row replays the live qualification flag and
the runtime Admin exit policy, but several constants are still defined on both
sides (defaults, trail width, ordering weights). These tests fail if the two
sides drift, so a change to the live strategy forces the backtest — and vice
versa — to move with it.
"""
import pytest
from app.services import paper_trade_service
from app.services.admin_service import ACTIVATION_DEFAULTS
from app.services.backtest_service import (
ATR_MULTIPLIER,
ATR_TRAIL_MULTIPLIER,
LIVE_EXIT_MODE_TO_SIM,
PORTFOLIO_MONITOR_STRATEGIES,
PRODUCTION_PERCENTILE_KEY,
RESIDUAL_HIGH_VOL_BLEND_80_20_KEY,
TIME_EXIT_DAYS,
_entry_variant_config,
_momentum_qualifies,
_qualifies_strategy_variant,
)
from app.services.momentum_service import (
STRATEGY_RANK_MOMENTUM_WEIGHT,
STRATEGY_RANK_VOL_WEIGHT,
)
def _production_monitor_row() -> dict:
return next(s for s in PORTFOLIO_MONITOR_STRATEGIES if s.get("is_production"))
def test_exit_defaults_match_the_simulated_exit() -> None:
assert paper_trade_service.DEFAULT_EXIT_MODE == "atr_trailing"
assert LIVE_EXIT_MODE_TO_SIM[paper_trade_service.DEFAULT_EXIT_MODE] == "atr_trail3"
assert paper_trade_service.DEFAULT_ATR_MULTIPLIER == ATR_TRAIL_MULTIPLIER
assert paper_trade_service.DEFAULT_HOLD_DAYS == max(TIME_EXIT_DAYS)
def test_every_live_exit_mode_has_a_sim_mapping() -> None:
assert set(paper_trade_service._VALID_EXIT_MODES) == set(LIVE_EXIT_MODE_TO_SIM)
def test_setup_stop_width_matches_the_frontend_constant() -> None:
"""The UI recovers ATR from a setup as |entry - stop| / 1.5 to render the real
exit plan (frontend/src/lib/exitPlan.ts: SETUP_STOP_ATR_MULTIPLIER). Nothing
else transmits ATR, so if the scanner's stop width changes here the UI would
silently draw the trailing stop in the wrong place."""
import inspect
from app.services import rr_scanner_service
frontend_constant = 1.5
assert ATR_MULTIPLIER == frontend_constant
for fn in (rr_scanner_service.scan_ticker, rr_scanner_service.scan_all_tickers):
signature = inspect.signature(fn)
assert signature.parameters["atr_multiplier"].default == frontend_constant
def test_gate_default_matches_the_promoted_cutoff() -> None:
prod = _production_monitor_row()
entry_cfg = _entry_variant_config(str(prod["entry_variant"]))
assert entry_cfg is not None
assert float(entry_cfg["cutoff"]) == float(ACTIVATION_DEFAULTS["min_momentum_percentile"])
def test_production_ordering_weights_are_single_sourced() -> None:
# The promoted ordering is 80/20 momentum/vol; the backtest imports the
# weight, so equality here pins the *value* the promotion was validated at.
assert STRATEGY_RANK_MOMENTUM_WEIGHT == 0.8
assert STRATEGY_RANK_VOL_WEIGHT == pytest.approx(0.2)
prod = _production_monitor_row()
entry_cfg = _entry_variant_config(str(prod["entry_variant"]))
assert entry_cfg is not None
assert entry_cfg["ranking_key"] == RESIDUAL_HIGH_VOL_BLEND_80_20_KEY
def test_production_monitor_row_replays_the_live_config() -> None:
prod = _production_monitor_row()
assert prod.get("use_live_config") is True
assert prod["exit_policy"] == "atr_trail3"
def test_live_gate_equals_the_production_variant_gate() -> None:
"""The monitor's live-gate switch relies on the runtime `qualified` flag
(_momentum_qualifies) selecting exactly what the frozen production variant
gate selects at the default cutoff."""
prod = _production_monitor_row()
entry_cfg = _entry_variant_config(str(prod["entry_variant"]))
assert entry_cfg is not None
cutoff = float(ACTIVATION_DEFAULTS["min_momentum_percentile"])
for cand in (
{"meets_core": True, "direction": "long", PRODUCTION_PERCENTILE_KEY: 92.0},
{"meets_core": True, "direction": "long", PRODUCTION_PERCENTILE_KEY: 80.0},
{"meets_core": True, "direction": "long", PRODUCTION_PERCENTILE_KEY: 79.9},
{"meets_core": True, "direction": "long", PRODUCTION_PERCENTILE_KEY: None},
{"meets_core": True, "direction": "short", PRODUCTION_PERCENTILE_KEY: 95.0},
{"meets_core": False, "direction": "long", PRODUCTION_PERCENTILE_KEY: 95.0},
):
assert _momentum_qualifies(cand, cutoff) == _qualifies_strategy_variant(
cand, entry_cfg
), cand