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 <noreply@openai.com>
This commit is contained in:
Saravana Achu Mac 2026-05-04 15:19:54 -07:00
parent 06d7afb065
commit c54b9f2017

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Star, Bell, BarChart2, Loader2 } from 'lucide-react'; import { Star, Bell, BarChart2, Loader2 } from 'lucide-react';
import { import {
AreaChart, Area, XAxis, YAxis, Tooltip, AreaChart, Area, Bar, ComposedChart, Line, LineChart, XAxis, YAxis, Tooltip,
ResponsiveContainer, CartesianGrid, ResponsiveContainer, CartesianGrid, ReferenceLine,
} from 'recharts'; } from 'recharts';
import { useAppContext } from '../context/AppContext'; import { useAppContext } from '../context/AppContext';
import { import {
@ -13,6 +13,26 @@ import {
// ─── Time period config ─────────────────────────────────────────────────────── // ─── Time period config ───────────────────────────────────────────────────────
const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const; const PERIODS = ['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '5Y', 'MAX'] as const;
type Period = typeof PERIODS[number]; 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 ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatPriceLabel(ts: number, period: Period) { function formatPriceLabel(ts: number, period: Period) {
@ -22,6 +42,96 @@ function formatPriceLabel(ts: number, period: Period) {
return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); 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 ──────────────────────────────────────────────────────────── // ─── Ticker header ────────────────────────────────────────────────────────────
function TickerHeader({ symbol }: { symbol: string }) { function TickerHeader({ symbol }: { symbol: string }) {
const { botState } = useAppContext(); const { botState } = useAppContext();
@ -90,6 +200,11 @@ function StockChart({ symbol }: { symbol: string }) {
const [bars, setBars] = useState<OHLCVBar[]>([]); const [bars, setBars] = useState<OHLCVBar[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [enabledIndicators, setEnabledIndicators] = useState<Record<IndicatorKey, boolean>>({
rsi: false,
macd: false,
bollinger: false,
});
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -103,10 +218,22 @@ function StockChart({ symbol }: { symbol: string }) {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [symbol, period]); }, [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, ts: b.ts,
price: b.close, price: b.close,
label: formatPriceLabel(b.ts, period), 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 firstPrice = chartData[0]?.price ?? 0;
@ -114,9 +241,24 @@ function StockChart({ symbol }: { symbol: string }) {
const positive = lastPrice >= firstPrice; const positive = lastPrice >= firstPrice;
const lineColor = positive ? '#2563EB' : '#DC2626'; const lineColor = positive ? '#2563EB' : '#DC2626';
const minY = chartData.length ? Math.min(...chartData.map(d => d.price)) : 0; const priceYValues = chartData.flatMap(d => [
const maxY = chartData.length ? Math.max(...chartData.map(d => d.price)) : 100; 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 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 ( return (
<div style={{ <div style={{
@ -127,7 +269,7 @@ function StockChart({ symbol }: { symbol: string }) {
marginBottom: 20, marginBottom: 20,
}}> }}>
{/* Period selector + chart type */} {/* Period selector + chart type */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, gap: 12 }}>
<div style={{ display: 'flex', gap: 2 }}> <div style={{ display: 'flex', gap: 2 }}>
{PERIODS.map(p => ( {PERIODS.map(p => (
<button <button
@ -149,8 +291,44 @@ function StockChart({ symbol }: { symbol: string }) {
</button> </button>
))} ))}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: '#6B7280', fontSize: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, color: '#6B7280', fontSize: 12 }}>
<BarChart2 size={14} /> Line Chart <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>
</div> </div>
@ -186,52 +364,132 @@ function StockChart({ symbol }: { symbol: string }) {
<span>No price data available for {symbol}</span> <span>No price data available for {symbol}</span>
</div> </div>
) : ( ) : (
<ResponsiveContainer width="100%" height={220}> <div>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}> <ResponsiveContainer width="100%" height={220}>
<defs> <AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1"> <defs>
<stop offset="5%" stopColor={lineColor} stopOpacity={0.15} /> <linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="95%" stopColor={lineColor} stopOpacity={0.01} /> <stop offset="5%" stopColor={lineColor} stopOpacity={0.15} />
</linearGradient> <stop offset="95%" stopColor={lineColor} stopOpacity={0.01} />
</defs> </linearGradient>
<CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} /> </defs>
<XAxis <CartesianGrid strokeDasharray="3 3" stroke="#F3F4F6" vertical={false} />
dataKey="label" <XAxis
tick={{ fontSize: 10, fill: '#9CA3AF' }} dataKey="label"
tickLine={false} tick={{ fontSize: 10, fill: '#9CA3AF' }}
axisLine={false} tickLine={false}
interval="preserveStartEnd" axisLine={false}
/> interval="preserveStartEnd"
<YAxis />
domain={[minY - pad, maxY + pad]} <YAxis
tick={{ fontSize: 10, fill: '#9CA3AF' }} domain={[minY - pad, maxY + pad]}
tickLine={false} tick={{ fontSize: 10, fill: '#9CA3AF' }}
axisLine={false} tickLine={false}
width={55} axisLine={false}
tickFormatter={v => `$${v.toFixed(0)}`} width={55}
/> tickFormatter={v => `$${v.toFixed(0)}`}
<Tooltip />
contentStyle={{ <Tooltip
background: '#fff', contentStyle={{
border: '1px solid #E5E7EB', background: '#fff',
borderRadius: 8, border: '1px solid #E5E7EB',
fontSize: 12, borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.08)', fontSize: 12,
}} boxShadow: '0 4px 12px rgba(0,0,0,0.08)',
formatter={(val: any) => [`$${Number(val).toFixed(2)}`, 'Price']} }}
labelStyle={{ color: '#6B7280', fontSize: 11 }} formatter={(val: unknown, name: unknown) => {
/> const labels: Record<string, string> = {
<Area price: 'Price',
type="monotone" bollingerUpper: 'BB Upper',
dataKey="price" bollingerMiddle: 'BB Mid',
stroke={lineColor} bollingerLower: 'BB Lower',
strokeWidth={2} };
fill="url(#chartGrad)" const key = String(name ?? '');
dot={false} return [`$${Number(val).toFixed(2)}`, labels[key] ?? key];
activeDot={{ r: 4, fill: lineColor, strokeWidth: 0 }} }}
/> labelStyle={{ color: '#6B7280', fontSize: 11 }}
</AreaChart> />
</ResponsiveContainer> {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> </div>
); );