Spaces:
Running
Running
| import React, { useMemo } from 'react' | |
| import { | |
| ComposedChart, | |
| Line, | |
| Area, | |
| XAxis, | |
| YAxis, | |
| CartesianGrid, | |
| Tooltip, | |
| Legend, | |
| ResponsiveContainer, | |
| } from 'recharts' | |
| import { OHLCVPoint } from '../api/stockApi' | |
| interface Props { | |
| data: OHLCVPoint[] | |
| } | |
| // Show every Nth date label to avoid crowding | |
| function tickFormatter(value: string, index: number, total: number): string { | |
| const step = Math.max(1, Math.floor(total / 10)) | |
| if (index % step === 0) return value | |
| return '' | |
| } | |
| const CustomTooltip = ({ active, payload, label }: any) => { | |
| if (!active || !payload?.length) return null | |
| const d: OHLCVPoint = payload[0]?.payload | |
| if (!d) return null | |
| return ( | |
| <div className="bg-slate-800 border border-slate-600 rounded p-3 text-xs space-y-1 shadow-lg"> | |
| <p className="text-slate-300 font-semibold">{label}</p> | |
| <p>O: <span className="text-white">{d.open?.toFixed(2)}</span></p> | |
| <p>H: <span className="text-green-400">{d.high?.toFixed(2)}</span></p> | |
| <p>L: <span className="text-red-400">{d.low?.toFixed(2)}</span></p> | |
| <p>C: <span className="text-yellow-300 font-bold">{d.close?.toFixed(2)}</span></p> | |
| {d.ma5 != null && <p>MA5: <span className="text-yellow-400">{d.ma5.toFixed(2)}</span></p>} | |
| {d.ma20 != null && <p>MA20: <span className="text-blue-400">{d.ma20.toFixed(2)}</span></p>} | |
| {d.ma60 != null && <p>MA60: <span className="text-red-400">{d.ma60.toFixed(2)}</span></p>} | |
| {d.ma120 != null && <p>MA120: <span className="text-purple-400">{d.ma120.toFixed(2)}</span></p>} | |
| {d.ma240 != null && <p>MA240: <span className="text-slate-400">{d.ma240.toFixed(2)}</span></p>} | |
| </div> | |
| ) | |
| } | |
| export const CandlestickChart: React.FC<Props> = ({ data }) => { | |
| const len = data.length | |
| // Build Bollinger band area data: pairs of [lower, upper] for Area fill | |
| const chartData = useMemo( | |
| () => | |
| data.map((d) => ({ | |
| ...d, | |
| bb_band: d.bb_upper != null && d.bb_lower != null | |
| ? [d.bb_lower, d.bb_upper] | |
| : [null, null], | |
| })), | |
| [data] | |
| ) | |
| const allPrices = data.flatMap((d) => [d.low, d.high, d.bb_upper, d.bb_lower].filter(Boolean) as number[]) | |
| const yMin = Math.floor(Math.min(...allPrices) * 0.995) | |
| const yMax = Math.ceil(Math.max(...allPrices) * 1.005) | |
| return ( | |
| <ResponsiveContainer width="100%" height={320}> | |
| <ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" /> | |
| <XAxis | |
| dataKey="date" | |
| tick={{ fill: '#94a3b8', fontSize: 11 }} | |
| tickLine={false} | |
| tickFormatter={(val, idx) => tickFormatter(val, idx, len)} | |
| interval={0} | |
| /> | |
| <YAxis | |
| domain={[yMin, yMax]} | |
| tick={{ fill: '#94a3b8', fontSize: 11 }} | |
| tickLine={false} | |
| tickFormatter={(v) => v.toFixed(0)} | |
| width={55} | |
| /> | |
| <Tooltip content={<CustomTooltip />} /> | |
| <Legend | |
| wrapperStyle={{ fontSize: 12, paddingTop: 4 }} | |
| formatter={(value) => <span style={{ color: '#94a3b8' }}>{value}</span>} | |
| /> | |
| {/* Bollinger Band fill — rendered first so it's behind price lines */} | |
| <Area | |
| dataKey="bb_upper" | |
| stroke="#334155" | |
| strokeWidth={1} | |
| fill="#1e293b" | |
| fillOpacity={0.5} | |
| dot={false} | |
| legendType="none" | |
| name="BB Upper" | |
| isAnimationActive={false} | |
| /> | |
| <Area | |
| dataKey="bb_lower" | |
| stroke="#334155" | |
| strokeWidth={1} | |
| fill="#0f172a" | |
| fillOpacity={1} | |
| dot={false} | |
| legendType="none" | |
| name="BB Lower" | |
| isAnimationActive={false} | |
| /> | |
| {/* Close price as thick line */} | |
| <Line | |
| dataKey="close" | |
| stroke="#e2e8f0" | |
| strokeWidth={2} | |
| dot={false} | |
| name="Close" | |
| isAnimationActive={false} | |
| /> | |
| {/* Moving averages */} | |
| <Line | |
| dataKey="ma5" | |
| stroke="#facc15" | |
| strokeWidth={1.5} | |
| dot={false} | |
| name="MA5" | |
| isAnimationActive={false} | |
| connectNulls | |
| /> | |
| <Line | |
| dataKey="ma20" | |
| stroke="#60a5fa" | |
| strokeWidth={1.5} | |
| dot={false} | |
| name="MA20" | |
| isAnimationActive={false} | |
| connectNulls | |
| /> | |
| <Line | |
| dataKey="ma60" | |
| stroke="#f87171" | |
| strokeWidth={1.5} | |
| dot={false} | |
| name="MA60" | |
| isAnimationActive={false} | |
| connectNulls | |
| /> | |
| <Line | |
| dataKey="ma120" | |
| stroke="#a78bfa" | |
| strokeWidth={1.5} | |
| dot={false} | |
| name="MA120" | |
| isAnimationActive={false} | |
| connectNulls | |
| strokeDasharray="4 2" | |
| /> | |
| <Line | |
| dataKey="ma240" | |
| stroke="#94a3b8" | |
| strokeWidth={1.5} | |
| dot={false} | |
| name="MA240" | |
| isAnimationActive={false} | |
| connectNulls | |
| strokeDasharray="6 3" | |
| /> | |
| </ComposedChart> | |
| </ResponsiveContainer> | |
| ) | |
| } | |