From c54b9f2017e74f4b2a13a7b163e1e46ca7b49ce5 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Mon, 4 May 2026 15:19:54 -0700 Subject: [PATCH] fix(B1): wire RSI MACD Bollinger chart toggles 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 --- web/src/views/HomeView.tsx | 366 +++++++++++++++++++++++++++++++------ 1 file changed, 312 insertions(+), 54 deletions(-) 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 }} + /> + + + + + + +
+ )} +
)}
);