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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user