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
+26 -19
View File
@@ -92,7 +92,7 @@ function RadarSetupRow({ setup, rank, reason, name, selected, onSelect }: RadarR
className={`grid cursor-pointer grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 rounded-lg px-2 py-2.5 transition-colors ${
selected ? 'bg-blue-400/[0.08]' : 'hover:bg-white/[0.03]'
} ${qualified ? '' : 'opacity-60'}`}
title={`R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · target prob ${Math.round(prob)}%` : ''} · click to focus`}
title={`gate: R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · touch odds ${Math.round(prob)}%` : ''} (screening, not an exit) · click to focus`}
>
<span className="num text-[11px] text-gray-500">{rank}</span>
<span className="min-w-0">
@@ -164,34 +164,41 @@ function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
conviction {convictionLabel(setup.recommended_action)}
</span>
{setup.momentum_percentile != null && (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
residual momentum {Math.round(setup.momentum_percentile)}th %ile
</span>
)}
</div>
</div>
</div>
<div className="flex gap-10 text-right">
<div>
<p className="section-index">reward / risk</p>
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
{setup.rr_ratio.toFixed(1)}
<span className="text-lg text-gray-400"> : 1</span>
</p>
</div>
{prob != null && (
<div>
<p className="section-index">target probability</p>
{/* The headline stat is the signal that actually selected this ticker.
R:R and touch odds are gate inputs computed from an S/R level the
trade never exits at — they get quiet, labelled treatment. */}
<div className="flex items-start gap-10 text-right">
{setup.momentum_percentile != null && (
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
<p className="section-index">residual momentum</p>
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
{Math.round(prob)}
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
<span className="text-lg text-gray-400">%</span>
</p>
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
<span className="block h-full rounded-full bg-blue-500" style={{ width: `${Math.round(prob)}%` }} />
<span
className="block h-full rounded-full bg-blue-500"
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
/>
</div>
</div>
)}
<div
className="max-w-[13rem]"
title="Gate metrics. The reward/risk and touch odds of the nearest S/R level are what admitted this setup through the activation gate. The trade does NOT exit at that level — it exits on the trailing stop."
>
<p className="section-index">gate metrics</p>
<p className="num mt-1.5 text-sm text-gray-300">
R:R {setup.rr_ratio.toFixed(1)}:1
{prob != null && <> · touch {Math.round(prob)}%</>}
</p>
<p className="mt-1 text-[10.5px] leading-relaxed text-gray-500">
screening only exits on the trailing stop, not at the level
</p>
</div>
</div>
</div>