File size: 13,625 Bytes
f78ef04
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
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>
  )
}