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>
106 lines
4.4 KiB
Python
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
|