import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { Star, Bell, BarChart2, Loader2 } from 'lucide-react'; import { AreaChart, Area, Bar, ComposedChart, Line, LineChart, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine, } from 'recharts'; import { useAppContext } from '../context/AppContext'; import { fetchChartBars, fetchResearchProfile, fetchResearchMetrics, fetchResearchEarnings, type OHLCVBar, } from '../lib/marketApi'; import { SkeletonBlock, SkeletonText } from '../components/Skeleton'; // ─── Time period config ─────────────────────────────────────────────────────── const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const; type Period = typeof PERIODS[number]; type IndicatorKey = 'rsi' | 'macd' | 'bollinger'; interface ChartPoint { ts: number; price: number; label: string; bollingerUpper?: number; bollingerMiddle?: number; bollingerLower?: number; rsi?: number; macd?: number; macdSignal?: number; macdHistogram?: number; } interface ResearchProfile { companyName?: string; sector?: string; industry?: string; description?: string; website?: string; mktCap?: number; revenue?: number; exchangeShortName?: string; } const INDICATORS: Array<{ key: IndicatorKey; label: string; hint: string }> = [ { key: 'rsi', label: 'RSI', hint: '14-period momentum' }, { key: 'macd', label: 'MACD', hint: '12/26 EMA trend' }, { key: 'bollinger', label: 'Bollinger', hint: '20-period bands' }, ]; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatPriceLabel(ts: number, period: Period) { const d = new Date(ts); if (period === '1D') return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (period === '5D') return d.toLocaleDateString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } function formatAsOfTimestamp(ts: number | null) { if (ts == null) return 'Latest bar pending'; return new Date(ts).toLocaleString('en-US', { timeZone: 'America/New_York', month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', }); } function average(values: number[]) { return values.reduce((sum, value) => sum + value, 0) / values.length; } function calculateRsi(closes: number[], period = 14): Array { const rsi: Array = Array(closes.length).fill(undefined); if (closes.length <= period) return rsi; const fromAverages = (gain: number, loss: number) => { if (gain === 0 && loss === 0) return 50; if (loss === 0) return 100; return 100 - (100 / (1 + gain / loss)); }; let gainSum = 0; let lossSum = 0; for (let i = 1; i <= period; i += 1) { const change = closes[i] - closes[i - 1]; gainSum += Math.max(change, 0); lossSum += Math.max(-change, 0); } let averageGain = gainSum / period; let averageLoss = lossSum / period; rsi[period] = fromAverages(averageGain, averageLoss); for (let i = period + 1; i < closes.length; i += 1) { const change = closes[i] - closes[i - 1]; averageGain = ((averageGain * (period - 1)) + Math.max(change, 0)) / period; averageLoss = ((averageLoss * (period - 1)) + Math.max(-change, 0)) / period; rsi[i] = fromAverages(averageGain, averageLoss); } return rsi; } function calculateEma(values: number[], period: number): Array { const ema: Array = Array(values.length).fill(undefined); if (values.length < period) return ema; const multiplier = 2 / (period + 1); ema[period - 1] = average(values.slice(0, period)); for (let i = period; i < values.length; i += 1) { ema[i] = (values[i] - ema[i - 1]!) * multiplier + ema[i - 1]!; } return ema; } function calculateMacd(closes: number[]) { const fast = calculateEma(closes, 12); const slow = calculateEma(closes, 26); const macd: Array = closes.map((_, i) => ( fast[i] != null && slow[i] != null ? fast[i]! - slow[i]! : undefined )); const signal: Array = Array(closes.length).fill(undefined); const signalPeriod = 9; const signalMultiplier = 2 / (signalPeriod + 1); for (let i = 0; i < macd.length; i += 1) { if (macd[i] == null) continue; const recentMacd = macd.slice(0, i + 1).filter((value): value is number => value != null); if (recentMacd.length < signalPeriod) continue; const previousSignal = signal[i - 1]; signal[i] = previousSignal == null ? average(recentMacd.slice(-signalPeriod)) : (macd[i]! - previousSignal) * signalMultiplier + previousSignal; } const histogram = macd.map((value, i) => ( value != null && signal[i] != null ? value - signal[i]! : undefined )); return { macd, signal, histogram }; } function calculateBollingerBands(closes: number[], period = 20, deviations = 2) { const upper: Array = Array(closes.length).fill(undefined); const middle: Array = Array(closes.length).fill(undefined); const lower: Array = Array(closes.length).fill(undefined); for (let i = period - 1; i < closes.length; i += 1) { const slice = closes.slice(i - period + 1, i + 1); const mean = average(slice); const variance = average(slice.map(value => (value - mean) ** 2)); const standardDeviation = Math.sqrt(variance); middle[i] = mean; upper[i] = mean + standardDeviation * deviations; lower[i] = mean - standardDeviation * deviations; } return { upper, middle, lower }; } function normalizeResearchProfile(profile: any): ResearchProfile | null { return Array.isArray(profile) ? profile[0] : profile; } // ─── Ticker header ──────────────────────────────────────────────────────────── export function TickerHeader({ symbol, profile, latestBarTimestamp, }: { symbol: string; profile?: ResearchProfile | null; latestBarTimestamp?: number | null; }) { const navigate = useNavigate(); const { botState } = useAppContext(); const data = botState.symbols?.[symbol]; const price = data?.price ?? 0; const change = data?.changeToday ?? 0; const changePct = price > 0 ? (change / (price - change)) * 100 : 0; const positive = change >= 0; const company = profile?.companyName; const companyName = typeof company === 'string' && company.trim() ? company.trim() : symbol; return (

