diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 87123b0..f610a6b 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -18,14 +18,17 @@ 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; - // Past the stop entirely = invalidated; moved >1/3 of the way to target = stale + // 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'; if (beyondStop) status = 'invalidated'; - else if (span > 0 && moved / span > 0.33) status = 'stale'; - return { pct, towardTarget, status }; + else if (towardTarget && progressPct > 33) status = 'stale'; + else if (!towardTarget && progressPct > 33) status = 'stale'; + return { pct, progressPct, towardTarget, status }; } function riskClass(risk: TradeSetup['risk_level']) { @@ -115,7 +118,9 @@ function SetupCard({ setup, action, currentPrice }: { setup?: TradeSetup; action )} {drift && drift.status === 'stale' && (
- ⚠ Price has moved {drift.pct >= 0 ? '+' : ''}{drift.pct.toFixed(1)}% from entry{drift.towardTarget ? ' toward target' : ' against the setup'} — entry may be stale. + {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.`}
)} diff --git a/frontend/src/lib/qualification.ts b/frontend/src/lib/qualification.ts index d3ee08e..c58e5ec 100644 --- a/frontend/src/lib/qualification.ts +++ b/frontend/src/lib/qualification.ts @@ -6,6 +6,13 @@ export function bestTargetProbability(setup: TradeSetup): number { return setup.targets?.length ? Math.max(...setup.targets.map((t) => t.probability)) : 0; } +/** Probability of the starred primary target (the one the headline R:R refers to). */ +export function primaryTargetProbability(setup: TradeSetup): number | null { + const primary = setup.targets?.find((t) => t.is_primary); + if (primary) return primary.probability; + return setup.targets?.length ? bestTargetProbability(setup) : null; +} + /** * Whether a setup clears the activation gate. Mirrors the backend predicate in * app/services/qualification.py — keep the two in sync. diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index ecaaac7..a4578fc 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -9,7 +9,7 @@ import { Section } from '../components/ui/Section'; import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton'; import { formatPrice } from '../lib/format'; import { recommendationActionLabel } from '../lib/recommendation'; -import { qualifiesSetup, activationSummary } from '../lib/qualification'; +import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../lib/qualification'; import type { TradeSetup } from '../lib/types'; function fmtR(value: number | null): string { @@ -130,7 +130,7 @@ export default function DashboardPage() {