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 +