Spaces:
Running
Running
| 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> | |
| ) | |
| } | |