Spaces:
Running
Running
| import React from 'react' | |
| import { TrendingUp, TrendingDown, Minus, AlertTriangle, Building2, Newspaper, Loader2, RefreshCw, ShieldCheck } from 'lucide-react' | |
| import { Prediction, QuoteData } from '../api/stockApi' | |
| import { getPredictionReliabilitySummary } from '../utils/predictionReliability' | |
| interface Props { | |
| prediction: Prediction | |
| stockName: string | |
| quote?: QuoteData | null | |
| currency?: string | |
| onRecalculate?: () => void | |
| recalculating?: boolean | |
| } | |
| const signalConfig = { | |
| BUY: { | |
| label: 'BUY', | |
| labelZh: '買入', | |
| bg: 'bg-green-500/10', | |
| border: 'border-green-500', | |
| text: 'text-green-400', | |
| badgeBg: 'bg-green-600', | |
| icon: TrendingUp, | |
| }, | |
| SELL: { | |
| label: 'SELL', | |
| labelZh: '賣出', | |
| bg: 'bg-red-500/10', | |
| border: 'border-red-500', | |
| text: 'text-red-400', | |
| badgeBg: 'bg-red-600', | |
| icon: TrendingDown, | |
| }, | |
| HOLD: { | |
| label: 'HOLD', | |
| labelZh: '持觀望', | |
| bg: 'bg-yellow-500/10', | |
| border: 'border-yellow-500', | |
| text: 'text-yellow-400', | |
| badgeBg: 'bg-yellow-600', | |
| icon: Minus, | |
| }, | |
| } | |
| function formatShares(value?: number): string { | |
| const v = Number(value ?? 0) | |
| const abs = Math.abs(v) | |
| const sign = v > 0 ? '+' : v < 0 ? '-' : '' | |
| if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M` | |
| if (abs >= 1_000) return `${sign}${(abs / 1_000).toFixed(0)}K` | |
| return `${sign}${abs.toFixed(0)}` | |
| } | |
| export const PredictionPanel: React.FC<Props> = ({ | |
| prediction, | |
| stockName, | |
| quote, | |
| currency = 'NT$', | |
| onRecalculate, | |
| recalculating = false, | |
| }) => { | |
| const cfg = signalConfig[prediction.signal] | |
| const Icon = cfg.icon | |
| const probPct = Math.round(prediction.signal_probability * 100) | |
| const rawProbPct = prediction.raw_signal_probability != null | |
| ? Math.round(prediction.raw_signal_probability * 100) | |
| : probPct | |
| const newsAdjustmentPct = Math.round((prediction.news_adjustment ?? 0) * 100) | |
| const changePositive = prediction.predicted_change_pct >= 0 | |
| const cur = currency | |
| const displayPrice = quote?.price ?? prediction.current_price | |
| const todayChangePct = quote?.change_pct ?? null | |
| const todayChangeAbs = quote?.change ?? null | |
| const todayUp = todayChangePct != null && todayChangePct >= 0 | |
| const isQuick = prediction.optimized === false | |
| const oldwang = prediction.oldwang_context | |
| const oldwangRiskBlocked = prediction.signal === 'BUY' && (oldwang?.bear_score ?? 0) >= 2 | |
| const displayCfg = oldwangRiskBlocked ? signalConfig.HOLD : cfg | |
| const DisplayIcon = oldwangRiskBlocked ? AlertTriangle : Icon | |
| const oldwangLevels = oldwang?.key_levels | |
| const oldwangKeyLevels = [ | |
| ['爆量低點', oldwangLevels?.volume_spike_low], | |
| ['缺口支撐', oldwangLevels?.gap_support], | |
| ['20MA', oldwangLevels?.ma20], | |
| ].filter((entry): entry is [string, number] => typeof entry[1] === 'number') | |
| const reliability = getPredictionReliabilitySummary({ | |
| confidence: prediction.confidence, | |
| trainingAccuracy: prediction.training_accuracy, | |
| isQuick, | |
| }) | |
| return ( | |
| <div className={`rounded-xl border-2 ${displayCfg.border} ${displayCfg.bg} p-3 sm:p-5 space-y-3 sm:space-y-4`}> | |
| {/* Quick-estimate banner */} | |
| {isQuick && ( | |
| <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-slate-700/60 border border-slate-500 text-slate-300 text-xs"> | |
| <Loader2 size={14} className="animate-spin text-yellow-400 shrink-0" /> | |
| <span> | |
| <span className="text-yellow-400 font-semibold">快速估算</span> | |
| {' '}— 僅供參考(技術指標規則),ML 模型正在背景計算中,約 2 分鐘後自動更新。 | |
| </span> | |
| </div> | |
| )} | |
| {oldwangRiskBlocked && ( | |
| <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-950/50 border border-red-700 text-red-200 text-xs"> | |
| <AlertTriangle size={14} className="text-red-300 shrink-0" /> | |
| <span> | |
| <span className="font-semibold">老王高風險觀望</span> | |
| {' '}— ML 原始訊號為 BUY,但已出現 {oldwang?.bear_score.toFixed(0)} 個老王風險條件,不列入買入建議。 | |
| </span> | |
| </div> | |
| )} | |
| {/* Header */} | |
| <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2"> | |
| <div> | |
| <h2 className="text-base sm:text-lg font-bold text-white"> | |
| {stockName} — {isQuick ? '快速估算' : 'ML 預測信號'} | |
| </h2> | |
| <p className="text-xs text-slate-400 mt-0.5">預測期間:未來 5 個交易日</p> | |
| </div> | |
| <div className="flex items-center gap-2 flex-wrap justify-end"> | |
| {onRecalculate && ( | |
| <button | |
| type="button" | |
| onClick={onRecalculate} | |
| disabled={recalculating} | |
| className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 disabled:opacity-60 disabled:cursor-not-allowed border border-slate-600 text-slate-200 text-xs font-medium transition-colors" | |
| title="清除快取並重新計算 ML 預測" | |
| > | |
| {recalculating ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />} | |
| {recalculating ? '重算中' : '重算'} | |
| </button> | |
| )} | |
| <div className={`flex items-center gap-2 px-3 sm:px-4 py-1.5 sm:py-2 rounded-lg ${displayCfg.badgeBg} shadow-lg`}> | |
| <DisplayIcon size={18} className="text-white" /> | |
| <span className="text-white font-black text-lg sm:text-xl tracking-wider"> | |
| {oldwangRiskBlocked ? 'HOLD' : cfg.label} | |
| </span> | |
| <span className="text-white/80 text-xs sm:text-sm"> | |
| ({oldwangRiskBlocked ? '老王風險觀望' : cfg.labelZh}) | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Price grid */} | |
| <div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> | |
| <PriceCard | |
| label="目前價格" | |
| labelEn="Current Price" | |
| value={`${cur} ${displayPrice.toFixed(2)}`} | |
| valueClass={todayChangePct == null ? 'text-white' : todayUp ? 'text-green-400' : 'text-red-400'} | |
| sub={todayChangePct != null ? ( | |
| <span className={todayUp ? 'text-green-400' : 'text-red-400'}> | |
| {todayUp ? '▲' : '▼'}{Math.abs(todayChangePct).toFixed(2)}% | |
| {todayChangeAbs != null && ` (${todayUp ? '+' : ''}${todayChangeAbs.toFixed(2)})`} | |
| </span> | |
| ) : undefined} | |
| /> | |
| <PriceCard | |
| label="預測價格 (5日)" | |
| labelEn="Predicted Price" | |
| value={`${cur} ${prediction.predicted_price.toFixed(2)}`} | |
| sub={ | |
| <span className={changePositive ? 'text-green-400' : 'text-red-400'}> | |
| {changePositive ? '+' : ''}{prediction.predicted_change_pct.toFixed(2)}% | |
| </span> | |
| } | |
| valueClass={changePositive ? 'text-green-400' : 'text-red-400'} | |
| /> | |
| <PriceCard | |
| label="建議賣出目標" | |
| labelEn="Sell Target" | |
| value={`${cur} ${prediction.sell_target.toFixed(2)}`} | |
| valueClass="text-green-300" | |
| /> | |
| <PriceCard | |
| label="停損價位" | |
| labelEn="Stop Loss" | |
| value={`${cur} ${prediction.stop_loss.toFixed(2)}`} | |
| valueClass="text-red-300" | |
| /> | |
| </div> | |
| {/* Signal probability bar */} | |
| <div className="space-y-1.5"> | |
| <div className="flex justify-between items-center text-xs"> | |
| <span className="text-slate-400"> | |
| {isQuick ? '快速估算分數 (Quick Score)' : '買入機率 (Buy Probability)'} | |
| </span> | |
| <span className={`font-bold ${displayCfg.text}`}> | |
| {probPct}%{rawProbPct !== probPct && <span className="text-slate-500 font-medium"> · ML {rawProbPct}%</span>} | |
| </span> | |
| </div> | |
| <div className="h-2.5 bg-slate-700 rounded-full overflow-hidden"> | |
| <div | |
| className={`h-full rounded-full transition-all duration-700 ${ | |
| probPct >= 60 ? 'bg-green-500' : probPct <= 35 ? 'bg-red-500' : 'bg-yellow-500' | |
| }`} | |
| style={{ width: `${probPct}%` }} | |
| /> | |
| </div> | |
| <div className="flex justify-between text-xs text-slate-500"> | |
| <span>強力賣出 (Strong Sell)</span> | |
| <span>強力買入 (Strong Buy)</span> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> | |
| <div className="bg-slate-900/60 rounded-lg p-3 border border-slate-700 space-y-2"> | |
| <div className="flex items-center gap-2"> | |
| <Building2 size={14} className="text-cyan-400" /> | |
| <span className="text-xs font-semibold text-slate-200">法人籌碼</span> | |
| {prediction.institutional_carry_forward && ( | |
| <span className="text-[10px] text-yellow-300 bg-yellow-500/10 border border-yellow-700 rounded px-1.5 py-0.5">沿用前日</span> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-4 gap-2 text-center"> | |
| {[ | |
| ['外資', prediction.institutional_flow?.foreign_net], | |
| ['投信', prediction.institutional_flow?.trust_net], | |
| ['自營', prediction.institutional_flow?.dealer_net], | |
| ['合計', prediction.institutional_flow?.institutional_net], | |
| ].map(([label, value]) => { | |
| const num = Number(value ?? 0) | |
| return ( | |
| <div key={label as string} className="min-w-0"> | |
| <div className="text-[10px] text-slate-500">{label as string}</div> | |
| <div className={`text-xs font-bold truncate ${num >= 0 ? 'text-green-400' : 'text-red-400'}`}> | |
| {formatShares(num)} | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| <p className="text-[10px] text-slate-500"> | |
| 資料日: {prediction.institutional_as_of ?? '尚無'} · {prediction.feature_version ?? 'feature-v1'} | |
| </p> | |
| </div> | |
| <div className="bg-slate-900/60 rounded-lg p-3 border border-slate-700 space-y-2"> | |
| <div className="flex items-center gap-2"> | |
| <Newspaper size={14} className="text-amber-400" /> | |
| <span className="text-xs font-semibold text-slate-200">新聞影響</span> | |
| <span className={`text-[10px] rounded px-1.5 py-0.5 border ${ | |
| newsAdjustmentPct > 0 | |
| ? 'text-green-300 bg-green-500/10 border-green-700' | |
| : newsAdjustmentPct < 0 | |
| ? 'text-red-300 bg-red-500/10 border-red-700' | |
| : 'text-slate-400 bg-slate-800 border-slate-700' | |
| }`}> | |
| {newsAdjustmentPct > 0 ? '+' : ''}{newsAdjustmentPct}% | |
| </span> | |
| </div> | |
| <div className="grid grid-cols-3 gap-2 text-center"> | |
| <div> | |
| <div className="text-[10px] text-slate-500">情緒</div> | |
| <div className="text-xs font-bold text-slate-200">{((prediction.news_signal?.sentiment_score ?? 0) * 100).toFixed(0)}</div> | |
| </div> | |
| <div> | |
| <div className="text-[10px] text-slate-500">事件</div> | |
| <div className="text-xs font-bold text-slate-200">{((prediction.news_signal?.event_score ?? 0) * 100).toFixed(0)}</div> | |
| </div> | |
| <div> | |
| <div className="text-[10px] text-slate-500">信度</div> | |
| <div className="text-xs font-bold text-slate-200">{((prediction.news_signal?.confidence ?? 0) * 100).toFixed(0)}%</div> | |
| </div> | |
| </div> | |
| <p className="text-[10px] text-slate-500 truncate"> | |
| {prediction.news_signal?.reason ? '新聞未納入調整' : prediction.news_as_of ? `更新: ${prediction.news_as_of.slice(0, 10)}` : '未啟用或無新聞訊號'} | |
| </p> | |
| </div> | |
| </div> | |
| {oldwang && ( | |
| <div className="bg-slate-900/60 rounded-lg p-3 border border-slate-700 space-y-3"> | |
| <div className="flex items-center justify-between gap-2 flex-wrap"> | |
| <div className="flex items-center gap-2"> | |
| <ShieldCheck size={14} className="text-emerald-400" /> | |
| <span className="text-xs font-semibold text-slate-200">老王策略</span> | |
| </div> | |
| <div className="flex items-center gap-1.5 text-[10px]"> | |
| <span className="rounded px-1.5 py-0.5 bg-green-500/10 text-green-300 border border-green-700"> | |
| 多方 {oldwang.bull_score.toFixed(0)} | |
| </span> | |
| <span className="rounded px-1.5 py-0.5 bg-red-500/10 text-red-300 border border-red-700"> | |
| 風險 {oldwang.bear_score.toFixed(0)} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> | |
| <div className="space-y-1"> | |
| <div className="text-[10px] text-green-300 font-semibold">多方理由</div> | |
| {(oldwang.bull_reasons?.length ?? 0) > 0 ? ( | |
| oldwang.bull_reasons?.slice(0, 3).map((reason) => ( | |
| <div key={reason} className="text-xs text-slate-300 leading-relaxed">{reason}</div> | |
| )) | |
| ) : ( | |
| <div className="text-xs text-slate-500">暫無明顯多方條件</div> | |
| )} | |
| </div> | |
| <div className="space-y-1"> | |
| <div className="text-[10px] text-red-300 font-semibold">風險提醒</div> | |
| {(oldwang.risk_reasons?.length ?? 0) > 0 ? ( | |
| oldwang.risk_reasons?.slice(0, 3).map((reason) => ( | |
| <div key={reason} className="text-xs text-slate-300 leading-relaxed">{reason}</div> | |
| )) | |
| ) : ( | |
| <div className="text-xs text-slate-500">暫無主要風險條件</div> | |
| )} | |
| </div> | |
| </div> | |
| {oldwangKeyLevels.length > 0 && ( | |
| <div className="grid grid-cols-3 gap-2 pt-1"> | |
| {oldwangKeyLevels.map(([label, value]) => ( | |
| <div key={label} className="rounded bg-slate-800/80 px-2 py-1.5 min-w-0"> | |
| <div className="text-[10px] text-slate-500 truncate">{label}</div> | |
| <div className="text-xs font-bold text-slate-200 truncate">{cur} {value.toFixed(2)}</div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Reliability + confidence + accuracy */} | |
| <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={14} className="text-red-300 mt-0.5 flex-shrink-0" /> | |
| <p className="text-xs text-red-200 leading-relaxed">{reliability.warning}</p> | |
| </div> | |
| )} | |
| {/* Disclaimer */} | |
| <div className="flex items-start gap-2 bg-slate-900/70 rounded-lg p-3 border border-slate-700"> | |
| <AlertTriangle size={14} className="text-yellow-400 mt-0.5 flex-shrink-0" /> | |
| <p className="text-xs text-slate-400 leading-relaxed"> | |
| <span className="text-yellow-400 font-semibold">免責聲明:</span> | |
| 本系統所有預測訊號僅供學術研究與教育用途,不構成任何投資建議。 | |
| 機器學習模型無法保證未來績效,投資人應自行判斷並承擔風險。 | |
| {' '} | |
| <span className="text-slate-500 italic"> | |
| (ML signals are for educational purposes only and do NOT constitute financial advice.) | |
| </span> | |
| </p> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Helper sub-component | |
| // --------------------------------------------------------------------------- | |
| interface PriceCardProps { | |
| label: string | |
| labelEn: string | |
| value: string | |
| valueClass?: string | |
| sub?: React.ReactNode | |
| } | |
| const PriceCard: React.FC<PriceCardProps> = ({ | |
| label, | |
| labelEn, | |
| value, | |
| valueClass = 'text-white', | |
| sub, | |
| }) => ( | |
| <div className="bg-slate-900/60 rounded-lg p-3 space-y-1 border border-slate-700"> | |
| <p className="text-xs text-slate-400 leading-tight">{label}</p> | |
| <p className="text-xs text-slate-500">{labelEn}</p> | |
| <p className={`text-base font-bold ${valueClass} leading-tight`}>{value}</p> | |
| {sub && <div className="text-xs font-semibold">{sub}</div>} | |
| </div> | |
| ) | |