Compute RSI, MACD, and Bollinger Bands from the OHLCV bars already loaded by StockChart so the redesigned dashboard can expose the planned technical indicators without adding backend calls. Bollinger Bands overlay the price chart while RSI and MACD render in separate panels to preserve scale readability. Refs: docs/AUDIT_REDESIGN.md item B1. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
714 lines
30 KiB
TypeScript
714 lines
30 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
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';
|
|
|
|
// ─── 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;
|
|
}
|
|
|
|
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 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 };
|
|
}
|
|
|
|
// ─── Ticker header ────────────────────────────────────────────────────────────
|
|
function TickerHeader({ symbol }: { symbol: string }) {
|
|
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;
|
|
|
|
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 }}>
|
|
{/* Company name placeholder — Phase 4 will fill from FMP */}
|
|
{symbol}
|
|
</span>
|
|
|
|
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
|
|
{/* Saved badge */}
|
|
<button 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 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 }}>
|
|
{new Date().toLocaleString('en-US', {
|
|
month: 'short', day: 'numeric', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})} ET · NASDAQ
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ─── Stock chart ──────────────────────────────────────────────────────────────
|
|
function StockChart({ symbol }: { symbol: string }) {
|
|
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([]);
|
|
fetchChartBars(symbol, period)
|
|
.then(data => { if (!cancelled) setBars(data); })
|
|
.catch(err => { if (!cancelled) setError(err?.message ?? 'Failed to load chart'); })
|
|
.finally(() => { if (!cancelled) setLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [symbol, period]);
|
|
|
|
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(4,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 }: { symbol: string }) {
|
|
const [profile, setProfile] = useState<any>(null);
|
|
const [metrics, setMetrics] = useState<any>(null);
|
|
const [earnings, setEarnings] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setProfile(null); setMetrics(null); setEarnings([]);
|
|
Promise.allSettled([
|
|
fetchResearchProfile(symbol),
|
|
fetchResearchMetrics(symbol),
|
|
fetchResearchEarnings(symbol),
|
|
]).then(([p, m, e]) => {
|
|
if (cancelled) return;
|
|
if (p.status === 'fulfilled') setProfile(Array.isArray(p.value) ? p.value[0] : p.value);
|
|
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 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: '1fr 1fr 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>
|
|
{loading ? (
|
|
<div style={{ color: '#9CA3AF', fontSize: 12 }}>Loading…</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 ? '…' : 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', loading ? '…' : 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}</span>
|
|
</div>
|
|
))}
|
|
{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();
|
|
|
|
if (!activeSymbol) return <EmptyState onSelect={setActiveSymbol} />;
|
|
|
|
return (
|
|
<div>
|
|
<TickerHeader symbol={activeSymbol} />
|
|
<StockChart symbol={activeSymbol} />
|
|
<QuickStats symbol={activeSymbol} />
|
|
<ResearchCards symbol={activeSymbol} />
|
|
</div>
|
|
);
|
|
}
|