import { useMemo, useEffect, useState, lazy, Suspense } from 'react'; import { useParams } from 'react-router-dom'; import { useTickerDetail } from '../hooks/useTickerDetail'; import { useFetchSymbolData } from '../hooks/useFetchSymbolData'; import { useWatchlist, useAddToWatchlist, useRemoveFromWatchlist } from '../hooks/useWatchlist'; import { useTrades } from '../hooks/useTrades'; import { usePaperTrades } from '../hooks/usePaperTrades'; import { useActivation } from '../hooks/useActivation'; import { topPickSymbol, qualifiesSetup } from '../lib/qualification'; import type { FetchSelector } from '../api/ingestion'; import { CandlestickChart } from '../components/charts/CandlestickChart'; import { ScoreCard } from '../components/ui/ScoreCard'; import { useTickerNames } from '../hooks/useTickers'; import { SkeletonCard } from '../components/ui/Skeleton'; import { SentimentPanel } from '../components/ticker/SentimentPanel'; import { FundamentalsPanel } from '../components/ticker/FundamentalsPanel'; 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 { Tabs } from '../components/ui/Tabs'; import { formatPrice } from '../lib/format'; import type { TradeSetup } from '../lib/types'; 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'] as const; type DetailTab = (typeof detailTabs)[number]; function SectionError({ message, onRetry }: { message: string; onRetry?: () => void }) { return ( {message} ); } function StatusPill({ tone, label, title }: { tone: 'blue' | 'emerald'; label: string; title?: string }) { const tones = { blue: 'bg-blue-500/15 text-blue-300 border-blue-500/30', emerald: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30', } as const; return ( {label} ); } function timeAgo(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const mins = Math.floor(diff / 60_000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); return `${days}d ago`; } interface DataStatusItem { label: string; available: boolean; timestamp?: string | null; selector: FetchSelector; // what a refresh of this row fetches paid?: boolean; // provider call that may cost money/quota } function RefreshIcon({ spinning }: { spinning: boolean }) { return ( ); } function DataFreshnessBar({ items, onRefresh, pendingLabel, busy, }: { items: DataStatusItem[]; onRefresh: (item: DataStatusItem) => void; pendingLabel: string | null; busy: boolean; }) { return (
{items.map((item) => (
{item.label} {item.available && item.timestamp ? ( {timeAgo(item.timestamp)} ) : !item.available ? ( no data ) : null} {item.paid && $}
))}
); } export default function TickerDetailPage() { const { symbol = '' } = useParams<{ symbol: string }>(); const companyName = useTickerNames().get(symbol.toUpperCase()); const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol); const ingestion = useFetchSymbolData(); const watchlist = useWatchlist(); const addToWatchlist = useAddToWatchlist(); const removeFromWatchlist = useRemoveFromWatchlist(); const onWatchlist = useMemo( () => (watchlist.data ?? []).some((e) => e.symbol.toUpperCase() === symbol.toUpperCase()), [watchlist.data, symbol], ); const watchlistBusy = addToWatchlist.isPending || removeFromWatchlist.isPending; // Status labels: is there an open paper trade on this ticker, and is it the // current top pick (same ranking the dashboard highlights)? const openTrades = usePaperTrades('open'); const allTrades = useTrades(); const activation = useActivation(); const hasOpenTrade = useMemo( () => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()), [openTrades.data, symbol], ); const isTopPick = useMemo( () => topPickSymbol(allTrades.data, activation.data)?.toUpperCase() === symbol.toUpperCase(), [allTrades.data, activation.data, symbol], ); const [activeTab, setActiveTab] = useState('Analysis'); const [refreshingLabel, setRefreshingLabel] = useState(null); const dataStatus: DataStatusItem[] = useMemo(() => [ { label: 'OHLCV', available: !!ohlcv.data && ohlcv.data.length > 0, timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at, selector: ['ohlcv'] as FetchSelector, paid: true, }, { label: 'Sentiment', available: !!sentiment.data && sentiment.data.count > 0, timestamp: sentiment.data?.scores?.[0]?.timestamp, selector: ['sentiment'] as FetchSelector, paid: true, }, { label: 'Fundamentals', available: !!fundamentals.data && fundamentals.data.fetched_at !== null, timestamp: fundamentals.data?.fetched_at, selector: ['fundamentals'] as FetchSelector, paid: true, }, { label: 'S/R Levels', available: !!srLevels.data && srLevels.data.count > 0, timestamp: srLevels.data?.levels?.[0]?.created_at, selector: 'recompute' as FetchSelector, }, { label: 'Scores', available: !!scores.data && scores.data.composite_score !== null, timestamp: scores.data?.computed_at, selector: 'recompute' as FetchSelector, }, ], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]); const handleRefresh = (item: DataStatusItem) => { setRefreshingLabel(item.label); ingestion.mutate( { symbol, sources: item.selector }, { onSettled: () => setRefreshingLabel(null) }, ); }; // Log trades API errors but don't disrupt the page useEffect(() => { if (trades.error) { console.error('Failed to fetch trade setups:', trades.error); } }, [trades.error]); const setupsForSymbol: TradeSetup[] = useMemo(() => { if (trades.error || !trades.data) return []; return trades.data.filter((t) => t.symbol.toUpperCase() === symbol.toUpperCase()); }, [trades.data, trades.error, symbol]); const longSetup = useMemo( () => setupsForSymbol?.find((s) => s.direction === 'long'), [setupsForSymbol], ); const shortSetup = useMemo( () => setupsForSymbol?.find((s) => s.direction === 'short'), [setupsForSymbol], ); // Standing matrix: this ticker's residual momentum percentile + long confidence (from its // setup), the field (every ticker's composite × momentum) for the cloud, and // whether it qualifies / is the top pick. const myMomentum = longSetup?.momentum_percentile ?? shortSetup?.momentum_percentile ?? null; const myConfidence = longSetup?.confidence_score ?? null; const standingField = useMemo(() => { const seen = new Set(); const out: FieldPoint[] = []; for (const t of allTrades.data ?? []) { const s = t.symbol.toUpperCase(); if (seen.has(s) || t.momentum_percentile == null) continue; seen.add(s); out.push({ symbol: s, composite: t.composite_score, momentum: t.momentum_percentile }); } return out; }, [allTrades.data]); const standingStatus: 'top-pick' | 'qualified' | 'none' = useMemo(() => { if (isTopPick) return 'top-pick'; if (longSetup && activation.data && qualifiesSetup(longSetup, activation.data)) return 'qualified'; return 'none'; }, [isTopPick, longSetup, activation.data]); const gateMomentum = activation.data?.min_momentum_percentile ?? 80; // Current price = latest close, with day-over-day change const priceInfo = useMemo(() => { const bars = ohlcv.data; if (!bars || bars.length === 0) return null; const last = bars[bars.length - 1]; const prev = bars.length > 1 ? bars[bars.length - 2] : null; const change = prev && prev.close ? ((last.close - prev.close) / prev.close) * 100 : null; return { price: last.close, date: last.date, change }; }, [ohlcv.data]); // Which setup the chart overlays. 'auto' = the ticker's preferred direction. const [overlayChoice, setOverlayChoice] = useState<'auto' | 'long' | 'short' | 'none'>('auto'); const action = (longSetup ?? shortSetup)?.recommended_action ?? null; const overlaySetup: TradeSetup | undefined = useMemo(() => { if (overlayChoice === 'none') return undefined; if (overlayChoice === 'long') return longSetup; if (overlayChoice === 'short') return shortSetup; // auto: preferred direction's setup, else the highest-confidence available if (action?.startsWith('LONG') && longSetup) return longSetup; if (action?.startsWith('SHORT') && shortSetup) return shortSetup; const candidates = [longSetup, shortSetup].filter(Boolean) as TradeSetup[]; return candidates.sort((a, b) => (b.confidence_score ?? 0) - (a.confidence_score ?? 0))[0]; }, [overlayChoice, longSetup, shortSetup, action]); // Sort visible S/R levels by strength for the table (only levels within chart zones) const sortedLevels = useMemo(() => { if (!srLevels.data?.visible_levels) return []; return [...srLevels.data.visible_levels].sort((a, b) => b.strength - a.strength); }, [srLevels.data]); return (
{/* Header */}

