diff --git a/web/src/views/HomeView.tsx b/web/src/views/HomeView.tsx index 0e0fede..7e7ba5b 100644 --- a/web/src/views/HomeView.tsx +++ b/web/src/views/HomeView.tsx @@ -1,8 +1,8 @@ import { useState, useEffect } from 'react'; import { Star, Bell, BarChart2, Loader2 } from 'lucide-react'; import { - AreaChart, Area, XAxis, YAxis, Tooltip, - ResponsiveContainer, CartesianGrid, + AreaChart, Area, Bar, ComposedChart, Line, LineChart, XAxis, YAxis, Tooltip, + ResponsiveContainer, CartesianGrid, ReferenceLine, } from 'recharts'; import { useAppContext } from '../context/AppContext'; import { @@ -13,6 +13,26 @@ import { // ─── 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) { @@ -22,6 +42,96 @@ function formatPriceLabel(ts: number, period: Period) { 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 { + 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 }; +} + // ─── Ticker header ──────────────────────────────────────────────────────────── function TickerHeader({ symbol }: { symbol: string }) { const { botState } = useAppContext(); @@ -90,6 +200,11 @@ function StockChart({ symbol }: { symbol: string }) { 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; @@ -103,10 +218,22 @@ function StockChart({ symbol }: { symbol: string }) { return () => { cancelled = true; }; }, [symbol, period]); - const chartData = bars.map(b => ({ + 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; @@ -114,9 +241,24 @@ function StockChart({ symbol }: { symbol: string }) { const positive = lastPrice >= firstPrice; const lineColor = positive ? '#2563EB' : '#DC2626'; - const minY = chartData.length ? Math.min(...chartData.map(d => d.price)) : 0; - const maxY = chartData.length ? Math.max(...chartData.map(d => d.price)) : 100; + 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 => (
-
- Line Chart +
+
+ {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
@@ -186,52 +364,132 @@ function StockChart({ symbol }: { symbol: string }) { No price data available for {symbol}
) : ( - - - - - - - - - - - `$${v.toFixed(0)}`} - /> - [`$${Number(val).toFixed(2)}`, 'Price']} - labelStyle={{ color: '#6B7280', fontSize: 11 }} - /> - - - +
+ + + + + + + + + + + `$${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 }} + /> + + + + + + +
+ )} +
)}
);