{symbol}

{companyName}
{price > 0 ? price.toFixed(2) : '—'} {price > 0 && ( {positive ? '+' : ''}{change.toFixed(2)} ({positive ? '+' : ''}{changePct.toFixed(2)}%) )}
{formatAsOfTimestamp(latestBarTimestamp ?? null)} ET · NASDAQ
); } // ─── Stock chart ────────────────────────────────────────────────────────────── function StockChart({ symbol, onLatestBarTimestamp, }: { symbol: string; onLatestBarTimestamp?: (timestamp: number | null) => void; }) { const [period, setPeriod] = useState('1Y'); const [bars, setBars] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [enabledIndicators, setEnabledIndicators] = useState>({ rsi: false, macd: false, bollinger: false, }); useEffect(() => { let cancelled = false; setLoading(true); setError(null); setBars([]); onLatestBarTimestamp?.(null); fetchChartBars(symbol, period) .then(data => { if (!cancelled) { setBars(data); onLatestBarTimestamp?.(data.at(-1)?.ts ?? null); } }) .catch(err => { if (!cancelled) setError(err?.message ?? 'Failed to load chart'); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [symbol, period, onLatestBarTimestamp]); const closes = bars.map(b => b.close); const rsi = calculateRsi(closes); const macd = calculateMacd(closes); const bollinger = calculateBollingerBands(closes); const chartData: ChartPoint[] = bars.map((b, index) => ({ ts: b.ts, price: b.close, label: formatPriceLabel(b.ts, period), bollingerUpper: bollinger.upper[index], bollingerMiddle: bollinger.middle[index], bollingerLower: bollinger.lower[index], rsi: rsi[index], macd: macd.macd[index], macdSignal: macd.signal[index], macdHistogram: macd.histogram[index], })); const firstPrice = chartData[0]?.price ?? 0; const lastPrice = chartData[chartData.length - 1]?.price ?? 0; const positive = lastPrice >= firstPrice; const lineColor = positive ? '#2563EB' : '#DC2626'; const priceYValues = chartData.flatMap(d => [ d.price, ...(enabledIndicators.bollinger ? [d.bollingerUpper, d.bollingerMiddle, d.bollingerLower].filter((value): value is number => value != null) : []), ]); const macdValues = chartData.flatMap(d => ( [d.macd, d.macdSignal, d.macdHistogram].filter((value): value is number => value != null) )); const minY = priceYValues.length ? Math.min(...priceYValues) : 0; const maxY = priceYValues.length ? Math.max(...priceYValues) : 100; const pad = (maxY - minY) * 0.1 || 10; const macdMaxAbs = macdValues.length ? Math.max(...macdValues.map(value => Math.abs(value))) : 1; const enabledCount = Object.values(enabledIndicators).filter(Boolean).length; const toggleIndicator = (key: IndicatorKey) => { setEnabledIndicators(prev => ({ ...prev, [key]: !prev[key] })); }; return (
{/* Period selector + chart type */}
{PERIODS.map(p => ( ))}
{INDICATORS.map(indicator => { const active = enabledIndicators[indicator.key]; return ( ); })}
Line Chart
Indicators: {enabledCount > 0 ? `${enabledCount} active` : 'none'}
RSI 14 · MACD 12/26/9 · Bollinger 20/2
{/* Chart */} {loading ? (
Loading chart…
) : error ? (
{error}
) : chartData.length < 2 ? (
No price data available for {symbol}
) : (
`$${v.toFixed(0)}`} /> { const labels: Record = { price: 'Price', bollingerUpper: 'BB Upper', bollingerMiddle: 'BB Mid', bollingerLower: 'BB Lower', }; const key = String(name ?? ''); return [`$${Number(val).toFixed(2)}`, labels[key] ?? key]; }} labelStyle={{ color: '#6B7280', fontSize: 11 }} /> {enabledIndicators.bollinger && ( <> )} {enabledIndicators.rsi && (
RSI (14) 70 overbought · 30 oversold
[Number(val).toFixed(1), 'RSI']} labelStyle={{ color: '#6B7280', fontSize: 11 }} />
)} {enabledIndicators.macd && (
MACD (12, 26, 9)
{ const labels: Record = { macdHistogram: 'Histogram', macd: 'MACD', macdSignal: 'Signal', }; const key = String(name ?? ''); return [Number(val).toFixed(3), labels[key] ?? key]; }} labelStyle={{ color: '#6B7280', fontSize: 11 }} />
)}
)}
); } // ─── Quick stats cards ──────────────────────────────────────────────────────── function QuickStats({ symbol }: { symbol: string }) { const { botState } = useAppContext(); const d = botState.symbols?.[symbol]; const stats = [ { label: 'RSI (1H)', value: d?.indicators?.rsi_1h?.toFixed(1) ?? '—' }, { label: 'EMA 50', value: d?.indicators?.ema50_4h?.toFixed(2) ?? '—' }, { label: 'EMA 200', value: d?.indicators?.ema200_4h?.toFixed(2) ?? '—' }, { label: 'Signal', value: d?.signal ?? '—' }, ]; return (
{stats.map(s => (
{s.label}
{s.value}
))}
); } // ─── Live research / financials cards (Phase 4) ─────────────────────────────── const fmtBig = (n: number | undefined) => { if (n == null || n === 0) return '—'; if (Math.abs(n) >= 1e12) return `$${(n / 1e12).toFixed(2)}T`; if (Math.abs(n) >= 1e9) return `$${(n / 1e9).toFixed(2)}B`; if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(2)}M`; return `$${n.toFixed(2)}`; }; function ResearchCards({ symbol, profile, profileLoading, }: { symbol: string; profile: ResearchProfile | null; profileLoading: boolean; }) { const [metrics, setMetrics] = useState(null); const [earnings, setEarnings] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; setLoading(true); setMetrics(null); setEarnings([]); Promise.allSettled([ fetchResearchMetrics(symbol), fetchResearchEarnings(symbol), ]).then(([m, e]) => { if (cancelled) return; if (m.status === 'fulfilled') setMetrics(Array.isArray(m.value) ? m.value[0] : m.value); if (e.status === 'fulfilled') setEarnings(e.value ?? []); setLoading(false); }); return () => { cancelled = true; }; }, [symbol]); const nextEarnings = earnings.find(e => e.date && new Date(e.date) >= new Date()); const fmtDate = (d?: string) => d ? new Date(d).toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' }) : '—'; const ValueSkeleton = ({ width = 58 }: { width?: number }) => ; const financialRows: [string, string][] = [ ['Market Cap', fmtBig(profile?.mktCap)], ['Revenue (TTM)', fmtBig(metrics?.revenuePerShareTTM != null && metrics?.sharesWSOQuarterly != null ? metrics.revenuePerShareTTM * metrics.sharesWSOQuarterly : profile?.revenue ?? undefined)], ['Net Income (TTM)', fmtBig(metrics?.netIncomePerShareTTM != null && metrics?.sharesWSOQuarterly != null ? metrics.netIncomePerShareTTM * metrics.sharesWSOQuarterly : undefined)], ['P/E Ratio (TTM)', metrics?.peRatioTTM != null ? metrics.peRatioTTM.toFixed(1) : '—'], ['ROE (TTM)', metrics?.roeTTM != null ? `${(metrics.roeTTM * 100).toFixed(1)}%` : '—'], ]; return (
{/* Company Profile */}
📋 Company
{profileLoading ? (
) : profile ? ( <>
{profile.companyName ?? symbol} {profile.sector && <> · {profile.sector}} {profile.industry && <> · {profile.industry}}
{profile.description ?? ''}
{profile.website && ( {profile.website} )} ) : (
No profile data
)}
{/* Financials */}
📊 Financials
{financialRows.map(([label, val]) => (
{label} {loading || profileLoading ? 10 ? 64 : 46} /> : val}
))}
{/* Events / Earnings */}
📅 Events
{[ ['Next Earnings', loading ? '…' : fmtDate(nextEarnings?.date)], ['EPS Estimate', loading ? '…' : nextEarnings?.epsEstimated != null ? `$${nextEarnings.epsEstimated.toFixed(2)}` : '—'], ['Revenue Est.', loading ? '…' : nextEarnings?.revenueEstimated != null ? fmtBig(nextEarnings.revenueEstimated) : '—'], ['Exchange', profileLoading ? '…' : profile?.exchangeShortName ?? '—'], ].map(([label, val]) => (
{label} {val === '…' ? : val}
))} {!loading && earnings.length > 0 && (
Past Earnings
{earnings.slice(0,3).map((e, i) => (
{fmtDate(e.date)} = (e.epsEstimated ?? e.eps) ? '#16A34A' : '#DC2626' }}> EPS {e.eps != null ? `$${e.eps.toFixed(2)}` : '—'}
))}
)}
); } // ─── Empty state ────────────────────────────────────────────────────────────── function EmptyState({ onSelect }: { onSelect: (symbol: string) => void }) { return (
📈
Search a company to get started
Type a ticker symbol or company name in the search bar above to view charts, financials, and news.
{['AAPL','MSFT','GOOGL','AMZN','NVDA'].map(t => ( onSelect(t)} style={{ padding: '4px 12px', background: '#EFF6FF', color: '#2563EB', borderRadius: 20, fontSize: 13, fontWeight: 600, cursor: 'pointer', }} > {t} ))}
); } // ─── HomeView ───────────────────────────────────────────────────────────────── export function HomeView() { const { activeSymbol, setActiveSymbol } = useAppContext(); const [profile, setProfile] = useState(null); const [profileLoading, setProfileLoading] = useState(false); const [latestBarTimestamp, setLatestBarTimestamp] = useState(null); useEffect(() => { if (!activeSymbol) { setProfile(null); setProfileLoading(false); return; } let cancelled = false; setProfile(null); setProfileLoading(true); fetchResearchProfile(activeSymbol) .then(data => { if (!cancelled) setProfile(normalizeResearchProfile(data)); }) .catch(() => { if (!cancelled) setProfile(null); }) .finally(() => { if (!cancelled) setProfileLoading(false); }); return () => { cancelled = true; }; }, [activeSymbol]); if (!activeSymbol) return ; return (
); }