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
+81
View File
@@ -0,0 +1,81 @@
/**
* Base rates for the strategy as it actually runs.
*
* The per-target `probability` on a setup answers "will price touch this S/R
* level?" — a question about a level we never exit at. These numbers answer the
* question the user is really asking ("what tends to happen when I take one of
* these?") and they are measured under the *real* exit policy, from the same
* backtest report the Track Record page already consumes.
*/
interface MonitorRun {
strategy?: string;
lookback?: string;
is_production?: boolean;
sharpe?: number | null;
cagr_pct?: number | null;
max_drawdown_pct?: number | null;
trades?: number | null;
win_rate?: number | null;
avg_hold_days?: number | null;
best_trade_r?: number | null;
worst_trade_r?: number | null;
exit_reasons?: Record<string, number> | null;
}
export interface BaseRates {
lookbackLabel: string;
trades: number;
winRate: number;
avgHoldDays: number | null;
bestR: number | null;
worstR: number | null;
sharpe: number | null;
/** How trades actually ended, as shares of the total (0-1). */
exits: { reason: string; label: string; count: number; share: number }[];
}
const EXIT_LABELS: Record<string, string> = {
stop: 'initial stop',
trailing_stop: 'trailing stop',
time: 'max hold',
target: 'target',
};
/**
* Pull the production strategy's full-history row out of a backtest report.
* Returns null when the report hasn't run or has no production row.
*/
export function productionBaseRates(report: unknown): BaseRates | null {
const monitor = (report as { portfolio_monitor?: { production_strategy?: string; runs?: MonitorRun[] } })
?.portfolio_monitor;
if (!monitor?.runs?.length) return null;
const strategy = monitor.production_strategy;
const row =
monitor.runs.find((r) => r.strategy === strategy && r.lookback === 'all') ??
monitor.runs.find((r) => r.is_production && r.lookback === 'all');
if (!row || !row.trades) return null;
const reasons = row.exit_reasons ?? {};
const total = Object.values(reasons).reduce((a, b) => a + b, 0);
const exits = Object.entries(reasons)
.map(([reason, count]) => ({
reason,
label: EXIT_LABELS[reason] ?? reason,
count,
share: total > 0 ? count / total : 0,
}))
.sort((a, b) => b.count - a.count);
return {
lookbackLabel: 'all history',
trades: row.trades,
winRate: row.win_rate ?? 0,
avgHoldDays: row.avg_hold_days ?? null,
bestR: row.best_trade_r ?? null,
worstR: row.worst_trade_r ?? null,
sharpe: row.sharpe ?? null,
exits,
};
}