diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx index 2297745..575eb2c 100644 --- a/frontend/src/components/ticker/RecommendationPanel.tsx +++ b/frontend/src/components/ticker/RecommendationPanel.tsx @@ -7,6 +7,8 @@ import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings' import { positionSize } from '../../lib/position'; 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'; interface RecommendationPanelProps { @@ -15,6 +17,8 @@ interface RecommendationPanelProps { shortSetup?: TradeSetup; currentPrice?: number; nextEarningsDate?: string | null; + /** Render without the section/glass wrapper (inside the unified ticker panel). */ + frameless?: boolean; } /** Whole days from today until an ISO date (negative if past). */ @@ -65,9 +69,9 @@ function notActionableState(setup: TradeSetup, currentPrice?: number) { } function riskClass(risk: TradeSetup['risk_level']) { - if (risk === 'Low') return 'text-emerald-400'; + if (risk === 'Low') return 'text-emerald-300'; if (risk === 'Medium') return 'text-amber-400'; - if (risk === 'High') return 'text-red-400'; + if (risk === 'High') return 'text-red-300'; return 'text-gray-400'; } @@ -77,6 +81,25 @@ function isRecommended(setup: TradeSetup | undefined, action: TradeSetup['recomm return action.startsWith('SHORT'); } +function DirTag({ direction }: { direction: string }) { + const isLong = direction === 'long'; + return ( + + {direction} + + ); +} + +function Chip({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + function TargetTable({ setup }: { setup: TradeSetup }) { if (!setup.targets || setup.targets.length === 0) { return

No target probabilities available.

; @@ -119,7 +142,7 @@ function TargetTable({ setup }: { setup: TradeSetup }) { function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings; regime?: MarketRegime }) { if (!setup) { return ( -
+
Setup unavailable for this direction.
); @@ -129,6 +152,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad const drift = entryDrift(setup, currentPrice); const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss); 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" @@ -159,23 +183,20 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad if (notActionable) { const dir = setup.direction.toUpperCase(); return ( -
-
-

- {dir} -

- No current setup +
+
+ + 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)} +
-

+

{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.`}

-
-
Current
{currentPrice != null ? formatPrice(currentPrice) : '—'}
-
Last entry
{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}
-
Last target
{formatPrice(setup.target)}
-
); } @@ -183,90 +204,86 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad return (
-
-

- {setup.direction.toUpperCase()} -

- {setup.confidence_score?.toFixed(1) ?? '—'}% + {/* Identity + key stats in one quiet row */} +
+ + {recommended && ( + + preferred + + )} + confidence {setup.confidence_score?.toFixed(0) ?? '—'}% + R:R {setup.rr_ratio.toFixed(1)}:1 + {prob != null && target prob {Math.round(prob)}%}
- {!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && ( -

Alternative setup (ticker bias currently favors the opposite direction).

- )} - - {counterTrend && regime && ( -

- ⚠ Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market - ({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation. -

- )} - - {drift && drift.status === 'invalidated' && ( -

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

- )} - {drift && drift.status === '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.`} -

- )} - -
-
Current
{currentPrice != null ? formatPrice(currentPrice) : '—'}
-
Entry
{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}
-
Stop
{formatPrice(setup.stop_loss)}
-
Primary Target
{formatPrice(setup.target)}
-
R:R
{setup.rr_ratio.toFixed(2)}
-
- - {sizing ? ( -
-

- Position size · {risk.riskPct}% of {formatPrice(risk.accountSize)} -

-
-
-
{sizing.shares}
-
shares
-
-
-
{formatPrice(sizing.positionValue)}
-
position
-
-
-
{formatPrice(sizing.dollarRisk)}
-
max loss
-
-
- {sizing.exceedsAccount && ( -

Position exceeds account — needs margin.

+ {/* Warnings — only when they apply */} + {(counterTrend || (!recommended && recommendationActionDirection(action ?? null) !== 'neutral') || drift?.status !== 'fresh') && ( +
+ {!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && ( +

Alternative setup — the ticker bias currently favors the opposite direction.

+ )} + {counterTrend && regime && ( +

+ ⚠ Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market + ({regime.benchmark ?? 'SPY'}). Lower odds — size down or wait for confirmation. +

+ )} + {drift && drift.status === 'invalidated' && ( +

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

+ )} + {drift && drift.status === '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.`} +

)}
- ) : ( -

Set account size below to size this trade.

)} - {!taking ? ( - - ) : ( -
+ {/* The setup, spatially — stop → entry → now → target */} + + + {/* Sizing + take, one line */} +
+ {sizing ? ( + + {sizing.shares} shares · {formatPrice(sizing.positionValue)} position · max loss {formatPrice(sizing.dollarRisk)} + {sizing.exceedsAccount && exceeds account — needs margin} + + ) : ( + Set account size above to size this trade. + )} + {!taking && ( + + )} +
+ + {taking && ( +
-

- Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(setup.target)} · {setup.direction.toUpperCase()} +

+ Stop {formatPrice(setup.stop_loss)} · Target {formatPrice(setup.target)} · {setup.direction.toUpperCase()} paper trade

@@ -308,12 +325,22 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
)} - + {/* Full target ladder — primary is already on the rail */} + {setup.targets && setup.targets.length > 0 && ( +
+ + All targets ({setup.targets.length}) · price / distance / R:R / probability + +
+ +
+
+ )} {setup.conflict_flags.length > 0 && ( -
+

{setup.conflict_flags.join(' • ')} -

+

)}
); @@ -361,7 +388,7 @@ function RiskControls({ risk, update }: { risk: RiskSettings; update: (p: Partia ); } -export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice, nextEarningsDate }: RecommendationPanelProps) { +export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPrice, nextEarningsDate, frameless = false }: RecommendationPanelProps) { const { settings: risk, update: updateRisk } = useRiskSettings(); const regime = useMarketRegime().data; const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary; @@ -392,67 +419,76 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric // above a "no current setup" card. const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null; + const body = ( +
+
+ {preferredInactive ? ( + + No current setup (last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — {preferredInactive.invalidated ? 'invalidated' : 'played out'}) + + ) : ( + {recommendationActionLabel(action)} + )} + + Risk: {summary?.risk_level ?? '—'} + +
+ +
+
+ + {summary?.reasoning && !preferredInactive && ( +

{summary.reasoning}

+ )} + + {earningsDays != null && earningsDays >= 0 && ( + earningsDays <= EARNINGS_HORIZON_DAYS ? ( +

+ ⚠ Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) — inside the ~30-day + target horizon. A report can gap price through your stop or target; consider waiting or sizing down. +

+ ) : ( +

Next earnings: {nextEarningsDate} ({earningsDays} days).

+ ) + )} + + {preferredDirection !== 'neutral' && preferredSetup ? ( +
+ + + {alternativeSetup && ( +
+ + Alternative scenario ({alternativeSetup.direction.toUpperCase()}) + +
+ +
+
+ )} +
+ ) : ( +
+ + +
+ )} +
+ ); + + if (frameless) { + return ( +
+

Recommendation · {symbol.toUpperCase()}

+ {body} +
+ ); + } + return (

Recommendation

-
-
- {preferredInactive ? ( - - No current setup (last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} — {preferredInactive.invalidated ? 'invalidated' : 'played out'}) - - ) : ( - {recommendationActionLabel(action)} - )} - - Risk: {summary?.risk_level ?? '—'} - - Composite: {summary?.composite_score?.toFixed(1) ?? '—'} - {symbol.toUpperCase()} -
- -
-
- -

Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.

