);
}
export default function TickerDetailPage() {
const { symbol = '' } = useParams<{ symbol: string }>();
const [showGateTraffic, setShowGateTraffic] = useState(false);
const companyName = useTickerNames().get(symbol.toUpperCase());
const {
ohlcv,
scores,
srLevels,
gateTargetLadder,
sentiment,
fundamentals,
trades,
} = useTickerDetail(symbol, showGateTraffic);
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');
// Full history for chart entry/exit arrows (open + closed on this symbol).
const paperTradeHistory = usePaperTrades();
const allTrades = useTrades();
const activation = useActivation();
const hasOpenTrade = useMemo(
() => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()),
[openTrades.data, symbol],
);
const symbolPaperTrades = useMemo(
() =>
(paperTradeHistory.data ?? []).filter(
(t) => t.symbol.toUpperCase() === symbol.toUpperCase(),
),
[paperTradeHistory.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',
// Keep the market session date distinct from the last successful bar
// write; treating YYYY-MM-DD as an instant makes today's session look old.
available: !!ohlcv.data && ohlcv.data.length > 0,
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.date,
timestampLabel: ohlcv.data?.length
? formatOHLCVFreshness(
ohlcv.data[ohlcv.data.length - 1].date,
ohlcv.data[ohlcv.data.length - 1].created_at,
)
: null,
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,
},
{
// Rebuilt for the whole universe by the nightly SEC + Dolt imports —
// there is no per-ticker fetch to offer here.
label: 'Fundamentals',
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
timestamp: fundamentals.data?.fetched_at,
},
{
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) => {
if (!item.selector) return;
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');
// Target chosen in the recommendation ladder, per direction — the chart
// overlay follows it.
const [chosenTargets, setChosenTargets] = useState<{ long: number | null; short: number | null }>({
long: null,
short: null,
});
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]);
// Chart overlay with the ladder-selected target applied (R:R from the ladder).
const overlayWithTarget: TradeSetup | undefined = useMemo(() => {
if (!overlaySetup) return undefined;
const chosen = chosenTargets[overlaySetup.direction as 'long' | 'short'];
if (chosen == null || chosen === overlaySetup.target) return overlaySetup;
const ladder = overlaySetup.targets?.find((t) => t.price === chosen);
return { ...overlaySetup, target: chosen, rr_ratio: ladder?.rr_ratio ?? overlaySetup.rr_ratio };
}, [overlaySetup, chosenTargets]);
// 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 (
{/* Drill-down header — identity + chips left, score fingerprint right */}
{/* Data freshness — the very first row: how fresh is what I'm looking at */}
Only the nearest support & resistance are drawn. Full list in the S/R Levels tab.
{srLevels.isError && ' S/R levels unavailable.'}
{gateTargetLadder.isError && ' GTL diagnostic unavailable.'}
{symbolPaperTrades.length > 0 && (
<>
{' '}
▼ paper entry
{' · '}
▲ paper exit
>
)}