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>
82 lines
2.5 KiB
TypeScript
82 lines
2.5 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|