'use client'; /** * 交易面板组件 * 仿同花顺风格:买卖档位、资金持仓 */ import { useState, useEffect, useMemo, useRef } from 'react'; import { TrendingUp, TrendingDown, Wallet, Package, RotateCcw, Eye, ChevronRight, Play, Pause, BarChart3, Trophy, X, TrendingUpIcon, Share2 } from 'lucide-react'; import { useGameStore, calculateReturnRate, calculateTotalAsset } from '@/store/gameStore'; import { useAuthStore } from '@/store/authStore'; import { api } from '@/lib/api'; import UserMenu from './UserMenu'; import LoginPage from './LoginPage'; import PaymentModal from './PaymentModal'; import { createChart, ColorType, LineData, Time, LineStyle } from 'lightweight-charts'; import html2canvas from 'html2canvas'; import { QRCodeSVG } from 'qrcode.react'; export default function TradePanel() { const [volume, setVolume] = useState(100); const [isLoading, setIsLoading] = useState(false); const [showReturnChart, setShowReturnChart] = useState(false); const [autoPlay, setAutoPlay] = useState(false); const [selectedMarket, setSelectedMarket] = useState('全部'); const [showPayment, setShowPayment] = useState(false); const { token, isVip, vipExpireAt, username, isInitialized, dailyUsed, dailyRemaining, dailyLimit } = useAuthStore(); const markets = ['全部', '全A股', '主板', '创业板', '科创板', '北交所', 'ETF', 'LOF', '可转债', 'REITs']; const { isPlaying, isRevealed, isFinished, allKlines, currentIndex, cash, holdings, avgPrice, history, initialIndex, initialCapital, startGame, nextCandle, buy, sell, reveal, finish, reset, } = useGameStore(); const currentPrice = allKlines[currentIndex]?.close || 0; const returnRate = calculateReturnRate(useGameStore.getState()); const totalAsset = calculateTotalAsset(useGameStore.getState()); const profit = totalAsset - initialCapital; const isProfit = profit >= 0; const canFinish = isRevealed || currentIndex >= allKlines.length - 1; const tradingDays = currentIndex - initialIndex + 1; // 计算收益统计 const stats = useMemo(() => { if (!isFinished) return null; let maxValue = initialCapital; let maxDrawdown = 0; let totalProfit = 0; let totalLoss = 0; let currentHoldings = 0; let currentCost = 0; let winCount = 0; let sellCount = 0; // 模拟交易流来计算最大回撤和每笔卖出的盈亏 // 我们需要遍历从开始到结束的每一天来计算资产曲线 let tempCash = initialCapital; let tempHoldings = 0; let tempCost = 0; for (let i = initialIndex; i <= currentIndex; i++) { const kline = allKlines[i]; if (!kline) continue; // 处理当天的交易 const tradesAtPoint = history.filter(t => t.date === kline.date); tradesAtPoint.forEach(trade => { if (trade.type === 'buy') { tempCost = (tempHoldings * tempCost + trade.totalCost) / (tempHoldings + trade.volume); tempCash -= trade.totalCost; tempHoldings += trade.volume; } else { const pnl = (trade.price - tempCost) * trade.volume; if (pnl > 0) winCount++; sellCount++; if (pnl > 0) totalProfit += pnl; else totalLoss += Math.abs(pnl); tempCash += trade.totalCost; tempHoldings -= trade.volume; if (tempHoldings === 0) tempCost = 0; } }); // 计算当日总资产 const currentAsset = tempCash + tempHoldings * kline.close; // 更新最大回撤逻辑 if (currentAsset > maxValue) maxValue = currentAsset; const drawdown = (maxValue - currentAsset) / maxValue; if (drawdown > maxDrawdown) maxDrawdown = drawdown; } const winRate = sellCount > 0 ? winCount / sellCount : 0; // 优化盈亏比计算:平均每笔盈利 / 平均每笔亏损 const avgProfit = winCount > 0 ? totalProfit / winCount : 0; const lossCount = sellCount - winCount; const avgLoss = lossCount > 0 ? totalLoss / lossCount : 0; const profitLossRatio = avgLoss > 0 ? avgProfit / avgLoss : (avgProfit > 0 ? 999 : 0); // 计算年化收益率 (平滑处理,至少按 1 个月计) const effectiveDays = Math.max(20, tradingDays); const years = effectiveDays / 250; const annualReturn = Math.pow(totalAsset / initialCapital, 1 / years) - 1; return { totalReturn: returnRate, annualReturn, maxDrawdown, winRate, profitLossRatio, tradeCount: history.length, buyCount: history.filter(t => t.type === 'buy').length, sellCount: sellCount, }; }, [isFinished, history, initialCapital, totalAsset, returnRate, tradingDays, allKlines, initialIndex, currentIndex]); // 自动播放 useEffect(() => { let interval: NodeJS.Timeout; if (autoPlay && isPlaying && currentIndex < allKlines.length - 1) { interval = setInterval(() => { nextCandle(); }, 1000); } else { setAutoPlay(false); } return () => clearInterval(interval); }, [autoPlay, isPlaying, currentIndex, allKlines.length, nextCandle]); // 初始化认证状态 - 解决 UserMenu 被 loading 阻塞导致无法初始化的问题 useEffect(() => { const { isInitialized, initialize } = useAuthStore.getState(); if (!isInitialized) { initialize(); } }, []); const handleStartGame = async () => { setIsLoading(true); try { const data = await api.startGame('random', selectedMarket, token || undefined); startGame(data); // 刷新今日使用次数 useAuthStore.getState().fetchUsage(); } catch (error: any) { // 检查 ApiError 结构: error.data.detail.code const detail = error.data?.detail; if (detail && detail.code === 'DAILY_LIMIT_EXCEEDED') { // 已经在 UI 中处理了显示,这里强制刷新一下 usage useAuthStore.getState().fetchUsage(); } else { alert(error.message || '启动游戏失败,请检查后端服务'); } } finally { setIsLoading(false); } }; const handleBuy = () => { if (volume > 0 && currentPrice > 0) { const success = buy(volume); if (!success) alert('资金不足'); } }; const handleSell = () => { if (volume > 0 && holdings >= volume) { const success = sell(volume); if (!success) alert('持仓不足'); } }; const setBuyPosition = (ratio: number) => { const maxBuy = Math.floor(cash / currentPrice / 100) * 100; setVolume(Math.max(100, Math.floor(maxBuy * ratio / 100) * 100)); }; const setSellPosition = (ratio: number) => { setVolume(Math.max(100, Math.floor(holdings * ratio / 100) * 100)); }; // 未开始游戏界面 if (!isPlaying) { // 未初始化(加载中) if (!isInitialized) { return (
); } // ===== 未登录:显示全屏登录/注册页 ===== if (!username) { return ; } // ===== 已登录:显示游戏开始界面 ===== const limitExceeded = !isVip && dailyLimit !== null && dailyUsed >= dailyLimit; return (
{/* 用户菜单 - 右上角 */}

