DockerSpace / frontend /src /components /RecentOverview.tsx
DennisChan0909's picture
feat: add Recent 1-2 Month Overview chart with stats
f78ef04
Raw
History Blame Contribute Delete
13.6 kB
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>
)
}