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
+134
View File
@@ -0,0 +1,134 @@
/**
* What actually closes a trade.
*
* The setup's `target` is NOT an exit under the production policy: it is a
* screening artifact — the nearest S/R level, used to compute the R:R and
* probability that admit the setup through the activation gate. The live exit
* (`paper_trade_service.resolve_open_trades`) never reads it; `atr_trailing`
* closes on the initial stop, a trailing stop, or the max hold.
*
* This module derives the real plan so the UI can show it instead of implying
* a take-profit that will never fire. See docs/research/sr-levels-and-exits.md.
*/
import type { ExitPolicy, TradeSetup } from './types';
/**
* Stop width used when the scanner builds a setup: stop = entry ∓ 1.5 × ATR
* (`rr_scanner_service.scan_symbol`, and `backtest_service.ATR_MULTIPLIER`).
* Lets us recover ATR from a setup without another round trip:
* ATR = |entry stop| / 1.5
* Guarded by test_prod_strategy_parity.py so a backend change can't silently
* desync this.
*/
export const SETUP_STOP_ATR_MULTIPLIER = 1.5;
export interface ExitPlan {
mode: ExitPolicy['mode'];
/** Does the setup's target actually close the trade? Only when mode === 'target'. */
honorsTarget: boolean;
/** Distance from entry to the initial stop, i.e. 1R per share. */
riskPerShare: number;
initialStop: number;
/** Trailing-stop width in price, once the trail is active (atr_trailing only). */
trailWidth: number | null;
/**
* Price the trade must reach before the trailing stop rises above the initial
* stop and takes over. Below this, the initial stop is what's protecting you.
*/
trailTakesOverAt: number | null;
/** Trail width expressed in R — the intuitive "how much give-back". */
trailWidthR: number | null;
maxHoldDays: number;
headline: string;
}
/**
* Derive the real exit plan for a setup under the live policy.
* Returns null when the setup has no usable risk distance.
*/
export function deriveExitPlan(setup: TradeSetup, policy?: ExitPolicy): ExitPlan | null {
const isLong = setup.direction === 'long';
const riskPerShare = Math.abs(setup.entry_price - setup.stop_loss);
if (!(riskPerShare > 0)) return null;
// Fall back to the shipped defaults when the policy hasn't loaded yet, so the
// card never renders a blank or (worse) a target-based plan.
const mode = policy?.mode ?? 'atr_trailing';
const maxHoldDays = policy?.hold_days ?? 30;
const atrMultiplier = policy?.atr_multiplier ?? 3;
const atr = riskPerShare / SETUP_STOP_ATR_MULTIPLIER;
if (mode === 'atr_trailing') {
const trailWidth = atrMultiplier * atr;
// The trail only bites once it clears the initial stop:
// highestClose trailWidth > stop ⇔ highestClose > entry ± (trailWidth R)
const takeoverOffset = trailWidth - riskPerShare;
const trailTakesOverAt = isLong
? setup.entry_price + takeoverOffset
: setup.entry_price - takeoverOffset;
return {
mode,
honorsTarget: false,
riskPerShare,
initialStop: setup.stop_loss,
trailWidth,
trailTakesOverAt,
trailWidthR: trailWidth / riskPerShare,
maxHoldDays,
headline: `${atrMultiplier}× ATR trailing stop · max ${maxHoldDays} trading days`,
};
}
if (mode === 'trailing') {
const trailWidth = (setup.entry_price * (policy?.trailing_pct ?? 12)) / 100;
return {
mode,
honorsTarget: false,
riskPerShare,
initialStop: setup.stop_loss,
trailWidth,
trailTakesOverAt: null,
trailWidthR: trailWidth / riskPerShare,
maxHoldDays,
headline: `${policy?.trailing_pct ?? 12}% trailing stop · max ${maxHoldDays} trading days`,
};
}
if (mode === 'target') {
return {
mode,
honorsTarget: true,
riskPerShare,
initialStop: setup.stop_loss,
trailWidth: null,
trailTakesOverAt: null,
trailWidthR: null,
maxHoldDays,
headline: 'Take profit at the selected level, or exit at the stop',
};
}
// 'time'
return {
mode,
honorsTarget: false,
riskPerShare,
initialStop: setup.stop_loss,
trailWidth: null,
trailTakesOverAt: null,
trailWidthR: null,
maxHoldDays,
headline: `Hold to the stop or ${maxHoldDays} trading days — no target, no trail`,
};
}
/** How far price has run from the scan entry, in R. Sign is direction-aware. */
export function driftInR(setup: TradeSetup, currentPrice: number): number | null {
const risk = Math.abs(setup.entry_price - setup.stop_loss);
if (!(risk > 0)) return null;
const moved = setup.direction === 'long'
? currentPrice - setup.entry_price
: setup.entry_price - currentPrice;
return moved / risk;
}