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 = ({ 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 (
{/* Quick-estimate banner */} {isQuick && (
快速估算 {' '}— 僅供參考(技術指標規則),ML 模型正在背景計算中,約 2 分鐘後自動更新。
)} {oldwangRiskBlocked && (
老王高風險觀望 {' '}— ML 原始訊號為 BUY,但已出現 {oldwang?.bear_score.toFixed(0)} 個老王風險條件,不列入買入建議。
)} {/* Header */}

{stockName} — {isQuick ? '快速估算' : 'ML 預測信號'}

預測期間:未來 5 個交易日

{onRecalculate && ( )}
{oldwangRiskBlocked ? 'HOLD' : cfg.label} ({oldwangRiskBlocked ? '老王風險觀望' : cfg.labelZh})
{/* Price grid */}
{todayUp ? '▲' : '▼'}{Math.abs(todayChangePct).toFixed(2)}% {todayChangeAbs != null && ` (${todayUp ? '+' : ''}${todayChangeAbs.toFixed(2)})`} ) : undefined} /> {changePositive ? '+' : ''}{prediction.predicted_change_pct.toFixed(2)}% } valueClass={changePositive ? 'text-green-400' : 'text-red-400'} />
{/* Signal probability bar */}
{isQuick ? '快速估算分數 (Quick Score)' : '買入機率 (Buy Probability)'} {probPct}%{rawProbPct !== probPct && · ML {rawProbPct}%}
= 60 ? 'bg-green-500' : probPct <= 35 ? 'bg-red-500' : 'bg-yellow-500' }`} style={{ width: `${probPct}%` }} />
強力賣出 (Strong Sell) 強力買入 (Strong Buy)
法人籌碼 {prediction.institutional_carry_forward && ( 沿用前日 )}
{[ ['外資', 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 (
{label as string}
= 0 ? 'text-green-400' : 'text-red-400'}`}> {formatShares(num)}
) })}

資料日: {prediction.institutional_as_of ?? '尚無'} · {prediction.feature_version ?? 'feature-v1'}

新聞影響 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}%
情緒
{((prediction.news_signal?.sentiment_score ?? 0) * 100).toFixed(0)}
事件
{((prediction.news_signal?.event_score ?? 0) * 100).toFixed(0)}
信度
{((prediction.news_signal?.confidence ?? 0) * 100).toFixed(0)}%

{prediction.news_signal?.reason ? '新聞未納入調整' : prediction.news_as_of ? `更新: ${prediction.news_as_of.slice(0, 10)}` : '未啟用或無新聞訊號'}

{oldwang && (
老王策略
多方 {oldwang.bull_score.toFixed(0)} 風險 {oldwang.bear_score.toFixed(0)}
多方理由
{(oldwang.bull_reasons?.length ?? 0) > 0 ? ( oldwang.bull_reasons?.slice(0, 3).map((reason) => (
{reason}
)) ) : (
暫無明顯多方條件
)}
風險提醒
{(oldwang.risk_reasons?.length ?? 0) > 0 ? ( oldwang.risk_reasons?.slice(0, 3).map((reason) => (
{reason}
)) ) : (
暫無主要風險條件
)}
{oldwangKeyLevels.length > 0 && (
{oldwangKeyLevels.map(([label, value]) => (
{label}
{cur} {value.toFixed(2)}
))}
)}
)} {/* Reliability + confidence + accuracy */}
整體可靠度
{reliability.levelText}
預測信心度
{reliability.confidenceText}
模型近期驗證準確率
{reliability.accuracyText}
{reliability.warning && (

{reliability.warning}

)} {/* Disclaimer */}

免責聲明: 本系統所有預測訊號僅供學術研究與教育用途,不構成任何投資建議。 機器學習模型無法保證未來績效,投資人應自行判斷並承擔風險。 {' '} (ML signals are for educational purposes only and do NOT constitute financial advice.)

) } // --------------------------------------------------------------------------- // Helper sub-component // --------------------------------------------------------------------------- interface PriceCardProps { label: string labelEn: string value: string valueClass?: string sub?: React.ReactNode } const PriceCard: React.FC = ({ label, labelEn, value, valueClass = 'text-white', sub, }) => (

{label}

{labelEn}

{value}

{sub &&
{sub}
}
)