811 lines
33 KiB
TypeScript
811 lines
33 KiB
TypeScript
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<number | undefined> {
|
|
const rsi: Array<number | undefined> = 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<number | undefined> {
|
|
const ema: Array<number | undefined> = 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<number | undefined> = closes.map((_, i) => (
|
|
fast[i] != null && slow[i] != null ? fast[i]! - slow[i]! : undefined
|
|
));
|
|
const signal: Array<number | undefined> = 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<number | undefined> = Array(closes.length).fill(undefined);
|
|
const middle: Array<number | undefined> = Array(closes.length).fill(undefined);
|
|
const lower: Array<number | undefined> = 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 (
|
|
<div style={{ marginBottom: 16 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 4 }}>
|
|
<h1 style={{ fontSize: 28, fontWeight: 800, color: '#111827', margin: 0 }}>
|
|
{symbol}
|
|
</h1>
|
|
<span style={{ fontSize: 13, color: '#6B7280', fontWeight: 500, marginTop: 4 }}>
|
|
{companyName}
|
|
</span>
|
|
|
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
|
<button
|
|
type="button"
|
|
aria-label={`Open watchlist for ${symbol}`}
|
|
onClick={() => navigate('/watchlist')}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 6,
|
|
padding: '5px 12px', borderRadius: 20,
|
|
background: '#F0FDF4', border: '1px solid #86EFAC',
|
|
color: '#16A34A', fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
|
}}>
|
|
<Star size={13} fill="#16A34A" /> Watchlist
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={`Open alerts for ${symbol}`}
|
|
onClick={() => navigate('/alerts')}
|
|
style={{
|
|
width: 32, height: 32, borderRadius: '50%',
|
|
border: '1px solid #E5E7EB', background: '#fff',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
cursor: 'pointer', color: '#6B7280',
|
|
}}>
|
|
<Bell size={15} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
|
|
<span style={{ fontSize: 32, fontWeight: 800, color: '#111827', letterSpacing: '-1px' }}>
|
|
{price > 0 ? price.toFixed(2) : '—'}
|
|
</span>
|
|
{price > 0 && (
|
|
<span style={{ fontSize: 15, fontWeight: 600, color: positive ? '#16A34A' : '#DC2626' }}>
|
|
{positive ? '+' : ''}{change.toFixed(2)} ({positive ? '+' : ''}{changePct.toFixed(2)}%)
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ fontSize: 11, color: '#9CA3AF', marginTop: 3 }}>
|
|
{formatAsOfTimestamp(latestBarTimestamp ?? null)} ET · NASDAQ
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Stock chart ──────────────────────────────────────────────────────────────
|
|
function StockChart({
|
|
symbol,
|
|
onLatestBarTimestamp,
|
|
}: {
|
|
symbol: string;
|
|
onLatestBarTimestamp?: (timestamp: number | null) => void;
|
|
}) {
|
|
const [period, setPeriod] = useState<Period>('1Y');
|
|
const [bars, setBars] = useState<OHLCVBar[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [enabledIndicators, setEnabledIndicators] = useState<Record<IndicatorKey, boolean>>({
|
|
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 (
|
|
<div style={{
|
|
background: '#fff',
|
|
borderRadius: 12,
|
|
border: '1px solid #E5E7EB',
|
|
padding: '16px 20px 12px',
|
|
marginBottom: 20,
|
|
}}>
|
|
{/* Period selector + chart type */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
|
|
<div style={{ display: 'flex', gap: 2 }}>
|
|
{PERIODS.map(p => (
|
|
<button
|
|
key={p}
|
|
onClick={() => setPeriod(p)}
|
|
style={{
|
|
padding: '4px 9px',
|
|
border: 'none',
|
|
borderRadius: 6,
|
|
fontSize: 12,
|
|
fontWeight: period === p ? 700 : 500,
|
|
background: period === p ? '#EFF6FF' : 'transparent',
|
|
color: period === p ? '#2563EB' : '#6B7280',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.15s',
|
|
}}
|
|
>
|
|
{p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, color: '#6B7280', fontSize: 12 }}>
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
{INDICATORS.map(indicator => {
|
|
const active = enabledIndicators[indicator.key];
|
|
return (
|
|
<button
|
|
key={indicator.key}
|
|
onClick={() => toggleIndicator(indicator.key)}
|
|
title={indicator.hint}
|
|
aria-pressed={active}
|
|
style={{
|
|
padding: '5px 9px',
|
|
borderRadius: 999,
|
|
border: active ? '1px solid #93C5FD' : '1px solid #E5E7EB',
|
|
background: active ? '#EFF6FF' : '#fff',
|
|
color: active ? '#1D4ED8' : '#6B7280',
|
|
fontSize: 11,
|
|
fontWeight: 700,
|
|
cursor: 'pointer',
|
|
boxShadow: active ? '0 2px 8px rgba(37,99,235,0.10)' : 'none',
|
|
}}
|
|
>
|
|
{indicator.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
<BarChart2 size={14} /> Line Chart
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
<div style={{ fontSize: 11, color: '#9CA3AF' }}>
|
|
Indicators: {enabledCount > 0 ? `${enabledCount} active` : 'none'}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#9CA3AF' }}>
|
|
RSI 14 · MACD 12/26/9 · Bollinger 20/2
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chart */}
|
|
{loading ? (
|
|
<div style={{
|
|
height: 220, display: 'flex', flexDirection: 'column',
|
|
alignItems: 'center', justifyContent: 'center', gap: 10, color: '#9CA3AF',
|
|
}}>
|
|
<Loader2 size={28} color="#93C5FD" style={{ animation: 'spin 1s linear infinite' }} />
|
|
<span style={{ fontSize: 13 }}>Loading chart…</span>
|
|
</div>
|
|
) : error ? (
|
|
<div style={{
|
|
height: 220, display: 'flex', flexDirection: 'column',
|
|
alignItems: 'center', justifyContent: 'center', gap: 8, color: '#EF4444', fontSize: 13,
|
|
}}>
|
|
<BarChart2 size={32} color="#FCA5A5" />
|
|
<span>{error}</span>
|
|
</div>
|
|
) : chartData.length < 2 ? (
|
|
<div style={{
|
|
height: 220,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: '#9CA3AF',
|
|
fontSize: 13,
|
|
gap: 8,
|
|
}}>
|
|
<BarChart2 size={32} color="#D1D5DB" />
|
|
<span>No price data available for {symbol}</span>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
|
<defs>
|
|
<linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={lineColor} stopOpacity={0.15} />
|
|
<stop offset="95%" stopColor={lineColor} stopOpacity={0.01} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
|
|
<XAxis
|
|
dataKey="label"
|
|
tick={{ fontSize: 10, fill: '#9CA3AF' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
interval="preserveStartEnd"
|
|
/>
|
|
<YAxis
|
|
domain={[minY - pad, maxY + pad]}
|
|
tick={{ fontSize: 10, fill: '#9CA3AF' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={55}
|
|
tickFormatter={v => `$${v.toFixed(0)}`}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
background: '#fff',
|
|
border: '1px solid #E5E7EB',
|
|
borderRadius: 8,
|
|
fontSize: 12,
|
|
boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
|
|
}}
|
|
formatter={(val: unknown, name: unknown) => {
|
|
const labels: Record<string, string> = {
|
|
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 && (
|
|
<>
|
|
<Line type="monotone" dataKey="bollingerUpper" stroke="#F59E0B" strokeWidth={1.4} dot={false} strokeDasharray="4 4" connectNulls />
|
|
<Line type="monotone" dataKey="bollingerMiddle" stroke="#FBBF24" strokeWidth={1.2} dot={false} strokeDasharray="2 4" connectNulls />
|
|
<Line type="monotone" dataKey="bollingerLower" stroke="#F59E0B" strokeWidth={1.4} dot={false} strokeDasharray="4 4" connectNulls />
|
|
</>
|
|
)}
|
|
<Area
|
|
type="monotone"
|
|
dataKey="price"
|
|
stroke={lineColor}
|
|
strokeWidth={2}
|
|
fill="url(#chartGrad)"
|
|
dot={false}
|
|
activeDot={{ r: 4, fill: lineColor, strokeWidth: 0 }}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
|
|
{enabledIndicators.rsi && (
|
|
<div style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #F3F4F6' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4, fontSize: 11, color: '#6B7280', fontWeight: 700 }}>
|
|
<span>RSI (14)</span>
|
|
<span style={{ color: '#9CA3AF', fontWeight: 500 }}>70 overbought · 30 oversold</span>
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={86}>
|
|
<LineChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
|
|
<XAxis dataKey="label" hide />
|
|
<YAxis domain={[0, 100]} tick={{ fontSize: 10, fill: '#9CA3AF' }} tickLine={false} axisLine={false} width={55} />
|
|
<Tooltip
|
|
contentStyle={{ background: '#fff', border: '1px solid #E5E7EB', borderRadius: 8, fontSize: 12 }}
|
|
formatter={(val: unknown) => [Number(val).toFixed(1), 'RSI']}
|
|
labelStyle={{ color: '#6B7280', fontSize: 11 }}
|
|
/>
|
|
<ReferenceLine y={70} stroke="#FCA5A5" strokeDasharray="3 3" />
|
|
<ReferenceLine y={30} stroke="#93C5FD" strokeDasharray="3 3" />
|
|
<Line type="monotone" dataKey="rsi" stroke="#7C3AED" strokeWidth={1.8} dot={false} connectNulls />
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
|
|
{enabledIndicators.macd && (
|
|
<div style={{ marginTop: 12, paddingTop: 10, borderTop: '1px solid #F3F4F6' }}>
|
|
<div style={{ marginBottom: 4, fontSize: 11, color: '#6B7280', fontWeight: 700 }}>
|
|
MACD (12, 26, 9)
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={106}>
|
|
<ComposedChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
|
|
<XAxis dataKey="label" hide />
|
|
<YAxis
|
|
domain={[-macdMaxAbs * 1.2, macdMaxAbs * 1.2]}
|
|
tick={{ fontSize: 10, fill: '#9CA3AF' }}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
width={55}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{ background: '#fff', border: '1px solid #E5E7EB', borderRadius: 8, fontSize: 12 }}
|
|
formatter={(val: unknown, name: unknown) => {
|
|
const labels: Record<string, string> = {
|
|
macdHistogram: 'Histogram',
|
|
macd: 'MACD',
|
|
macdSignal: 'Signal',
|
|
};
|
|
const key = String(name ?? '');
|
|
return [Number(val).toFixed(3), labels[key] ?? key];
|
|
}}
|
|
labelStyle={{ color: '#6B7280', fontSize: 11 }}
|
|
/>
|
|
<ReferenceLine y={0} stroke="#CBD5E1" />
|
|
<Bar dataKey="macdHistogram" fill="#BFDBFE" radius={[2, 2, 0, 0]} />
|
|
<Line type="monotone" dataKey="macd" stroke="#2563EB" strokeWidth={1.7} dot={false} connectNulls />
|
|
<Line type="monotone" dataKey="macdSignal" stroke="#F97316" strokeWidth={1.5} dot={false} connectNulls />
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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 (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, marginBottom: 20 }}>
|
|
{stats.map(s => (
|
|
<div key={s.label} style={{
|
|
background: '#fff',
|
|
borderRadius: 10,
|
|
border: '1px solid #E5E7EB',
|
|
padding: '12px 14px',
|
|
}}>
|
|
<div style={{ fontSize: 11, color: '#9CA3AF', fontWeight: 500, marginBottom: 4 }}>{s.label}</div>
|
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#111827' }}>{s.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── 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<any>(null);
|
|
const [earnings, setEarnings] = useState<any[]>([]);
|
|
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 }) => <SkeletonBlock width={width} height={10} />;
|
|
|
|
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 (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}>
|
|
{/* Company Profile */}
|
|
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}>
|
|
📋 Company
|
|
</div>
|
|
{profileLoading ? (
|
|
<div role="status" aria-label="Loading company profile" style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<SkeletonText width="70%" />
|
|
<SkeletonText width="96%" />
|
|
<SkeletonText width="88%" />
|
|
<SkeletonText width="55%" />
|
|
</div>
|
|
) : profile ? (
|
|
<>
|
|
<div style={{ fontSize: 12, color: '#374151', marginBottom: 6, lineHeight: 1.5 }}>
|
|
<strong>{profile.companyName ?? symbol}</strong>
|
|
{profile.sector && <> · {profile.sector}</>}
|
|
{profile.industry && <> · {profile.industry}</>}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#6B7280', lineHeight: 1.6, marginBottom: 8,
|
|
display: '-webkit-box', WebkitLineClamp: 4, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
|
}}>
|
|
{profile.description ?? ''}
|
|
</div>
|
|
{profile.website && (
|
|
<a href={profile.website} target="_blank" rel="noreferrer"
|
|
style={{ fontSize: 11, color: '#2563EB', textDecoration: 'none' }}>
|
|
{profile.website}
|
|
</a>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div style={{ fontSize: 12, color: '#9CA3AF' }}>No profile data</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Financials */}
|
|
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}>
|
|
📊 Financials
|
|
</div>
|
|
{financialRows.map(([label, val]) => (
|
|
<div key={label} style={{
|
|
display: 'flex', justifyContent: 'space-between',
|
|
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
|
|
}}>
|
|
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>
|
|
{loading || profileLoading ? <ValueSkeleton width={label.length > 10 ? 64 : 46} /> : val}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Events / Earnings */}
|
|
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 16 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 700, color: '#111827', marginBottom: 10 }}>
|
|
📅 Events
|
|
</div>
|
|
{[
|
|
['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]) => (
|
|
<div key={label} style={{
|
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
|
padding: '5px 0', borderBottom: '1px solid #F9FAFB',
|
|
}}>
|
|
<span style={{ fontSize: 12, color: '#6B7280' }}>{label}</span>
|
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#111827' }}>
|
|
{val === '…' ? <ValueSkeleton width={label === 'Next Earnings' ? 82 : 52} /> : val}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{!loading && earnings.length > 0 && (
|
|
<div style={{ marginTop: 10 }}>
|
|
<div style={{ fontSize: 11, fontWeight: 600, color: '#374151', marginBottom: 4 }}>Past Earnings</div>
|
|
{earnings.slice(0,3).map((e, i) => (
|
|
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 11, color: '#6B7280', padding: '2px 0' }}>
|
|
<span>{fmtDate(e.date)}</span>
|
|
<span style={{ color: e.eps >= (e.epsEstimated ?? e.eps) ? '#16A34A' : '#DC2626' }}>
|
|
EPS {e.eps != null ? `$${e.eps.toFixed(2)}` : '—'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Empty state ──────────────────────────────────────────────────────────────
|
|
function EmptyState({ onSelect }: { onSelect: (symbol: string) => void }) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
|
justifyContent: 'center', height: '60vh', gap: 16,
|
|
color: '#9CA3AF',
|
|
}}>
|
|
<div style={{ fontSize: 56 }}>📈</div>
|
|
<div style={{ fontSize: 20, fontWeight: 700, color: '#374151' }}>
|
|
Search a company to get started
|
|
</div>
|
|
<div style={{ fontSize: 14, textAlign: 'center', maxWidth: 360 }}>
|
|
Type a ticker symbol or company name in the search bar above to view charts, financials, and news.
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
{['AAPL','MSFT','GOOGL','AMZN','NVDA'].map(t => (
|
|
<span
|
|
key={t}
|
|
onClick={() => onSelect(t)}
|
|
style={{
|
|
padding: '4px 12px',
|
|
background: '#EFF6FF',
|
|
color: '#2563EB',
|
|
borderRadius: 20,
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── HomeView ─────────────────────────────────────────────────────────────────
|
|
export function HomeView() {
|
|
const { activeSymbol, setActiveSymbol } = useAppContext();
|
|
const [profile, setProfile] = useState<ResearchProfile | null>(null);
|
|
const [profileLoading, setProfileLoading] = useState(false);
|
|
const [latestBarTimestamp, setLatestBarTimestamp] = useState<number | null>(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 <EmptyState onSelect={setActiveSymbol} />;
|
|
|
|
return (
|
|
<div>
|
|
<TickerHeader symbol={activeSymbol} profile={profile} latestBarTimestamp={latestBarTimestamp} />
|
|
<StockChart symbol={activeSymbol} onLatestBarTimestamp={setLatestBarTimestamp} />
|
|
<QuickStats symbol={activeSymbol} />
|
|
<ResearchCards symbol={activeSymbol} profile={profile} profileLoading={profileLoading} />
|
|
</div>
|
|
);
|
|
}
|