{symbol.toUpperCase()}

{companyName && ( {companyName} )} {priceInfo && (
{formatPrice(priceInfo.price)} {priceInfo.change !== null && ( = 0 ? 'text-emerald-400' : 'text-red-400'}`}> {priceInfo.change >= 0 ? '+' : ''}{priceInfo.change.toFixed(2)}% )} last close · {timeAgo(priceInfo.date)}
)}
{isTopPick && ( )} {hasOpenTrade && ( )}
{/* Data freshness bar */} {/* Chart — always visible */}
{ohlcv.isLoading && } {ohlcv.isError && ( ohlcv.refetch()} /> )} {ohlcv.data && (
{(longSetup || shortSetup) && (
Overlay setup: {([ { key: 'auto', label: 'Auto', show: true }, { key: 'long', label: 'Long', show: !!longSetup }, { key: 'short', label: 'Short', show: !!shortSetup }, { key: 'none', label: 'None', show: true }, ] as const).filter((o) => o.show).map((o) => ( ))} {overlaySetup && ( showing {overlaySetup.direction.toUpperCase()} · entry {formatPrice(overlaySetup.entry_price)} → target {formatPrice(overlaySetup.target)} )}
)}

Only the nearest support & resistance are drawn. Full list in the S/R Levels tab. {srLevels.isError && ' S/R levels unavailable.'}

)}
{/* Detail tabs */} {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 && ( )}
{sentiment.isLoading && } {sentiment.isError && ( sentiment.refetch()} /> )} {sentiment.data && }
{fundamentals.isLoading && } {fundamentals.isError && ( fundamentals.refetch()} /> )} {fundamentals.data && }
)} {activeTab === 'Indicators' && (
)} {activeTab === 'S/R Levels' && (
{sortedLevels.length === 0 ? ( No S/R levels detected for this ticker yet. ) : (
{sortedLevels.map((level) => ( ))}
Type Price Level Strength Method
{level.type} {formatPrice(level.price_level)} {level.strength} {level.detection_method}
)}
)}
); }