Spaces:
Running
Running
| import React from 'react' | |
| import { TrendingUp, TrendingDown, Minus, AlertTriangle, Gem } from 'lucide-react' | |
| import { getPredictionReliabilitySummary } from '../utils/predictionReliability' | |
| export interface MetalPredictionData { | |
| symbol: string | |
| name: string | |
| type: string | |
| signal: 'BUY' | 'SELL' | 'HOLD' | |
| signal_probability: number | |
| predicted_price: number | |
| predicted_change_pct: number | |
| sell_target: number | |
| stop_loss: number | |
| confidence: 'HIGH' | 'MEDIUM' | 'LOW' | |
| current_price: number | |
| training_accuracy: number | |
| prediction_horizon_days?: number | |
| oil_news_signal?: { | |
| summary?: string | |
| sentiment_score?: number | |
| event_score?: number | |
| confidence?: number | |
| } | null | |
| trend_conflict_guard?: { | |
| applied?: boolean | |
| reason?: string | null | |
| original_signal?: string | |
| final_signal?: string | |
| snapshot?: { | |
| bearish_count?: number | |
| bullish_count?: number | |
| return_5d?: number | null | |
| return_20d?: number | null | |
| rsi?: number | null | |
| } | |
| } | null | |
| oil_news_probability_adjustment?: number | |
| oil_news_change_adjustment_pct?: number | |
| disclaimer: string | |
| error?: string | |
| } | |
| interface Props { | |
| prediction: MetalPredictionData | |
| theme: 'gold' | 'silver' | 'oil' | |
| } | |
| const themeConfig = { | |
| gold: { border: 'border-yellow-500', bg: 'bg-yellow-500/10', text: 'text-yellow-400' }, | |
| silver: { border: 'border-slate-400', bg: 'bg-slate-400/10', text: 'text-slate-300' }, | |
| oil: { border: 'border-cyan-500', bg: 'bg-cyan-500/10', text: 'text-cyan-300' }, | |
| } | |
| const signalConfig = { | |
| BUY: { label: 'BUY', zh: '買入', bg: 'bg-green-600', Icon: TrendingUp }, | |
| SELL: { label: 'SELL', zh: '賣出', bg: 'bg-red-600', Icon: TrendingDown }, | |
| HOLD: { label: 'HOLD', zh: '觀望', bg: 'bg-yellow-600', Icon: Minus }, | |
| } | |
| export const MetalPredictionCard: React.FC<Props> = ({ prediction: p, theme }) => { | |
| const t = themeConfig[theme] | |
| const s = signalConfig[p.signal] || signalConfig.HOLD | |
| const up = p.predicted_change_pct >= 0 | |
| const prob = Math.round(p.signal_probability * 100) | |
| const reliability = getPredictionReliabilitySummary({ | |
| confidence: p.confidence, | |
| trainingAccuracy: p.training_accuracy, | |
| }) | |
| if (p.error) { | |
| return ( | |
| <div className={`rounded-xl border-2 ${t.border} ${t.bg} p-4`}> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <Gem size={18} className={t.text} /> | |
| <h3 className="text-base font-bold text-white">{p.name}</h3> | |
| </div> | |
| <p className="text-red-400 text-sm">{p.error}</p> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className={`rounded-xl border-2 ${t.border} ${t.bg} p-4 space-y-4`}> | |
| {/* Header */} | |
| <div className="flex items-center justify-between gap-2 flex-wrap"> | |
| <div className="flex items-center gap-2"> | |
| <Gem size={18} className={t.text} /> | |
| <div> | |
| <h3 className="text-base font-bold text-white">{p.name}</h3> | |
| <p className="text-xs text-slate-400">ML 預測 · 未來 {p.prediction_horizon_days ?? 5} 交易日</p> | |
| </div> | |
| </div> | |
| <div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg ${s.bg}`}> | |
| <s.Icon size={16} className="text-white" /> | |
| <span className="text-white font-black text-lg">{s.label}</span> | |
| <span className="text-white/80 text-xs">({s.zh})</span> | |
| </div> | |
| </div> | |
| {/* Price grid */} | |
| <div className="grid grid-cols-2 gap-3"> | |
| {[ | |
| { label: '現貨價', val: `$${p.current_price.toFixed(2)}`, cls: 'text-white' }, | |
| { label: '預測價(5日)', val: `$${p.predicted_price.toFixed(2)}`, cls: up ? 'text-green-400' : 'text-red-400', | |
| sub: `${up ? '+' : ''}${p.predicted_change_pct.toFixed(2)}%` }, | |
| { label: '目標價', val: `$${p.sell_target.toFixed(2)}`, cls: 'text-green-300' }, | |
| { label: '停損', val: `$${p.stop_loss.toFixed(2)}`, cls: 'text-red-300' }, | |
| ].map(({ label, val, cls, sub }) => ( | |
| <div key={label} className="bg-slate-900/60 rounded-lg p-3 border border-slate-700"> | |
| <p className="text-xs text-slate-400">{label}</p> | |
| <p className={`text-base font-bold ${cls}`}>{val}</p> | |
| {sub && <p className={`text-xs font-semibold mt-0.5 ${up ? 'text-green-400' : 'text-red-400'}`}>{sub}</p>} | |
| </div> | |
| ))} | |
| </div> | |
| {/* Probability bar */} | |
| <div className="space-y-1"> | |
| <div className="flex justify-between text-xs"> | |
| <span className="text-slate-400">買入機率</span> | |
| <span className={`font-bold ${t.text}`}>{prob}%</span> | |
| </div> | |
| <div className="h-2.5 bg-slate-700 rounded-full overflow-hidden"> | |
| <div className={`h-full rounded-full transition-all duration-700 ${ | |
| prob >= 60 ? 'bg-green-500' : prob <= 35 ? 'bg-red-500' : 'bg-yellow-500' | |
| }`} style={{ width: `${prob}%` }} /> | |
| </div> | |
| </div> | |
| {/* Reliability */} | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-2 text-sm"> | |
| <div className="bg-slate-800 rounded-lg px-3 py-2"> | |
| <div className="text-[11px] text-slate-400">整體可靠度</div> | |
| <div className={`font-bold ${reliability.levelColor}`}>{reliability.levelText}</div> | |
| </div> | |
| <div className="bg-slate-800 rounded-lg px-3 py-2"> | |
| <div className="text-[11px] text-slate-400">預測信心度</div> | |
| <div className={`font-bold ${reliability.confidenceColor}`}>{reliability.confidenceText}</div> | |
| </div> | |
| <div className="bg-slate-800 rounded-lg px-3 py-2"> | |
| <div className="text-[11px] text-slate-400">模型近期驗證準確率</div> | |
| <div className={`font-semibold ${reliability.accuracyColor}`}>{reliability.accuracyText}</div> | |
| </div> | |
| </div> | |
| {reliability.warning && ( | |
| <div className="flex items-start gap-2 bg-red-500/10 rounded-lg p-3 border border-red-700/70"> | |
| <AlertTriangle size={13} className="text-red-300 mt-0.5 flex-shrink-0" /> | |
| <p className="text-xs text-red-200 leading-relaxed">{reliability.warning}</p> | |
| </div> | |
| )} | |
| {p.trend_conflict_guard?.applied && ( | |
| <div className="flex items-start gap-2 bg-amber-500/10 rounded-lg p-3 border border-amber-700/70"> | |
| <AlertTriangle size={13} className="text-amber-300 mt-0.5 flex-shrink-0" /> | |
| <p className="text-xs text-amber-100 leading-relaxed"> | |
| 強趨勢衝突 guard 已將 {p.trend_conflict_guard.original_signal} 調整為 {p.trend_conflict_guard.final_signal}。 | |
| 空方條件 {p.trend_conflict_guard.snapshot?.bearish_count ?? 0} 項,5日報酬 {((p.trend_conflict_guard.snapshot?.return_5d ?? 0) * 100).toFixed(2)}%。 | |
| </p> | |
| </div> | |
| )} | |
| {theme === 'oil' && p.oil_news_signal && ( | |
| <div className="bg-slate-900/70 rounded-lg p-3 border border-cyan-700/60"> | |
| <div className="flex items-center justify-between gap-2 mb-2"> | |
| <span className="text-xs font-semibold text-cyan-300">新聞 / 政治事件 overlay</span> | |
| <span className="text-xs text-slate-400"> | |
| {((p.oil_news_probability_adjustment ?? 0) * 100).toFixed(2)}pp | |
| </span> | |
| </div> | |
| <p className="text-xs text-slate-300 leading-relaxed line-clamp-4 whitespace-pre-line"> | |
| {p.oil_news_signal.summary || '尚無新聞訊號'} | |
| </p> | |
| </div> | |
| )} | |
| {/* Disclaimer */} | |
| <div className="flex items-start gap-2 bg-slate-900/70 rounded-lg p-3 border border-slate-700"> | |
| <AlertTriangle size={13} className="text-yellow-400 mt-0.5 flex-shrink-0" /> | |
| <p className="text-xs text-slate-400"> | |
| <span className="text-yellow-400 font-semibold">免責聲明:</span> | |
| {p.disclaimer} | |
| </p> | |
| </div> | |
| </div> | |
| ) | |
| } | |