import React, { useState, useEffect, useMemo } from 'react' import { ComposedChart, Line, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell, ReferenceLine, } from 'recharts' import { TrendingUp, ChevronDown, ChevronUp, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react' import { fetchStockHistory, OHLCVPoint } from '../api/stockApi' interface Props { stockNo: string } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function formatVolume(v: number): string { if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M` if (v >= 1_000) return `${(v / 1_000).toFixed(0)}K` return String(v) } function formatPct(v: number): string { const sign = v >= 0 ? '+' : '' return `${sign}${v.toFixed(2)}%` } function tickFormatter(value: string, index: number, total: number): string { const step = Math.max(1, Math.floor(total / 8)) if (index % step === 0) return value return '' } // --------------------------------------------------------------------------- // Statistics computation // --------------------------------------------------------------------------- interface OverviewStats { periodHigh: number periodLow: number totalChangePct: number avgVolume: number volumeVsMa20: number | null // percentage above/below 20-day MA currentRsi: number | null volatility: number // std dev of daily returns as % } function computeStats(data: OHLCVPoint[]): OverviewStats | null { if (data.length < 2) return null const highs = data.map(d => d.high) const lows = data.map(d => d.low) const periodHigh = Math.max(...highs) const periodLow = Math.min(...lows) const firstClose = data[0].close const lastClose = data[data.length - 1].close const totalChangePct = ((lastClose - firstClose) / firstClose) * 100 const totalVol = data.reduce((s, d) => s + d.volume, 0) const avgVolume = totalVol / data.length // Volume vs 20-day MA (use last data point's volume_ma20 if available) const lastPoint = data[data.length - 1] const volumeVsMa20 = lastPoint.volume_ma20 ? ((lastPoint.volume - lastPoint.volume_ma20) / lastPoint.volume_ma20) * 100 : null // RSI from last point const currentRsi = lastPoint.rsi ?? null // Volatility: annualized std dev of daily returns const returns: number[] = [] for (let i = 1; i < data.length; i++) { const prev = data[i - 1].close if (prev > 0) returns.push((data[i].close - prev) / prev) } const mean = returns.reduce((s, r) => s + r, 0) / returns.length const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / returns.length const volatility = Math.sqrt(variance) * 100 // daily std dev as % return { periodHigh, periodLow, totalChangePct, avgVolume, volumeVsMa20, currentRsi, volatility } } // --------------------------------------------------------------------------- // Tooltip // --------------------------------------------------------------------------- const CustomTooltip = ({ active, payload, label }: any) => { if (!active || !payload?.length) return null const d: OHLCVPoint = payload[0]?.payload if (!d) return null return (

{label}

Close: {d.close?.toFixed(2)}

{d.ma5 != null &&

MA5: {d.ma5.toFixed(2)}

} {d.ma20 != null &&

MA20: {d.ma20.toFixed(2)}

}

Vol: {formatVolume(d.volume)}

) } // --------------------------------------------------------------------------- // Stat badge sub-component // --------------------------------------------------------------------------- interface StatBadgeProps { label: string value: string sub?: string color?: string } const StatBadge: React.FC = ({ label, value, sub, color = 'text-white' }) => (
{label}
{value}
{sub &&
{sub}
}
) // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export const RecentOverview: React.FC = ({ stockNo }) => { const [data, setData] = useState([]) const [loading, setLoading] = useState(false) const [expanded, setExpanded] = useState(true) useEffect(() => { let cancelled = false setLoading(true) fetchStockHistory(stockNo, 2) .then(d => { if (!cancelled) setData(d) }) .catch(() => { if (!cancelled) setData([]) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [stockNo]) const stats = useMemo(() => computeStats(data), [data]) const len = data.length // Price Y-axis domain const allPrices = useMemo(() => { if (!data.length) return [0, 100] const prices = data.flatMap(d => [d.close, d.ma5, d.ma20].filter(v => v != null) as number[] ) const min = Math.min(...prices) const max = Math.max(...prices) const pad = (max - min) * 0.05 || 1 return [Math.floor((min - pad) * 100) / 100, Math.ceil((max + pad) * 100) / 100] }, [data]) if (loading) { return (
載入近期總覽...
) } if (!data.length || !stats) return null // Determine colors const changeColor = stats.totalChangePct >= 0 ? 'text-green-400' : 'text-red-400' const ChangeIcon = stats.totalChangePct > 0 ? ArrowUpRight : stats.totalChangePct < 0 ? ArrowDownRight : Minus const rsiColor = stats.currentRsi == null ? 'text-slate-400' : stats.currentRsi >= 70 ? 'text-red-400' : stats.currentRsi <= 30 ? 'text-green-400' : 'text-yellow-300' const rsiLabel = stats.currentRsi == null ? '-' : stats.currentRsi >= 70 ? '超買' : stats.currentRsi <= 30 ? '超賣' : '中性' const volTrendColor = stats.volumeVsMa20 == null ? 'text-slate-400' : stats.volumeVsMa20 > 20 ? 'text-green-400' : stats.volumeVsMa20 < -20 ? 'text-red-400' : 'text-slate-300' const volTrendLabel = stats.volumeVsMa20 == null ? '-' : stats.volumeVsMa20 > 20 ? '放量' : stats.volumeVsMa20 < -20 ? '縮量' : '正常' const volLabel = stats.volatility > 3 ? '高' : stats.volatility > 1.5 ? '中' : '低' const volLabelColor = stats.volatility > 3 ? 'text-red-400' : stats.volatility > 1.5 ? 'text-yellow-300' : 'text-green-400' return (
{/* Header — collapsible */} {expanded && ( <> {/* Statistics grid */}
{/* Volatility indicator bar */}
波動度
3 ? 'bg-red-500' : stats.volatility > 1.5 ? 'bg-yellow-500' : 'bg-green-500' }`} style={{ width: `${Math.min(100, (stats.volatility / 5) * 100)}%` }} />
{volLabel} ({stats.volatility.toFixed(2)}%)
{/* Combined chart: price + volume */} tickFormatter(val, idx, len)} interval={0} /> {/* Left Y-axis: price */} v.toFixed(0)} width={55} /> {/* Right Y-axis: volume */} } /> {value}} /> {/* Volume bars — behind price lines */} {data.map((d, i) => ( = d.open ? '#22c55e' : '#ef4444'} /> ))} {/* Price line */} {/* MA5 */} {/* MA20 */} {/* RSI reference lines (rendered on price axis just as visual guides would be odd — skip) */} )}
) }