StockReplay

A股历史行情复盘训练系统

{isVip && vipExpireAt && (
⭐ VIP 会员 · 到期: {vipExpireAt.split(' ')[0]}
)}

盲盒挑战

系统将随机抽取一只A股的历史行情,你需要根据K线走势进行模拟交易,验证你的盘感与策略。

{/* 每日次数提示 */} {!isVip && dailyLimit !== null && (
= dailyLimit - 1 ? 'bg-yellow-500/10 border border-yellow-500/30 text-yellow-400' : 'bg-blue-500/10 border border-blue-500/30 text-blue-400' }`}> {limitExceeded ? '今日免费次数已用完' : `今日剩余次数:${dailyLimit - dailyUsed} / ${dailyLimit}`} {limitExceeded && ( 明日重置 )}
)} {/* 次数用完时的 VIP 引导 */} {limitExceeded ? (
⭐ 开通 VIP 会员
真实历史行情 · 无限次回测 · 从此走上盈利之路
) : ( )}
初始资金
100万
标的范围
{/* 自定义下拉箭头 */}
数据周期
10年
setShowPayment(false)} />
); } // 回测结束界面 if (isFinished && stats) { return ( <>

回测结束

{useGameStore.getState().realName} ({useGameStore.getState().realCode})

最终总资产
{totalAsset.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
{isProfit ? '+' : ''}{profit.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} ({(stats.totalReturn * 100).toFixed(2)}%)
年化收益
= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{(stats.annualReturn * 100).toFixed(2)}%
最大回撤
-{(stats.maxDrawdown * 100).toFixed(2)}%
行情天数
{tradingDays} 天
交易次数
{stats.tradeCount} 笔
交易统计
{stats.buyCount}
买入
{stats.sellCount}
卖出
= 0.5 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{(stats.winRate * 100).toFixed(1)}%
胜率
交易明细
{history.length === 0 ?
暂无交易记录
: history.slice().reverse().map((trade, idx) => (
{trade.type === 'buy' ? '买入' : '卖出'} {trade.volume}股{trade.date}
{trade.price.toFixed(2)}
成交: {(trade.totalCost / 10000).toFixed(1)}万
))}
{showReturnChart && setShowReturnChart(false)} allKlines={allKlines} history={history} initialIndex={initialIndex} currentIndex={currentIndex} initialCapital={initialCapital} stats={stats} />} ); } // 正常交易界面 return (
{/* 资产概览 - 手机端极致压缩 */}
总资产 {totalAsset.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}
{isProfit ? '+' : ''}{(returnRate * 100).toFixed(2)}%
可用 {cash.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}
持仓 {(holdings * currentPrice).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}
{holdings > 0 && (
盈亏 = 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}> {((currentPrice - avgPrice) * holdings).toFixed(0)}
)}
{/* 价格 - 手机端标签与输入框同行 */}
市价 {currentPrice.toFixed(2)}
{/* 数量 - 手机端标签与输入框同行 */}
setVolume(Math.max(100, parseInt(e.target.value) || 0))} className="flex-1 w-full bg-[#12141C] border border-gray-700/50 rounded-lg px-1.5 py-1 lg:px-4 lg:py-4 text-white font-mono text-xs lg:text-xl focus:outline-none focus:border-blue-500 transition-colors" step={100} />
{[1 / 4, 1 / 2, 3 / 4, 1].map((ratio) => ( ))}
{[1 / 4, 1 / 2, 3 / 4, 1].map((ratio) => ( ))}
{canFinish ? ( ) : ( )}
); } // 收益率曲线弹窗组件 interface ReturnChartModalProps { onClose: () => void; allKlines: any[]; history: any[]; initialIndex: number; currentIndex: number; initialCapital: number; stats: any; } function ReturnChartModal({ onClose, allKlines, history, initialIndex, currentIndex, initialCapital, stats }: ReturnChartModalProps) { const chartContainerRef = useRef(null); const modalRef = useRef(null); const chartRef = useRef(null); const [hs300Data, setHS300Data] = useState<{ date: string, close: number }[]>([]); const [isLoading, setIsLoading] = useState(true); const [isSharing, setIsSharing] = useState(false); const handleShare = async () => { if (!modalRef.current) return; setIsSharing(true); try { const modal = modalRef.current; const shareFooter = document.getElementById('share-footer'); const originalMaxHeight = modal.style.maxHeight; const originalOverflow = modal.style.overflow; modal.style.maxHeight = 'none'; modal.style.overflow = 'visible'; if (shareFooter) shareFooter.style.display = 'block'; await new Promise(resolve => setTimeout(resolve, 300)); const canvas = await html2canvas(modal, { backgroundColor: '#1E212B', scale: 2, useCORS: true, logging: false, width: modal.offsetWidth, height: modal.scrollHeight, ignoreElements: (element) => element.tagName === 'BUTTON' }); modal.style.maxHeight = originalMaxHeight; modal.style.overflow = originalOverflow; if (shareFooter) shareFooter.style.display = 'none'; const image = canvas.toDataURL('image/png'); if (navigator.share) { const blob = await (await fetch(image)).blob(); const file = new File([blob], 'profit_chart.png', { type: 'image/png' }); await navigator.share({ files: [file], title: '我的复盘收益率', text: '看看我在 StockReplay 的复盘战绩!' }); } else { const link = document.createElement('a'); link.href = image; link.download = `StockReplay_Profit_${new Date().getTime()}.png`; link.click(); } } catch (error) { alert('生成分享图片失败'); } finally { setIsSharing(false); } }; useEffect(() => { const fetchHS300 = async () => { try { const visibleKlines = allKlines.slice(initialIndex, currentIndex + 1); const startDate = visibleKlines[0]?.date; const endDate = visibleKlines[visibleKlines.length - 1]?.date; const data = await api.getHS300Index(startDate, endDate); setHS300Data(data); } catch (error) { console.error('Failed to fetch HS300 data:', error); } finally { setIsLoading(false); } }; fetchHS300(); }, [allKlines, initialIndex, currentIndex]); const { strategyData, benchmarkData, detailedStats } = useMemo(() => { const strategy: LineData[] = []; const benchmark: LineData[] = []; let tempCash = initialCapital; let tempHoldings = 0; let maxValue = initialCapital; let totalProfit = 0; let totalLoss = 0; const visibleKlines = allKlines.slice(initialIndex, currentIndex + 1); const hs300Map = new Map(hs300Data.map(d => [d.date, d.close])); const hs300StartPrice = hs300Data[0]?.close || 4000; let currentCost = 0; for (let i = 0; i < visibleKlines.length; i++) { const kline = visibleKlines[i]; const tradesAtPoint = history.filter(t => t.date === kline.date); tradesAtPoint.forEach(trade => { if (trade.type === 'buy') { currentCost = (tempHoldings * currentCost + trade.totalCost) / (tempHoldings + trade.volume); tempCash -= trade.totalCost; tempHoldings += trade.volume; } else { const pnl = (trade.price - currentCost) * trade.volume; if (pnl > 0) totalProfit += pnl; else totalLoss += Math.abs(pnl); tempCash += trade.totalCost; tempHoldings -= trade.volume; if (tempHoldings === 0) currentCost = 0; } }); const asset = tempCash + tempHoldings * kline.close; strategy.push({ time: kline.date as Time, value: ((asset - initialCapital) / initialCapital) * 100 }); const hs300Close = hs300Map.get(kline.date); if (hs300Close && hs300StartPrice > 0) { benchmark.push({ time: kline.date as Time, value: ((hs300Close - hs300StartPrice) / hs300StartPrice) * 100 }); } if (asset > maxValue) maxValue = asset; } // 计算详细指标 const finalReturn = (strategy[strategy.length - 1]?.value || 0) / 100; const benchmarkReturn = benchmark.length > 0 ? (benchmark[benchmark.length - 1].value || 0) / 100 : 0; // 1. 计算每日收益率 (用于 Beta, Sharpe, Volatility) const strategyDailyReturns = strategy.slice(1).map((s, i) => (s.value - strategy[i].value) / 100); const benchmarkDailyReturns = benchmark.slice(1).map((b, i) => (b.value - benchmark[i].value) / 100); const riskFreeRate = 0.03; // 无风险利率 3% // 2. 计算贝塔 (Beta) let beta = 1.0; if (strategyDailyReturns.length > 2 && benchmarkDailyReturns.length > 2) { const meanS = strategyDailyReturns.reduce((a, b) => a + b, 0) / strategyDailyReturns.length; const meanB = benchmarkDailyReturns.reduce((a, b) => a + b, 0) / benchmarkDailyReturns.length; let covariance = 0; let varianceB = 0; for (let i = 0; i < Math.min(strategyDailyReturns.length, benchmarkDailyReturns.length); i++) { covariance += (strategyDailyReturns[i] - meanS) * (benchmarkDailyReturns[i] - meanB); varianceB += Math.pow(benchmarkDailyReturns[i] - meanB, 2); } if (varianceB > 0) beta = covariance / varianceB; } // 3. 计算阿尔法 (Alpha - 詹森指数) const alpha = stats.annualReturn - (riskFreeRate + beta * (benchmarkReturn / (visibleKlines.length / 250) - riskFreeRate)); // 4. 计算夏普比率 (Sharpe Ratio) let sharpeRatio = 0; if (strategyDailyReturns.length > 2) { const meanS = strategyDailyReturns.reduce((a, b) => a + b, 0) / strategyDailyReturns.length; const varianceS = strategyDailyReturns.reduce((sum, r) => sum + Math.pow(r - meanS, 2), 0) / (strategyDailyReturns.length - 1); const annualVol = Math.sqrt(varianceS * 250); // 年化波动率 if (annualVol > 0) { sharpeRatio = (stats.annualReturn - riskFreeRate) / annualVol; } } return { strategyData: strategy, benchmarkData: benchmark, detailedStats: { totalReturn: finalReturn, annualReturn: stats.annualReturn, benchmarkReturn, excessReturn: finalReturn - benchmarkReturn, maxDrawdown: stats.maxDrawdown, winRate: stats.winRate, profitLossRatio: stats.profitLossRatio, alpha: alpha, beta: beta.toFixed(2), sharpeRatio: sharpeRatio.toFixed(2), tradingDays: visibleKlines.length, tradeCount: history.length, } }; }, [allKlines, history, initialIndex, currentIndex, initialCapital, stats, hs300Data]); const formatPercent = (v?: number) => (v === undefined || v === null || isNaN(v)) ? '--' : `${(v * 100).toFixed(2)}%`; const formatNum = (v?: any) => { if (v === undefined || v === null || isNaN(v)) return '--'; if (v === 999) return 'MAX'; // 只有盈利时显示 MAX return Number(v).toFixed(2); }; useEffect(() => { if (!chartContainerRef.current || isLoading || benchmarkData.length === 0) return; const chart = createChart(chartContainerRef.current, { layout: { background: { type: ColorType.Solid, color: '#1E212B' }, textColor: '#9CA3AF', fontFamily: "'Roboto', sans-serif" }, grid: { vertLines: { color: '#2A2D3C' }, horzLines: { color: '#2A2D3C' } }, width: chartContainerRef.current.clientWidth, height: 240, crosshair: { mode: 1, vertLine: { color: '#6B7280', width: 1, style: 3 }, horzLine: { color: '#6B7280', width: 1, style: 3 } }, rightPriceScale: { borderColor: '#374151', alignLabels: true, autoScale: true, scaleMargins: { top: 0.1, bottom: 0.1 }, minimumWidth: 35, }, localization: { priceFormatter: (p: number) => `${p.toFixed(1)}%` }, timeScale: { borderColor: '#374151', timeVisible: true, fixLeftEdge: true, fixRightEdge: true, rightOffset: 0 }, handleScroll: { mouseWheel: true, pressedMouseMove: true }, handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true }, }); const sSeries = chart.addLineSeries({ color: '#A855F7', lineWidth: 2, priceLineVisible: false }); sSeries.setData(strategyData); const bSeries = chart.addLineSeries({ color: '#3B82F6', lineWidth: 2, priceLineVisible: false }); bSeries.setData(benchmarkData); const zSeries = chart.addLineSeries({ color: '#9CA3AF', lineWidth: 2, lineStyle: LineStyle.Dashed, lastValueVisible: false, priceLineVisible: false }); const times = Array.from(new Set([...strategyData, ...benchmarkData].map(d => d.time))); zSeries.setData(times.map(t => ({ time: t, value: 0 }))); chart.timeScale().fitContent(); chartRef.current = chart; const handleResize = () => { if (chartContainerRef.current && chartRef.current) chartRef.current.applyOptions({ width: chartContainerRef.current.clientWidth }); }; const ro = new ResizeObserver(handleResize); ro.observe(chartContainerRef.current); return () => { ro.disconnect(); chart.remove(); }; }, [strategyData, benchmarkData, isLoading]); return (

收益率曲线

{isLoading ?
加载中...
: benchmarkData.length === 0 ?
暂无沪深300数据
:
}
策略收益率
沪深300

详细指标

收益率
= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.totalReturn)}
年化收益
= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.annualReturn)}
基准收益
= 0 ? 'text-[#3B82F6]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.benchmarkReturn)}
超额收益
= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.excessReturn)}
最大回撤
-{formatPercent(detailedStats.maxDrawdown).replace('-', '')}
盈亏比
{formatNum(detailedStats.profitLossRatio)}
阿尔法
= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.alpha)}
贝塔
{formatNum(detailedStats.beta)}
夏普比率
= 1 ? 'text-[#FD1050]' : 'text-white'}`}>{formatNum(detailedStats.sharpeRatio)}
行情天数
{detailedStats.tradingDays}
交易次数
{detailedStats.tradeCount}
胜率
{formatPercent(detailedStats.winRate)}
); }