- - {summary?.reasoning && !preferredInactive && ( -

{summary.reasoning}

- )} - - {earningsDays != null && earningsDays >= 0 && ( - earningsDays <= EARNINGS_HORIZON_DAYS ? ( -

- ⚠ Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) — inside the ~30-day - target horizon. A report can gap price through your stop or target; consider waiting or sizing down. -

- ) : ( -

Next earnings: {nextEarningsDate} ({earningsDays} days).

- ) - )} - - {preferredDirection !== 'neutral' && preferredSetup ? ( -
- - - {alternativeSetup && ( -
- - Alternative scenario ({alternativeSetup.direction.toUpperCase()}) - -
- -
-
- )} -
- ) : ( -
- - -
- )} -
+
{body}
); } diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index 38e14bc..9ed4554 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -19,7 +19,6 @@ import { IndicatorSelector } from '../components/ticker/IndicatorSelector'; import { RecommendationPanel } from '../components/ticker/RecommendationPanel'; import { Button } from '../components/ui/Button'; import { Callout } from '../components/ui/Callout'; -import { Section } from '../components/ui/Section'; import { formatPrice } from '../lib/format'; import type { TradeSetup } from '../lib/types'; import type { FieldPoint } from '../components/ticker/StandingMatrix'; @@ -27,7 +26,7 @@ import type { FieldPoint } from '../components/ticker/StandingMatrix'; // Lazy so recharts (heavy) ships in its own chunk, not the main ticker bundle. const StandingMatrix = lazy(() => import('../components/ticker/StandingMatrix')); -const detailTabs = ['Analysis', 'Indicators', 'S/R Levels', 'Sentiment', 'Fundamentals'] as const; +const detailTabs = ['Analysis', 'Standing', 'Dimensions', 'Indicators', 'S/R Levels', 'Sentiment', 'Fundamentals'] as const; type DetailTab = (typeof detailTabs)[number]; function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) { @@ -430,6 +429,72 @@ export default function TickerDetailPage() {

)} + {(longSetup || shortSetup) && ( +
+ +
+ )} +
+ )} + + {activeTab === 'Standing' && ( +
+ {scores.isLoading && } + {scores.isError && ( + scores.refetch()} /> + )} + {scores.data && ( + <> + }> + + + {(() => { + const cb = scores.data?.composite_breakdown; + const adj = cb?.sentiment_adjustment; + const base = cb?.base_score; + if (adj == null || base == null || Math.abs(adj) < 0.05) return null; + const composite = scores.data?.composite_score ?? base + adj; + return ( +

+ Composite{' '} + {Math.round(composite)} + {' '}= Base {Math.round(base)}{' '} + {adj >= 0 ? '+' : '−'} Sentiment{' '} + = 0 ? 'text-emerald-300/80' : 'text-red-300/80'}> + {Math.abs(adj).toFixed(1)} + +

+ ); + })()} + + )} +
+ )} + + {activeTab === 'Dimensions' && ( +
+ {scores.isLoading && } + {scores.isError && ( + scores.refetch()} /> + )} + {scores.data && ( + + )}
)} @@ -504,68 +569,6 @@ export default function TickerDetailPage() {
- {activeTab === 'Analysis' && ( -
- -
-
- {scores.isLoading && } - {scores.isError && ( - scores.refetch()} /> - )} - {scores.data && ( - <> - }> - - - {(() => { - const cb = scores.data?.composite_breakdown; - const adj = cb?.sentiment_adjustment; - const base = cb?.base_score; - if (adj == null || base == null || Math.abs(adj) < 0.05) return null; - const composite = scores.data?.composite_score ?? base + adj; - return ( -

- Composite{' '} - {Math.round(composite)} - {' '}= Base {Math.round(base)}{' '} - {adj >= 0 ? '+' : '−'} Sentiment{' '} - = 0 ? 'text-emerald-400/80' : 'text-red-400/80'}> - {Math.abs(adj).toFixed(1)} - -

- ); - })()} - - )} -
- -
- {scores.isLoading && } - {scores.isError && ( - scores.refetch()} /> - )} - {scores.data && ( - - )} -
-
-
- )}
); }