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.
) : (
| Type |
Price Level |
Strength |
Method |
{sortedLevels.map((level) => (
|
{level.type}
|
{formatPrice(level.price_level)} |
{level.strength} |
{level.detection_method} |
))}
)}
)}
);
}