DockerSpace / frontend /src /components /WatchlistPanel.tsx
DennisChan0909's picture
Clarify watchlist quick estimates
006e994
Raw
History Blame Contribute Delete
6.1 kB
import React, { useEffect, useState } from 'react'
import { Loader2, TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { fetchBatchPredictions, fetchStockInfo, BatchPredictionItem, StockInfo } from '../api/stockApi'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface WatchlistEntry {
code: string
info: StockInfo | null
prediction: BatchPredictionItem | null
error: string | null
}
interface Props {
onSelect: (code: string) => void
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_WATCHLIST = ['2330', '2317', '2454', '0050', '006208']
const SIGNAL_EMOJI: Record<string, string> = {
BUY: '📈',
SELL: '📉',
HOLD: '➡️',
}
const SIGNAL_TEXT_CLASS: Record<string, string> = {
BUY: 'text-green-400',
SELL: 'text-red-400',
HOLD: 'text-yellow-400',
}
const BUY_PROB_BG = (prob: number): string => {
if (prob >= 0.6) return 'bg-green-600'
if (prob <= 0.35) return 'bg-red-600'
return 'bg-yellow-600'
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const WatchlistPanel: React.FC<Props> = ({ onSelect }) => {
const [entries, setEntries] = useState<WatchlistEntry[]>(
DEFAULT_WATCHLIST.map((code) => ({ code, info: null, prediction: null, error: null }))
)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
let firstLoad = true
const loadAll = async () => {
if (firstLoad && !cancelled) setLoading(true)
// Fetch info and batch predictions in parallel (single batch request instead of 5 individual ones)
const [infoResults, batchResult] = await Promise.allSettled([
Promise.allSettled(DEFAULT_WATCHLIST.map((code) => fetchStockInfo(code))),
fetchBatchPredictions(DEFAULT_WATCHLIST),
])
if (cancelled) return
const infos = infoResults.status === 'fulfilled' ? infoResults.value : []
const batchItems = batchResult.status === 'fulfilled' ? batchResult.value : []
// Build a lookup map from batch results
const predMap = new Map<string, BatchPredictionItem>()
for (const item of batchItems) {
predMap.set(item.code, item)
}
const resolved: WatchlistEntry[] = DEFAULT_WATCHLIST.map((code, i) => {
const infoSettled = infos[i]
const info = infoSettled?.status === 'fulfilled' ? infoSettled.value : null
const batchItem = predMap.get(code) ?? null
return {
code,
info,
prediction: batchItem && !batchItem.error ? batchItem : null,
error: batchItem?.error ?? null,
}
})
setEntries(resolved)
setLoading(false)
firstLoad = false
}
loadAll()
const id = setInterval(loadAll, 30_000)
return () => {
cancelled = true
clearInterval(id)
}
}, [])
return (
<div className="bg-slate-900 border border-slate-700 rounded-xl p-4 space-y-3">
<div className="flex items-center gap-2 mb-1">
<TrendingUp size={16} className="text-blue-400" />
<h3 className="text-sm font-bold text-slate-200">觀察清單</h3>
<span className="text-xs text-slate-500 ml-auto">Watchlist</span>
</div>
{loading ? (
<div className="flex items-center justify-center py-6 gap-2 text-slate-400">
<Loader2 size={18} className="animate-spin" />
<span className="text-sm">載入中...</span>
</div>
) : (
<ul className="space-y-1.5">
{entries.map(({ code, info, prediction, error }) => (
<li key={code}>
<button
onClick={() => onSelect(code)}
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-left bg-slate-800/60 hover:bg-slate-700/60 transition-colors group"
>
{/* Code + Name */}
<div className="flex-1 min-w-0">
<span className="text-xs font-bold text-slate-100 group-hover:text-white">
{code}
</span>
{info?.name && info.name !== code && (
<span className="ml-1.5 text-xs text-slate-400 truncate">
{info.name}
</span>
)}
</div>
{/* Signal emoji */}
{prediction && prediction.signal && !error ? (
<>
<span
className={`text-sm ${SIGNAL_TEXT_CLASS[prediction.signal] ?? 'text-slate-300'}`}
title={prediction.signal}
>
{SIGNAL_EMOJI[prediction.signal] ?? prediction.signal}
</span>
{/* Buy probability badge */}
{prediction.optimized === false ? (
<span className="text-[10px] font-semibold text-yellow-200 bg-yellow-700/70 rounded px-1.5 py-0.5">
計算中
</span>
) : prediction.buy_prob != null && (
<span
className={`text-xs font-semibold text-white rounded px-1.5 py-0.5 ${BUY_PROB_BG(
prediction.buy_prob
)}`}
>
{Math.round(prediction.buy_prob * 100)}%
</span>
)}
</>
) : error ? (
<span className="text-xs text-red-400"></span>
) : (
<Loader2 size={12} className="animate-spin text-slate-500" />
)}
</button>
</li>
))}
</ul>
)}
</div>
)
}