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>
This commit is contained in:
2026-07-12 15:29:21 +02:00
co-authored by Claude Opus 4.8
parent 85b3ef618f
commit 9789e3d762
7 changed files with 536 additions and 97 deletions
+17
View File
@@ -12,6 +12,7 @@ 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,
@@ -43,6 +44,22 @@ 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"]))