DockerSpace / frontend /src /components /PredictionPanel.tsx
DennisChan0909's picture
Merge remote-tracking branch 'hf/main' into codex/self-refine-validation-gate
099ee80
Raw
History Blame Contribute Delete
17.3 kB
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>
)