From 9789e3d7622a10d0e7c58b9f9ea5f897708dbb72 Mon Sep 17 00:00:00 2001 From: Dennis Thiessen Date: Sun, 12 Jul 2026 15:29:21 +0200 Subject: [PATCH] =?UTF-8?q?UI:=20frame=20the=20setup=20as=20what=20it=20is?= =?UTF-8?q?=20=E2=80=94=20a=20momentum=20signal=20with=20a=20trailing=20ex?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/ticker/BaseRatesPanel.tsx | 73 ++++++ .../src/components/ticker/ExitPlanPanel.tsx | 62 +++++ .../components/ticker/RecommendationPanel.tsx | 221 +++++++++++------- frontend/src/lib/baseRates.ts | 81 +++++++ frontend/src/lib/exitPlan.ts | 134 +++++++++++ frontend/src/pages/DashboardPage.tsx | 45 ++-- tests/unit/test_prod_strategy_parity.py | 17 ++ 7 files changed, 536 insertions(+), 97 deletions(-) create mode 100644 frontend/src/components/ticker/BaseRatesPanel.tsx create mode 100644 frontend/src/components/ticker/ExitPlanPanel.tsx create mode 100644 frontend/src/lib/baseRates.ts create mode 100644 frontend/src/lib/exitPlan.ts diff --git a/frontend/src/components/ticker/BaseRatesPanel.tsx b/frontend/src/components/ticker/BaseRatesPanel.tsx new file mode 100644 index 0000000..d4e2e33 --- /dev/null +++ b/frontend/src/components/ticker/BaseRatesPanel.tsx @@ -0,0 +1,73 @@ +import type { BaseRates } from '../../lib/baseRates'; + +/** + * What actually happens to trades like this one, measured under the real exit + * policy in the backtest. This is the honest replacement for the per-target + * "probability", which estimates the odds of touching a level the trade never + * exits at. Note `target` is absent from the exit mix — by construction. + */ +export function BaseRatesPanel({ rates }: { rates: BaseRates }) { + return ( +
+ + What usually happens · {rates.trades} backtested trades ({rates.lookbackLabel}) + + +
+ + Win rate {rates.winRate.toFixed(0)}% + + {rates.avgHoldDays != null && ( + + Avg hold {rates.avgHoldDays.toFixed(0)}d + + )} + {rates.bestR != null && ( + + Best +{rates.bestR.toFixed(1)}R + + )} + {rates.worstR != null && ( + + Worst {rates.worstR.toFixed(1)}R + + )} +
+ +

+ Most trades lose a little; a few win big. That asymmetry is the edge — which is why + there is no take-profit. +

+ + {rates.exits.length > 0 && ( +
+

how they ended

+
+ {rates.exits.map((e) => ( +
+ ))} +
+
+ {rates.exits.map((e) => ( + + {e.label} {(e.share * 100).toFixed(0)}% + + ))} + target 0% +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ticker/ExitPlanPanel.tsx b/frontend/src/components/ticker/ExitPlanPanel.tsx new file mode 100644 index 0000000..1990d93 --- /dev/null +++ b/frontend/src/components/ticker/ExitPlanPanel.tsx @@ -0,0 +1,62 @@ +import type { ExitPlan } from '../../lib/exitPlan'; +import { formatPrice } from '../../lib/format'; + +/** + * The exit rules that will actually close this trade. + * + * Deliberately sits *above* the levels ladder: the levels are context, this is + * the plan. Before this existed the card showed a "Target" with the same visual + * weight as the entry, implying a take-profit that the live exit never fires. + */ +export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction: string }) { + const isLong = direction === 'long'; + + return ( +
+
+ how this exits + {plan.headline} +
+ +
+
+
Initial stop
+
+ {formatPrice(plan.initialStop)}{' '} + (1R = {formatPrice(plan.riskPerShare)}/sh) +
+
+ + {plan.mode === 'atr_trailing' && plan.trailTakesOverAt != null && plan.trailWidthR != null && ( + <> +
+
Trail takes over
+
+ {isLong ? 'above' : 'below'} {formatPrice(plan.trailTakesOverAt)} +
+
+
+
Then it trails
+
+ {formatPrice(plan.trailWidth ?? 0)} ({plan.trailWidthR.toFixed(1)}R) below the highest close +
+
+ + )} + +
+
Max hold
+
{plan.maxHoldDays} trading days
+
+
+ + {!plan.honorsTarget && ( +

+ There is no take-profit. Winners are ridden until the + trailing stop is hit — that’s where the strategy’s edge comes from, so hitting a level + below is not a reason to sell. The levels shown below are screening context, not exits. +

+ )} +
+ ); +} diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index a6d1283..ac40e8d 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -10,7 +10,13 @@ import { useMarketRegime } from '../../hooks/useMarketRegime'; import { isCounterTrend } from '../../lib/regime'; import { primaryTargetProbability } from '../../lib/qualification'; import { PriceRail } from '../charts/horizon'; -import type { MarketRegime } from '../../lib/types'; +import type { ExitPolicy, MarketRegime } from '../../lib/types'; +import { deriveExitPlan, driftInR } from '../../lib/exitPlan'; +import { productionBaseRates } from '../../lib/baseRates'; +import { useExitPolicy } from '../../hooks/usePaperTrades'; +import { useBacktestReport } from '../../hooks/useMarketRegime'; +import { ExitPlanPanel } from './ExitPlanPanel'; +import { BaseRatesPanel } from './BaseRatesPanel'; interface RecommendationPanelProps { symbol: string; @@ -32,44 +38,42 @@ function daysUntil(iso: string): number | null { return Math.ceil((t - Date.now()) / 86_400_000); } -/** Earnings within the ~30-day target horizon can gap price through stop/target. */ +/** Earnings within the ~30-day hold horizon can gap price through the stop. */ const EARNINGS_HORIZON_DAYS = 30; /** - * How far current price has drifted from the setup's entry. A setup whose - * entry is far from the live price (price already ran toward target, or fell - * through the stop) is stale — entering now changes the risk/reward. + * How far price has drifted from the scan entry, measured in R (the initial risk + * distance). R is the right unit: the stop sits 1R away and the trailing exit is + * denominated in R too. + * + * This used to judge staleness by progress toward the *target*, and declared a + * setup "played out" once price reached it. Under the live trailing exit that is + * backwards — reaching a level is the good case and the trade keeps running. The + * only thing that invalidates a setup is price through the stop; running past the + * entry just means you'd be chasing (a wider effective stop), which is a warning, + * not a death sentence. */ function entryDrift(setup: TradeSetup, currentPrice?: number) { if (currentPrice == null || !setup.entry_price) return null; const pct = ((currentPrice - setup.entry_price) / setup.entry_price) * 100; - const towardTarget = setup.direction === 'long' ? currentPrice >= setup.entry_price : currentPrice <= setup.entry_price; - // Judge staleness by how much of the entry→target distance is already gone, - // not the raw % move — an 8%-wide setup is "used up" far faster than a 40% one. - const span = Math.abs(setup.target - setup.entry_price); - const moved = Math.abs(currentPrice - setup.entry_price); - const progressPct = span > 0 ? (moved / span) * 100 : 0; - const beyondStop = setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss; - let status: 'fresh' | 'stale' | 'invalidated' = 'fresh'; + const r = driftInR(setup, currentPrice); + const beyondStop = + setup.direction === 'long' ? currentPrice <= setup.stop_loss : currentPrice >= setup.stop_loss; + let status: 'fresh' | 'extended' | 'invalidated' = 'fresh'; if (beyondStop) status = 'invalidated'; - else if (towardTarget && progressPct > 33) status = 'stale'; - else if (!towardTarget && progressPct > 33) status = 'stale'; - return { pct, progressPct, towardTarget, status }; + else if (r != null && r >= 1) status = 'extended'; + else if (r != null && r <= -0.5) status = 'extended'; + return { pct, r, status }; } /** - * A stored setup is the latest for its direction. When price has run to/past the - * target (played out) or through the stop (invalidated), there is no fresh setup - * — the card and the ticker-level header should say so rather than present a - * stale actionable recommendation. Returns null when there's no live price. + * The only state with no tradeable setup left: price has gone through the stop. + * Returns null when there's no live price. */ function notActionableState(setup: TradeSetup, currentPrice?: number) { if (currentPrice == null) return null; - const drift = entryDrift(setup, currentPrice); - const playedOut = setup.direction === 'long' ? currentPrice >= setup.target : currentPrice <= setup.target; - const invalidated = drift?.status === 'invalidated'; - if (!playedOut && !invalidated) return null; - return { playedOut, invalidated }; + if (entryDrift(setup, currentPrice)?.status !== 'invalidated') return null; + return { invalidated: true }; } function riskClass(risk: TradeSetup['risk_level']) { @@ -106,25 +110,39 @@ function Chip({ children }: { children: React.ReactNode }) { type Target = NonNullable[number]; -function TargetTable({ setup, selectedPrice, onSelect }: { +function TargetTable({ setup, selectedPrice, onSelect, honorsTarget }: { setup: TradeSetup; selectedPrice: number; onSelect: (target: Target) => void; + /** True only when the live exit policy actually takes profit at a level. */ + honorsTarget: boolean; }) { if (!setup.targets || setup.targets.length === 0) { - return

No target probabilities available.

; + return

No overhead levels detected.

; } return (
- +
- - + + - - + + @@ -170,13 +188,14 @@ function TargetTable({ setup, selectedPrice, onSelect }: { ); } -function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, onSelectPrice }: { +function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, selectedPrice, onSelectPrice }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings; regime?: MarketRegime; - /** Controlled target selection (lifted so the candlestick chart can follow). */ + exitPolicy?: ExitPolicy; + /** Controlled level selection (lifted so the candlestick chart can follow). */ selectedPrice?: number | null; onSelectPrice?: (price: number) => void; }) { @@ -194,12 +213,13 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false; const prob = primaryTargetProbability(setup); - // When price has run to/past the target (played out) or through the stop - // (invalidated), there is no fresh setup — show a plain "no current setup" - // state instead of an actionable card with no reward left. - const inactive = notActionableState(setup, currentPrice); - const invalidated = inactive?.invalidated ?? false; - const notActionable = inactive != null; + // The real exit rules. `honorsTarget` is false under the production policy — + // the level ladder below is context, not a menu of exits. + const exitPlan = deriveExitPlan(setup, exitPolicy); + const honorsTarget = exitPlan?.honorsTarget ?? false; + + // Only price through the stop leaves no tradeable setup. + const notActionable = notActionableState(setup, currentPrice) != null; const createTrade = useCreatePaperTrade(); const [taking, setTaking] = useState(false); @@ -239,7 +259,10 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o entry_price: takeEntry, shares: takeShares, stop_loss: setup.stop_loss, - target: takeTarget, + // Only a real choice when the exit honors it. Otherwise record the + // setup's own primary level, so the stored value doesn't silently depend + // on which row the user happened to click while exploring the chart. + target: honorsTarget ? takeTarget : setup.target, }, { onSuccess: () => setTaking(false) }, ); @@ -254,13 +277,13 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o no current setup now {currentPrice != null ? formatPrice(currentPrice) : '—'} · last entry {formatPrice(setup.entry_price)} - {drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''} · last target {formatPrice(setup.target)} + {drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}

- {invalidated - ? `The last ${dir} setup is invalidated — price (${formatPrice(currentPrice!)}) has passed the stop (${formatPrice(setup.stop_loss)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.` - : `The last ${dir} setup has played out — price (${formatPrice(currentPrice!)}) is at or past the target (${formatPrice(setup.target)}). No fresh ${dir} setup right now; the scanner surfaces a new one when it forms.`} + The last {dir} setup is invalidated — price ({formatPrice(currentPrice!)}) has passed the stop + ({formatPrice(setup.stop_loss)}). No fresh {dir} setup right now; the scanner surfaces a new one + when it forms.

); @@ -279,10 +302,23 @@ function SetupCard({ setup, action, currentPrice, risk, regime, selectedPrice, o preferred )} + {setup.momentum_percentile != null && ( + + momentum top {Math.max(1, Math.round(100 - setup.momentum_percentile))}% + + )} confidence {setup.confidence_score?.toFixed(0) ?? '—'}% - R:R {activeRR.toFixed(1)}:1 - {activeProb != null && target prob {Math.round(activeProb)}%} - {selected && !selected.is_primary && custom target} + + gate · R:R {activeRR.toFixed(1)}:1 + {activeProb != null && ` · touch ${Math.round(activeProb)}%`} + + {selected && !selected.is_primary && custom level} {sizing ? ( )} - {drift && drift.status === 'invalidated' && ( -

- ⚠ Price ({formatPrice(currentPrice!)}) is past the stop — this setup is invalidated. -

- )} - {drift && drift.status === 'stale' && ( + {/* No 'invalidated' branch here: that state returns the "no current + setup" card above, so it can never reach this block. */} + {drift && drift.status === 'extended' && drift.r != null && (

- {drift.towardTarget - ? `⚠ ${drift.progressPct.toFixed(0)}% of the entry→target move is already gone (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}% from entry) — little reward left.` - : `⚠ Price has moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`} + {drift.r >= 1 + ? `⚠ Price has run ${drift.r.toFixed(1)}R past the scan entry (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%). Entering now means chasing — your stop sits further away, so the same dollar risk buys fewer shares.` + : `⚠ Price has drifted ${Math.abs(drift.r).toFixed(1)}R toward the stop (${drift.pct.toFixed(1)}%) — the entry is stale.`}

)} )} - {activeProb != null && activeProb < 15 && ( -

- ⚠ This target has only a {Math.round(activeProb)}% probability — pick a nearer one from the target list below. -

- )} {/* The setup, spatially — stop/entry/now hold still, the *selected* - target moves along a scale that always spans the whole ladder */} + level moves along a scale that always spans the whole ladder */} t.price)} /> + {/* The rules that actually close the trade. Above the ladder on purpose: + this is the plan, the levels below are only context. */} + {exitPlan && } + {/* Take dialog — portaled overlay so the panel structure stays put */} {taking && createPortal(
paper trade

- stop {formatPrice(setup.stop_loss)} · target {formatPrice(takeTarget)} + stop {formatPrice(setup.stop_loss)} + {honorsTarget && <> · take profit {formatPrice(takeTarget)}} {sizing && <> · suggested {sizing.shares} sh, max loss {formatPrice(sizing.dollarRisk)}}

+ {/* The exit is the trailing stop, not a target. Say so here, where the + user is actually committing — this dialog used to offer a target + dropdown whose value the exit never reads. */} + {exitPlan && !honorsTarget && ( +

+ exits on{' '} + {exitPlan.headline}. No take-profit — the {formatPrice(setup.target)} level is recorded for + reference only and will not close this trade. +

+ )} +
- {setup.targets && setup.targets.length > 1 ? ( + {/* Only a real choice when the live exit policy takes profit at a + level. Under `atr_trailing` (production) the value is inert, so + offering it would imply control the user does not have. */} + {honorsTarget && setup.targets && setup.targets.length > 1 ? (
ClassificationPriceBandLevel DistanceR:RProbability + Gate R:R + + Touch odds +