Recommendation module in Horizon language; Standing/Dimensions as tabs
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 1m4s
Deploy / deploy (push) Successful in 37s

RecommendationPanel redesigned to match the rest of the redesign:

- Setup cards use the spatial price rail (stop -> entry -> now ->
  target with live R-multiples) instead of the label/value text grid.
- One quiet chip row per card (direction, preferred marker, confidence,
  R:R, target probability) replaces scattered header text.
- Position sizing is a single mono line (shares / position / max loss)
  instead of a boxed 3-column mini-table; Mark-as-taken flow kept, in
  the standard cyan button style.
- Full target ladder moves behind a disclosure (the primary target is
  already on the rail); warnings (counter-trend, stale/invalidated
  drift, earnings window, conflict flags) all kept.
- Cards are quiet outlined blocks, no glass-in-glass; the module
  renders frameless inside the unified ticker panel's Analysis tab,
  under the chart.

Ticker panel tabs now: Analysis, Standing, Dimensions, Indicators,
S/R Levels, Sentiment, Fundamentals - Standing and Dimensions were the
last below-the-fold sections; the whole page is now the single panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 08:32:03 +02:00
co-authored by Claude Fable 5
parent fc0945367a
commit d806ede07e
2 changed files with 260 additions and 221 deletions
@@ -7,6 +7,8 @@ import { useRiskSettings, type RiskSettings } from '../../hooks/useRiskSettings'
import { positionSize } from '../../lib/position'; import { positionSize } from '../../lib/position';
import { useMarketRegime } from '../../hooks/useMarketRegime'; import { useMarketRegime } from '../../hooks/useMarketRegime';
import { isCounterTrend } from '../../lib/regime'; import { isCounterTrend } from '../../lib/regime';
import { primaryTargetProbability } from '../../lib/qualification';
import { PriceRail } from '../charts/horizon';
import type { MarketRegime } from '../../lib/types'; import type { MarketRegime } from '../../lib/types';
interface RecommendationPanelProps { interface RecommendationPanelProps {
@@ -15,6 +17,8 @@ interface RecommendationPanelProps {
shortSetup?: TradeSetup; shortSetup?: TradeSetup;
currentPrice?: number; currentPrice?: number;
nextEarningsDate?: string | null; 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). */ /** 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']) { 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 === 'Medium') return 'text-amber-400';
if (risk === 'High') return 'text-red-400'; if (risk === 'High') return 'text-red-300';
return 'text-gray-400'; return 'text-gray-400';
} }
@@ -77,6 +81,25 @@ function isRecommended(setup: TradeSetup | undefined, action: TradeSetup['recomm
return action.startsWith('SHORT'); return action.startsWith('SHORT');
} }
function DirTag({ direction }: { direction: string }) {
const isLong = direction === 'long';
return (
<span className={`num inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
isLong ? 'bg-emerald-500/15 text-emerald-300' : 'bg-red-500/15 text-red-300'
}`}>
{direction}
</span>
);
}
function Chip({ children }: { children: React.ReactNode }) {
return (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11px] text-gray-400">
{children}
</span>
);
}
function TargetTable({ setup }: { setup: TradeSetup }) { function TargetTable({ setup }: { setup: TradeSetup }) {
if (!setup.targets || setup.targets.length === 0) { if (!setup.targets || setup.targets.length === 0) {
return <p className="text-xs text-gray-500">No target probabilities available.</p>; return <p className="text-xs text-gray-500">No target probabilities available.</p>;
@@ -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 }) { function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: TradeSetup; action?: TradeSetup['recommended_action']; currentPrice?: number; risk: RiskSettings; regime?: MarketRegime }) {
if (!setup) { if (!setup) {
return ( return (
<div className="glass-sm p-4 text-xs text-gray-500"> <div className="rounded-xl border border-white/[0.07] p-4 text-xs text-gray-500">
Setup unavailable for this direction. Setup unavailable for this direction.
</div> </div>
); );
@@ -129,6 +152,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
const drift = entryDrift(setup, currentPrice); const drift = entryDrift(setup, currentPrice);
const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss); const sizing = positionSize(risk.accountSize, risk.riskPct, setup.entry_price, setup.stop_loss);
const counterTrend = regime ? isCounterTrend(setup.direction, regime.label) : false; 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 // 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" // (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) { if (notActionable) {
const dir = setup.direction.toUpperCase(); const dir = setup.direction.toUpperCase();
return ( return (
<div data-direction={setup.direction} className="glass-sm p-4 space-y-2"> <div data-direction={setup.direction} className="rounded-xl border border-white/[0.07] p-4">
<div className="flex items-center justify-between"> <div className="flex flex-wrap items-center gap-2">
<h4 className={`text-sm font-semibold ${setup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}> <DirTag direction={setup.direction} />
{dir} <span className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">no current setup</span>
</h4> <span className="num ml-auto text-xs text-gray-500">
<span className="text-[10px] uppercase tracking-wider text-gray-500">No current setup</span> now {currentPrice != null ? formatPrice(currentPrice) : '—'} · last entry {formatPrice(setup.entry_price)}
{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''} · last target {formatPrice(setup.target)}
</span>
</div> </div>
<p className="text-[11px] text-gray-400"> <p className="mt-2 text-[11.5px] leading-relaxed text-gray-400">
{invalidated {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 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 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.`}
</p> </p>
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs">
<div className="text-gray-500">Current</div><div className="font-mono text-gray-300">{currentPrice != null ? formatPrice(currentPrice) : '—'}</div>
<div className="text-gray-500">Last entry</div><div className="font-mono text-gray-400">{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}</div>
<div className="text-gray-500">Last target</div><div className="font-mono text-gray-400">{formatPrice(setup.target)}</div>
</div>
</div> </div>
); );
} }
@@ -183,28 +204,35 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
return ( return (
<div <div
data-direction={setup.direction} data-direction={setup.direction}
className={`glass-sm p-4 space-y-3 ${recommended ? 'border border-emerald-500/40' : 'opacity-80'}`} className={`rounded-xl border p-4 ${recommended ? 'border-blue-400/25' : 'border-white/[0.07] opacity-80'}`}
> >
<div className="flex items-center justify-between"> {/* Identity + key stats in one quiet row */}
<h4 className={`text-sm font-semibold ${setup.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}> <div className="flex flex-wrap items-center gap-2">
{setup.direction.toUpperCase()} <DirTag direction={setup.direction} />
</h4> {recommended && (
<span className="text-xs text-gray-300">{setup.confidence_score?.toFixed(1) ?? '—'}%</span> <span className="num rounded-full border border-[#ff6a45]/40 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.18em] text-[#ff6a45]">
preferred
</span>
)}
<Chip>confidence {setup.confidence_score?.toFixed(0) ?? '—'}%</Chip>
<Chip>R:R {setup.rr_ratio.toFixed(1)}:1</Chip>
{prob != null && <Chip>target prob {Math.round(prob)}%</Chip>}
</div> </div>
{/* Warnings — only when they apply */}
{(counterTrend || (!recommended && recommendationActionDirection(action ?? null) !== 'neutral') || drift?.status !== 'fresh') && (
<div className="mt-2.5 space-y-1">
{!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && ( {!recommended && recommendationActionDirection(action ?? null) !== 'neutral' && (
<p className="text-[11px] text-amber-400">Alternative setup (ticker bias currently favors the opposite direction).</p> <p className="text-[11px] text-amber-400">Alternative setup the ticker bias currently favors the opposite direction.</p>
)} )}
{counterTrend && regime && ( {counterTrend && regime && (
<p className="text-[11px] text-amber-400"> <p className="text-[11px] text-amber-400">
Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market Counter-trend: {setup.direction.toUpperCase()} against a {regime.label} market
({regime.benchmark ?? 'SPY'}). Lower odds size down or wait for confirmation. ({regime.benchmark ?? 'SPY'}). Lower odds size down or wait for confirmation.
</p> </p>
)} )}
{drift && drift.status === 'invalidated' && ( {drift && drift.status === 'invalidated' && (
<p className="text-[11px] text-red-400"> <p className="text-[11px] text-red-300">
Price ({formatPrice(currentPrice!)}) is past the stop this setup is invalidated. Price ({formatPrice(currentPrice!)}) is past the stop this setup is invalidated.
</p> </p>
)} )}
@@ -215,58 +243,47 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
: `⚠ Price has moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`} : `⚠ Price has moved ${Math.abs(drift.pct).toFixed(1)}% against the setup (toward the stop) — entry may be stale.`}
</p> </p>
)} )}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-gray-500">Current</div><div className="font-mono text-gray-200">{currentPrice != null ? formatPrice(currentPrice) : '—'}</div>
<div className="text-gray-500">Entry</div><div className="font-mono text-gray-200">{formatPrice(setup.entry_price)}{drift ? ` (${drift.pct >= 0 ? '+' : ''}${drift.pct.toFixed(1)}%)` : ''}</div>
<div className="text-gray-500">Stop</div><div className="font-mono text-gray-200">{formatPrice(setup.stop_loss)}</div>
<div className="text-gray-500">Primary Target</div><div className="font-mono text-gray-200">{formatPrice(setup.target)}</div>
<div className="text-gray-500">R:R</div><div className="font-mono text-gray-200">{setup.rr_ratio.toFixed(2)}</div>
</div> </div>
)}
{/* The setup, spatially — stop → entry → now → target */}
<PriceRail
direction={setup.direction}
entry={setup.entry_price}
stop={setup.stop_loss}
target={setup.target}
current={currentPrice ?? null}
/>
{/* Sizing + take, one line */}
<div className="mt-1 flex flex-wrap items-center justify-between gap-3 border-t border-white/[0.06] pt-3">
{sizing ? ( {sizing ? (
<div className="rounded border border-white/[0.06] bg-white/[0.02] p-2.5 text-xs"> <span className="num text-xs text-gray-400">
<p className="mb-1.5 text-[10px] uppercase tracking-wider text-gray-500"> {sizing.shares} shares · {formatPrice(sizing.positionValue)} position · max loss {formatPrice(sizing.dollarRisk)}
Position size · {risk.riskPct}% of {formatPrice(risk.accountSize)} {sizing.exceedsAccount && <span className="ml-2 text-amber-400">exceeds account needs margin</span>}
</p> </span>
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<div className="font-mono text-sm text-gray-100">{sizing.shares}</div>
<div className="text-[10px] text-gray-500">shares</div>
</div>
<div>
<div className={`font-mono text-sm ${sizing.exceedsAccount ? 'text-amber-400' : 'text-gray-100'}`}>{formatPrice(sizing.positionValue)}</div>
<div className="text-[10px] text-gray-500">position</div>
</div>
<div>
<div className="font-mono text-sm text-gray-100">{formatPrice(sizing.dollarRisk)}</div>
<div className="text-[10px] text-gray-500">max loss</div>
</div>
</div>
{sizing.exceedsAccount && (
<p className="mt-1.5 text-[10px] text-amber-400">Position exceeds account needs margin.</p>
)}
</div>
) : ( ) : (
<p className="text-[11px] text-gray-600">Set account size below to size this trade.</p> <span className="text-[11px] text-gray-600">Set account size above to size this trade.</span>
)} )}
{!taking && (
{!taking ? (
<button <button
onClick={() => { onClick={() => {
setTakeShares(sizing?.shares ?? 0); setTakeShares(sizing?.shares ?? 0);
setTakeEntry(currentPrice ?? setup.entry_price); setTakeEntry(currentPrice ?? setup.entry_price);
setTaking(true); setTaking(true);
}} }}
className="w-full rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-300 transition-colors hover:bg-emerald-500/20" className="rounded-lg border border-blue-500/35 bg-blue-500/15 px-3.5 py-1.5 text-xs font-semibold text-blue-300 transition-colors hover:bg-blue-500/25"
> >
+ Mark as taken (paper trade) Mark as taken
</button> </button>
) : ( )}
<div className="rounded-md border border-white/[0.08] bg-white/[0.02] p-2.5 space-y-2"> </div>
{taking && (
<div className="mt-3 rounded-lg border border-white/[0.08] p-3 space-y-2.5">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<label className="block space-y-1"> <label className="block space-y-1">
<span className="text-[10px] uppercase tracking-wider text-gray-500">Shares</span> <span className="num text-[10px] uppercase tracking-wider text-gray-500">Shares</span>
<input <input
type="number" type="number"
min={0} min={0}
@@ -276,7 +293,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
/> />
</label> </label>
<label className="block space-y-1"> <label className="block space-y-1">
<span className="text-[10px] uppercase tracking-wider text-gray-500">Entry</span> <span className="num text-[10px] uppercase tracking-wider text-gray-500">Entry</span>
<input <input
type="number" type="number"
min={0} min={0}
@@ -287,20 +304,20 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
/> />
</label> </label>
</div> </div>
<p className="text-[10px] text-gray-500"> <p className="num text-[10px] text-gray-500">
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
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={confirmTake} onClick={confirmTake}
disabled={createTrade.isPending || !(takeShares > 0) || !(takeEntry > 0)} disabled={createTrade.isPending || !(takeShares > 0) || !(takeEntry > 0)}
className="flex-1 rounded-md bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-300 hover:bg-emerald-500/30 disabled:opacity-50" className="flex-1 rounded-lg border border-blue-500/35 bg-blue-500/15 px-3 py-1.5 text-xs font-semibold text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
> >
{createTrade.isPending ? 'Taking…' : 'Confirm'} {createTrade.isPending ? 'Taking…' : 'Confirm'}
</button> </button>
<button <button
onClick={() => setTaking(false)} onClick={() => setTaking(false)}
className="rounded-md border border-white/[0.08] px-3 py-1.5 text-xs text-gray-400 hover:text-gray-200" className="rounded-lg border border-white/[0.08] px-3 py-1.5 text-xs text-gray-400 transition-colors hover:text-gray-200"
> >
Cancel Cancel
</button> </button>
@@ -308,12 +325,22 @@ function SetupCard({ setup, action, currentPrice, risk, regime }: { setup?: Trad
</div> </div>
)} )}
{/* Full target ladder — primary is already on the rail */}
{setup.targets && setup.targets.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-[11px] font-medium text-gray-500 transition-colors hover:text-gray-300">
All targets ({setup.targets.length}) · price / distance / R:R / probability
</summary>
<div className="mt-2">
<TargetTable setup={setup} /> <TargetTable setup={setup} />
</div>
</details>
)}
{setup.conflict_flags.length > 0 && ( {setup.conflict_flags.length > 0 && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-2 text-[11px] text-amber-300"> <p className="mt-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
{setup.conflict_flags.join(' • ')} {setup.conflict_flags.join(' • ')}
</div> </p>
)} )}
</div> </div>
); );
@@ -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 { settings: risk, update: updateRisk } = useRiskSettings();
const regime = useMarketRegime().data; const regime = useMarketRegime().data;
const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary; const summary = longSetup?.recommendation_summary ?? shortSetup?.recommendation_summary;
@@ -392,11 +419,9 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
// above a "no current setup" card. // above a "no current setup" card.
const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null; const preferredInactive = preferredSetup ? notActionableState(preferredSetup, currentPrice) : null;
return ( const body = (
<section> <div className="space-y-4">
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Recommendation</h2> <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="glass p-5 space-y-4">
<div className="flex flex-wrap items-center gap-4">
{preferredInactive ? ( {preferredInactive ? (
<span className="text-sm font-semibold text-gray-400"> <span className="text-sm font-semibold text-gray-400">
No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} {preferredInactive.invalidated ? 'invalidated' : 'played out'})</span> No current setup <span className="font-normal text-gray-500">(last {preferredDirection} bias {recommendationActionLabel(action).toLowerCase()} {preferredInactive.invalidated ? 'invalidated' : 'played out'})</span>
@@ -407,22 +432,18 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
<span className={`text-sm font-semibold ${riskClass(summary?.risk_level ?? null)}`}> <span className={`text-sm font-semibold ${riskClass(summary?.risk_level ?? null)}`}>
Risk: {summary?.risk_level ?? '—'} Risk: {summary?.risk_level ?? '—'}
</span> </span>
<span className="text-sm text-gray-300">Composite: {summary?.composite_score?.toFixed(1) ?? '—'}</span>
<span className="text-xs text-gray-500">{symbol.toUpperCase()}</span>
<div className="ml-auto"> <div className="ml-auto">
<RiskControls risk={risk} update={updateRisk} /> <RiskControls risk={risk} update={updateRisk} />
</div> </div>
</div> </div>
<p className="text-xs text-gray-500">Recommended Action is the ticker-level bias. The preferred setup is shown first; the opposite side is available under Alternative scenario.</p>
{summary?.reasoning && !preferredInactive && ( {summary?.reasoning && !preferredInactive && (
<p className="text-sm text-gray-300">{summary.reasoning}</p> <p className="text-sm leading-relaxed text-gray-300">{summary.reasoning}</p>
)} )}
{earningsDays != null && earningsDays >= 0 && ( {earningsDays != null && earningsDays >= 0 && (
earningsDays <= EARNINGS_HORIZON_DAYS ? ( earningsDays <= EARNINGS_HORIZON_DAYS ? (
<p className="rounded border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300"> <p className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-300">
Earnings in {earningsDays} day{earningsDays === 1 ? '' : 's'} ({nextEarningsDate}) inside the ~30-day 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. target horizon. A report can gap price through your stop or target; consider waiting or sizing down.
</p> </p>
@@ -436,8 +457,8 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
<SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} /> <SetupCard setup={preferredSetup} action={action} currentPrice={currentPrice} risk={risk} regime={regime} />
{alternativeSetup && ( {alternativeSetup && (
<details className="glass-sm p-3"> <details>
<summary className="cursor-pointer text-xs font-medium text-gray-300"> <summary className="cursor-pointer text-xs font-medium text-gray-500 transition-colors hover:text-gray-300">
Alternative scenario ({alternativeSetup.direction.toUpperCase()}) Alternative scenario ({alternativeSetup.direction.toUpperCase()})
</summary> </summary>
<div className="mt-3"> <div className="mt-3">
@@ -453,6 +474,21 @@ export function RecommendationPanel({ symbol, longSetup, shortSetup, currentPric
</div> </div>
)} )}
</div> </div>
);
if (frameless) {
return (
<div>
<p className="section-index mb-3">Recommendation · {symbol.toUpperCase()}</p>
{body}
</div>
);
}
return (
<section>
<h2 className="mb-3 text-xs font-medium uppercase tracking-widest text-gray-500">Recommendation</h2>
<div className="glass p-5 space-y-4">{body}</div>
</section> </section>
); );
} }
+67 -64
View File
@@ -19,7 +19,6 @@ import { IndicatorSelector } from '../components/ticker/IndicatorSelector';
import { RecommendationPanel } from '../components/ticker/RecommendationPanel'; import { RecommendationPanel } from '../components/ticker/RecommendationPanel';
import { Button } from '../components/ui/Button'; import { Button } from '../components/ui/Button';
import { Callout } from '../components/ui/Callout'; import { Callout } from '../components/ui/Callout';
import { Section } from '../components/ui/Section';
import { formatPrice } from '../lib/format'; import { formatPrice } from '../lib/format';
import type { TradeSetup } from '../lib/types'; import type { TradeSetup } from '../lib/types';
import type { FieldPoint } from '../components/ticker/StandingMatrix'; 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. // Lazy so recharts (heavy) ships in its own chunk, not the main ticker bundle.
const StandingMatrix = lazy(() => import('../components/ticker/StandingMatrix')); 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]; type DetailTab = (typeof detailTabs)[number];
function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) { function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) {
@@ -429,6 +428,72 @@ export default function TickerDetailPage() {
{srLevels.isError && ' S/R levels unavailable.'} {srLevels.isError && ' S/R levels unavailable.'}
</p> </p>
</> </>
)}
{(longSetup || shortSetup) && (
<div className="mt-6 border-t border-white/[0.06] pt-5">
<RecommendationPanel
frameless
symbol={symbol}
longSetup={longSetup}
shortSetup={shortSetup}
currentPrice={priceInfo?.price}
nextEarningsDate={fundamentals.data?.next_earnings_date}
/>
</div>
)}
</div>
)}
{activeTab === 'Standing' && (
<div className="hz-frameless animate-fade-in">
{scores.isLoading && <SkeletonCard className="h-80" />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<StandingMatrix
symbol={symbol}
composite={scores.data.composite_score}
momentum={myMomentum}
field={standingField}
gateMomentum={gateMomentum}
status={standingStatus}
confidence={myConfidence}
/>
</Suspense>
{(() => {
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 (
<p className="mt-3 text-center text-[11px] text-gray-500">
Composite{' '}
<span className="font-semibold text-gray-300">{Math.round(composite)}</span>
{' '}= Base {Math.round(base)}{' '}
{adj >= 0 ? '+' : ''} Sentiment{' '}
<span className={adj >= 0 ? 'text-emerald-300/80' : 'text-red-300/80'}>
{Math.abs(adj).toFixed(1)}
</span>
</p>
);
})()}
</>
)}
</div>
)}
{activeTab === 'Dimensions' && (
<div className="hz-frameless animate-fade-in">
{scores.isLoading && <SkeletonCard />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<ScoreCard showComposite={false} showRadar={false} compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
)} )}
</div> </div>
)} )}
@@ -504,68 +569,6 @@ export default function TickerDetailPage() {
</div> </div>
</section> </section>
{activeTab === 'Analysis' && (
<div className="space-y-6 animate-fade-in">
<RecommendationPanel
symbol={symbol}
longSetup={longSetup}
shortSetup={shortSetup}
currentPrice={priceInfo?.price}
nextEarningsDate={fundamentals.data?.next_earnings_date}
/>
<div className="grid items-start gap-6 lg:grid-cols-2">
<Section title="Standing" hint="how this ticker ranks vs. the field">
{scores.isLoading && <SkeletonCard className="h-80" />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<StandingMatrix
symbol={symbol}
composite={scores.data.composite_score}
momentum={myMomentum}
field={standingField}
gateMomentum={gateMomentum}
status={standingStatus}
confidence={myConfidence}
/>
</Suspense>
{(() => {
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 (
<p className="mt-3 text-center text-[11px] text-gray-500">
Composite{' '}
<span className="font-semibold text-gray-300">{Math.round(composite)}</span>
{' '}= Base {Math.round(base)}{' '}
{adj >= 0 ? '+' : ''} Sentiment{' '}
<span className={adj >= 0 ? 'text-emerald-400/80' : 'text-red-400/80'}>
{Math.abs(adj).toFixed(1)}
</span>
</p>
);
})()}
</>
)}
</Section>
<Section title="Dimensions" hint="fingerprint values in the header · expand a row for the formula">
{scores.isLoading && <SkeletonCard />}
{scores.isError && (
<SectionError message={scores.error instanceof Error ? scores.error.message : 'Failed to load scores'} onRetry={() => scores.refetch()} />
)}
{scores.data && (
<ScoreCard showComposite={false} showRadar={false} compositeScore={scores.data.composite_score} dimensions={scores.data.dimensions} compositeBreakdown={scores.data.composite_breakdown} />
)}
</Section>
</div>
</div>
)}
</div> </div>
); );
} }