Spaces:
Running
Running
| 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 ( | |
| <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>Close: <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>} | |
| <p>Vol: <span className="text-white">{formatVolume(d.volume)}</span></p> | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Stat badge sub-component | |
| // --------------------------------------------------------------------------- | |
| interface StatBadgeProps { | |
| label: string | |
| value: string | |
| sub?: string | |
| color?: string | |
| } | |
| const StatBadge: React.FC<StatBadgeProps> = ({ label, value, sub, color = 'text-white' }) => ( | |
| <div className="bg-slate-800/60 rounded-lg p-3 text-center min-w-0"> | |
| <div className="text-xs text-slate-500 mb-1 truncate">{label}</div> | |
| <div className={`text-sm sm:text-base font-bold ${color} truncate`}>{value}</div> | |
| {sub && <div className="text-xs text-slate-500 mt-0.5 truncate">{sub}</div>} | |
| </div> | |
| ) | |
| // --------------------------------------------------------------------------- | |
| // Main component | |
| // --------------------------------------------------------------------------- | |
| export const RecentOverview: React.FC<Props> = ({ stockNo }) => { | |
| const [data, setData] = useState<OHLCVPoint[]>([]) | |
| 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 ( | |
| <div className="bg-slate-900 border border-slate-800 rounded-xl p-4"> | |
| <div className="flex items-center gap-2 text-slate-400 text-sm"> | |
| <div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" /> | |
| 載入近期總覽... | |
| </div> | |
| </div> | |
| ) | |
| } | |
| 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 ( | |
| <div className="bg-slate-900 border border-slate-800 rounded-xl p-4 space-y-3"> | |
| {/* Header — collapsible */} | |
| <button | |
| onClick={() => setExpanded(e => !e)} | |
| className="w-full flex items-center justify-between group" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <TrendingUp size={16} className="text-cyan-400" /> | |
| <div className="text-left"> | |
| <h3 className="text-sm font-semibold text-white leading-tight">近 1-2 月總覽</h3> | |
| <p className="text-xs text-slate-500 leading-tight">Recent 1–2 Month Overview — Price, Volume & Key Stats</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <span className={`text-sm font-bold ${changeColor} flex items-center gap-0.5`}> | |
| <ChangeIcon size={14} /> | |
| {formatPct(stats.totalChangePct)} | |
| </span> | |
| {expanded | |
| ? <ChevronUp size={16} className="text-slate-500 group-hover:text-slate-300 transition-colors" /> | |
| : <ChevronDown size={16} className="text-slate-500 group-hover:text-slate-300 transition-colors" /> | |
| } | |
| </div> | |
| </button> | |
| {expanded && ( | |
| <> | |
| {/* Statistics grid */} | |
| <div className="grid grid-cols-3 sm:grid-cols-6 gap-2"> | |
| <StatBadge | |
| label="區間最高" | |
| value={stats.periodHigh.toFixed(2)} | |
| color="text-green-400" | |
| /> | |
| <StatBadge | |
| label="區間最低" | |
| value={stats.periodLow.toFixed(2)} | |
| color="text-red-400" | |
| /> | |
| <StatBadge | |
| label="區間漲跌" | |
| value={formatPct(stats.totalChangePct)} | |
| color={changeColor} | |
| /> | |
| <StatBadge | |
| label="日均量" | |
| value={formatVolume(stats.avgVolume)} | |
| color="text-slate-200" | |
| /> | |
| <StatBadge | |
| label="量能趨勢" | |
| value={stats.volumeVsMa20 != null ? formatPct(stats.volumeVsMa20) : '-'} | |
| sub={volTrendLabel} | |
| color={volTrendColor} | |
| /> | |
| <StatBadge | |
| label="RSI" | |
| value={stats.currentRsi != null ? stats.currentRsi.toFixed(1) : '-'} | |
| sub={rsiLabel} | |
| color={rsiColor} | |
| /> | |
| </div> | |
| {/* Volatility indicator bar */} | |
| <div className="flex items-center gap-2 px-1"> | |
| <span className="text-xs text-slate-500">波動度</span> | |
| <div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden"> | |
| <div | |
| className={`h-full rounded-full transition-all ${ | |
| stats.volatility > 3 ? 'bg-red-500' : stats.volatility > 1.5 ? 'bg-yellow-500' : 'bg-green-500' | |
| }`} | |
| style={{ width: `${Math.min(100, (stats.volatility / 5) * 100)}%` }} | |
| /> | |
| </div> | |
| <span className={`text-xs font-semibold ${volLabelColor}`}> | |
| {volLabel} ({stats.volatility.toFixed(2)}%) | |
| </span> | |
| </div> | |
| {/* Combined chart: price + volume */} | |
| <ResponsiveContainer width="100%" height={300}> | |
| <ComposedChart data={data} 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} | |
| /> | |
| {/* Left Y-axis: price */} | |
| <YAxis | |
| yAxisId="price" | |
| domain={allPrices as [number, number]} | |
| tick={{ fill: '#94a3b8', fontSize: 11 }} | |
| tickLine={false} | |
| tickFormatter={(v) => v.toFixed(0)} | |
| width={55} | |
| /> | |
| {/* Right Y-axis: volume */} | |
| <YAxis | |
| yAxisId="volume" | |
| orientation="right" | |
| tick={{ fill: '#94a3b8', fontSize: 10 }} | |
| tickLine={false} | |
| tickFormatter={formatVolume} | |
| width={48} | |
| /> | |
| <Tooltip content={<CustomTooltip />} /> | |
| <Legend | |
| wrapperStyle={{ fontSize: 12, paddingTop: 4 }} | |
| formatter={(value) => <span style={{ color: '#94a3b8' }}>{value}</span>} | |
| /> | |
| {/* Volume bars — behind price lines */} | |
| <Bar | |
| yAxisId="volume" | |
| dataKey="volume" | |
| name="Volume" | |
| isAnimationActive={false} | |
| maxBarSize={6} | |
| fillOpacity={0.7} | |
| > | |
| {data.map((d, i) => ( | |
| <Cell | |
| key={i} | |
| fill={d.close >= d.open ? '#22c55e' : '#ef4444'} | |
| /> | |
| ))} | |
| </Bar> | |
| {/* Price line */} | |
| <Line | |
| yAxisId="price" | |
| dataKey="close" | |
| stroke="#e2e8f0" | |
| strokeWidth={2} | |
| dot={false} | |
| name="Close" | |
| isAnimationActive={false} | |
| /> | |
| {/* MA5 */} | |
| <Line | |
| yAxisId="price" | |
| dataKey="ma5" | |
| stroke="#facc15" | |
| strokeWidth={1.5} | |
| dot={false} | |
| name="MA5" | |
| isAnimationActive={false} | |
| connectNulls | |
| /> | |
| {/* MA20 */} | |
| <Line | |
| yAxisId="price" | |
| dataKey="ma20" | |
| stroke="#60a5fa" | |
| strokeWidth={1.5} | |
| dot={false} | |
| name="MA20" | |
| isAnimationActive={false} | |
| connectNulls | |
| /> | |
| {/* RSI reference lines (rendered on price axis just as visual guides would be odd — skip) */} | |
| </ComposedChart> | |
| </ResponsiveContainer> | |
| </> | |
| )} | |
| </div> | |
| ) | |
| } | |