Spaces:
Running
Running
| '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 ( | |
| <div className="flex items-center justify-center h-full bg-[#191B28]"> | |
| <div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" /> | |
| </div> | |
| ); | |
| } | |
| // ===== 未登录:显示全屏登录/注册页 ===== | |
| if (!username) { | |
| return <LoginPage />; | |
| } | |
| // ===== 已登录:显示游戏开始界面 ===== | |
| const limitExceeded = !isVip && dailyLimit !== null && dailyUsed >= dailyLimit; | |
| return ( | |
| <div className="flex flex-col items-center justify-center h-full gap-6 lg:gap-8 p-4 lg:p-8 bg-[#191B28] text-white relative"> | |
| {/* 用户菜单 - 右上角 */} | |
| <div className="absolute top-4 right-4"> | |
| <UserMenu /> | |
| </div> | |
| <div className="text-center space-y-2"> | |
| <div className="flex items-center justify-center gap-3"> | |
| <div className="w-10 h-10 bg-blue-500/20 rounded-xl flex items-center justify-center text-blue-400"> | |
| <TrendingUp size={24} /> | |
| </div> | |
| <h1 className="text-4xl lg:text-5xl font-bold tracking-tight text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">StockReplay</h1> | |
| </div> | |
| <p className="text-gray-400 text-base lg:text-lg">A股历史行情复盘训练系统</p> | |
| {isVip && vipExpireAt && ( | |
| <div className="flex items-center justify-center gap-1 text-yellow-500 text-xs"> | |
| <span>⭐ VIP 会员</span> | |
| <span className="text-gray-500">· 到期: {vipExpireAt.split(' ')[0]}</span> | |
| </div> | |
| )} | |
| </div> | |
| <div className="bg-[#2A2D3C] rounded-2xl p-5 lg:p-8 max-w-md w-full shadow-2xl border border-gray-800"> | |
| <h2 className="text-xl lg:text-2xl font-bold mb-2 lg:mb-4 text-center">盲盒挑战</h2> | |
| <p className="text-gray-400 mb-4 lg:mb-6 text-center text-sm lg:text-base leading-relaxed"> | |
| 系统将随机抽取一只A股的历史行情,你需要根据K线走势进行模拟交易,验证你的盘感与策略。 | |
| </p> | |
| {/* 每日次数提示 */} | |
| {!isVip && dailyLimit !== null && ( | |
| <div className={`mb-4 rounded-xl px-4 py-3 text-sm flex items-center justify-between ${limitExceeded | |
| ? 'bg-red-500/10 border border-red-500/30 text-red-400' | |
| : dailyUsed >= 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' | |
| }`}> | |
| <span> | |
| {limitExceeded | |
| ? '今日免费次数已用完' | |
| : `今日剩余次数:${dailyLimit - dailyUsed} / ${dailyLimit}`} | |
| </span> | |
| {limitExceeded && ( | |
| <span className="text-xs text-gray-500">明日重置</span> | |
| )} | |
| </div> | |
| )} | |
| {/* 次数用完时的 VIP 引导 */} | |
| {limitExceeded ? ( | |
| <div className="space-y-3"> | |
| <div className="bg-gradient-to-r from-yellow-500/10 to-orange-500/10 border border-yellow-500/30 rounded-xl p-4 text-center"> | |
| <div className="text-yellow-400 font-bold mb-1">⭐ 开通 VIP 会员</div> | |
| <div className="text-gray-400 text-xs flex items-center justify-center gap-1.5 flex-wrap"> | |
| <span>真实历史行情</span> | |
| <span className="text-gray-600 leading-none">·</span> | |
| <span>无限次回测</span> | |
| <span className="text-gray-600 leading-none">·</span> | |
| <span>从此走上盈利之路</span> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => setShowPayment(true)} | |
| className="w-full bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white font-bold py-3.5 lg:py-4 px-8 rounded-xl shadow-lg transition-all transform hover:scale-[1.02] active:scale-[0.98]" | |
| > | |
| 立即开通 VIP (¥10/月) | |
| </button> | |
| </div> | |
| ) : ( | |
| <button | |
| onClick={handleStartGame} | |
| disabled={isLoading} | |
| className="w-full bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white font-bold py-3.5 lg:py-4 px-8 rounded-xl transition-all duration-200 disabled:opacity-50 shadow-lg transform hover:scale-[1.02] active:scale-[0.98]" | |
| > | |
| {isLoading ? '加载数据中...' : '开始挑战'} | |
| </button> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-3 gap-3 lg:gap-6 w-full max-w-md text-center"> | |
| <div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 flex flex-col justify-center"> | |
| <div className="text-gray-500 text-[10px] lg:text-xs mb-1">初始资金</div> | |
| <div className="text-sm lg:text-xl font-bold text-white whitespace-nowrap">100万</div> | |
| </div> | |
| <div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 relative group cursor-pointer"> | |
| <div className="text-gray-500 text-[10px] lg:text-xs mb-1">标的范围</div> | |
| <select | |
| value={selectedMarket} | |
| onChange={(e) => setSelectedMarket(e.target.value)} | |
| className="bg-transparent text-white font-bold text-sm lg:text-xl outline-none cursor-pointer w-full text-center appearance-none" | |
| > | |
| {markets.map(m => <option key={m} value={m} className="bg-[#2A2D3C]">{m}</option>)} | |
| </select> | |
| {/* 自定义下拉箭头 */} | |
| <div className="absolute right-2 bottom-2 lg:right-3 lg:bottom-3 pointer-events-none text-gray-500"> | |
| <ChevronRight size={12} className="rotate-90" /> | |
| </div> | |
| </div> | |
| <div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 flex flex-col justify-center"> | |
| <div className="text-gray-500 text-[10px] lg:text-xs mb-1">数据周期</div> | |
| <div className="text-sm lg:text-xl font-bold text-white whitespace-nowrap">10年</div> | |
| </div> | |
| </div> | |
| <PaymentModal isOpen={showPayment} onClose={() => setShowPayment(false)} /> | |
| </div> | |
| ); | |
| } | |
| // 回测结束界面 | |
| if (isFinished && stats) { | |
| return ( | |
| <> | |
| <div className="fixed inset-0 z-50 flex flex-col bg-[#1E212B] text-white overflow-hidden"> | |
| <div className="p-4 lg:p-5 bg-gradient-to-b from-[#2A2D3C] to-[#1E212B] border-b border-[#2A2D3C] flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-10 h-10 lg:w-12 lg:h-12 bg-yellow-500/20 rounded-full flex items-center justify-center"><Trophy className="text-yellow-500" size={20} /></div> | |
| <div> | |
| <h2 className="text-lg lg:text-xl font-bold">回测结束</h2> | |
| <p className="text-gray-400 text-xs lg:text-sm">{useGameStore.getState().realName} ({useGameStore.getState().realCode})</p> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => { setAutoPlay(false); reset(); }} | |
| disabled={isLoading} | |
| className="bg-gradient-to-r from-[#FD1050] to-[#FF4081] hover:from-[#FF4081] hover:to-[#FD1050] text-white font-bold py-2 px-4 lg:px-6 rounded-lg transition-all shadow-lg text-sm lg:text-base flex items-center gap-2" | |
| > | |
| <RotateCcw size={16} /> | |
| <span>再来一局</span> | |
| </button> | |
| </div> | |
| <div className="p-4 lg:p-6 flex-1 overflow-y-auto"> | |
| <div className="max-w-4xl mx-auto"> | |
| <div className="bg-[#2A2D3C] rounded-2xl p-6 mb-6 shadow-xl border border-gray-800/50"> | |
| <div className="text-gray-400 text-sm mb-2">最终总资产</div> | |
| <div className={`text-4xl lg:text-5xl font-bold font-mono ${isProfit ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{totalAsset.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</div> | |
| <div className="flex items-center gap-3 mt-3"> | |
| <span className={`text-xl font-mono ${isProfit ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{isProfit ? '+' : ''}{profit.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> | |
| <span className={`text-lg ${isProfit ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>({(stats.totalReturn * 100).toFixed(2)}%)</span> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> | |
| <div className="bg-[#2A2D3C] rounded-xl p-4 border border-gray-800/30"><div className="text-gray-500 text-xs mb-1">年化收益</div><div className={`text-xl font-bold font-mono ${stats.annualReturn >= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{(stats.annualReturn * 100).toFixed(2)}%</div></div> | |
| <div className="bg-[#2A2D3C] rounded-xl p-4 border border-gray-800/30"><div className="text-gray-500 text-xs mb-1">最大回撤</div><div className="text-xl font-bold font-mono text-[#00F0F0]">-{(stats.maxDrawdown * 100).toFixed(2)}%</div></div> | |
| <div className="bg-[#2A2D3C] rounded-xl p-4 border border-gray-800/30"><div className="text-gray-500 text-xs mb-1">行情天数</div><div className="text-xl font-bold font-mono text-white">{tradingDays} 天</div></div> | |
| <div className="bg-[#2A2D3C] rounded-xl p-4 border border-gray-800/30"><div className="text-gray-500 text-xs mb-1">交易次数</div><div className="text-xl font-bold font-mono text-white">{stats.tradeCount} 笔</div></div> | |
| </div> | |
| <div className="bg-[#2A2D3C] rounded-xl p-5 mb-6 border border-gray-800/30"> | |
| <div className="flex items-center gap-2 mb-4"><BarChart3 size={18} className="text-gray-400" /><span className="text-gray-400 font-medium">交易统计</span></div> | |
| <div className="grid grid-cols-3 gap-6 text-center"> | |
| <div><div className="text-[#FD1050] text-xl font-bold">{stats.buyCount}</div><div className="text-gray-500 text-xs mt-1">买入</div></div> | |
| <div><div className="text-[#00F0F0] text-xl font-bold">{stats.sellCount}</div><div className="text-gray-500 text-xs mt-1">卖出</div></div> | |
| <div><div className={`text-xl font-bold ${stats.winRate >= 0.5 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{(stats.winRate * 100).toFixed(1)}%</div><div className="text-gray-500 text-xs mt-1">胜率</div></div> | |
| </div> | |
| </div> | |
| <div className="flex flex-col lg:flex-row gap-4"> | |
| <button onClick={() => setShowReturnChart(true)} className="flex-1 bg-[#2A2D3C] hover:bg-[#35394B] border border-gray-700 text-white font-medium py-4 rounded-xl transition-colors flex items-center justify-center gap-2 shadow-lg"><TrendingUpIcon size={20} />查看收益率曲线</button> | |
| <div className="flex-1 bg-[#2A2D3C] rounded-xl p-5 border border-gray-800/30"> | |
| <div className="flex items-center gap-2 mb-4"><Package size={18} className="text-gray-400" /><span className="text-gray-400 font-medium">交易明细</span></div> | |
| <div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-gray-700"> | |
| {history.length === 0 ? <div className="text-center py-8 text-gray-600">暂无交易记录</div> : history.slice().reverse().map((trade, idx) => ( | |
| <div key={idx} className="flex justify-between items-center border-b border-gray-700/30 pb-3"> | |
| <div className="flex flex-col"><span className={`font-bold ${trade.type === 'buy' ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{trade.type === 'buy' ? '买入' : '卖出'} {trade.volume}股</span><span className="text-gray-500 text-xs mt-1">{trade.date}</span></div> | |
| <div className="text-right"><div className="text-white font-mono font-bold">{trade.price.toFixed(2)}</div><div className="text-gray-500 text-xs mt-1">成交: {(trade.totalCost / 10000).toFixed(1)}万</div></div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {showReturnChart && <ReturnChartModal onClose={() => setShowReturnChart(false)} allKlines={allKlines} history={history} initialIndex={initialIndex} currentIndex={currentIndex} initialCapital={initialCapital} stats={stats} />} | |
| </> | |
| ); | |
| } | |
| // 正常交易界面 | |
| return ( | |
| <div className="h-full flex flex-col bg-[#1E212B] text-white w-full overflow-hidden"> | |
| {/* 资产概览 - 手机端极致压缩 */} | |
| <div className="p-2 lg:p-5 bg-gradient-to-b from-[#2A2D3C] to-[#1E212B] border-b border-[#2A2D3C] w-full"> | |
| <div className="flex justify-between items-center lg:items-end mb-1 lg:mb-2"> | |
| <div className="flex items-baseline gap-1.5"> | |
| <span className="text-gray-400 text-[10px] lg:text-sm">总资产</span> | |
| <span className={`text-base lg:text-2xl font-bold font-mono ${isProfit ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}> | |
| {totalAsset.toLocaleString('zh-CN', { maximumFractionDigits: 0 })} | |
| </span> | |
| </div> | |
| <div className={`text-[10px] lg:text-sm font-mono ${isProfit ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}> | |
| {isProfit ? '+' : ''}{(returnRate * 100).toFixed(2)}% | |
| </div> | |
| </div> | |
| <div className="flex justify-between items-center text-[10px] lg:text-sm text-gray-500"> | |
| <div className="flex gap-4"> | |
| <div className="flex items-baseline gap-1"> | |
| <span>可用</span> | |
| <span className="text-gray-300 font-mono">{cash.toLocaleString('zh-CN', { maximumFractionDigits: 0 })}</span> | |
| </div> | |
| <div className="flex items-baseline gap-1"> | |
| <span>持仓</span> | |
| <span className="text-gray-300 font-mono">{(holdings * currentPrice).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}</span> | |
| </div> | |
| </div> | |
| {holdings > 0 && ( | |
| <div className="flex items-baseline gap-1"> | |
| <span>盈亏</span> | |
| <span className={`font-mono ${(currentPrice - avgPrice) >= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}> | |
| {((currentPrice - avgPrice) * holdings).toFixed(0)} | |
| </span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="lg:flex-1 p-2 lg:p-5 flex flex-col gap-2 lg:gap-6 overflow-y-auto overflow-x-hidden w-full box-border scrollbar-thin scrollbar-thumb-gray-700"> | |
| <div className="grid grid-cols-2 gap-2 lg:gap-4"> | |
| {/* 价格 - 手机端标签与输入框同行 */} | |
| <div className="flex items-center gap-1 lg:flex-col lg:items-start lg:gap-2 min-w-0"> | |
| <label className="text-[10px] lg:text-sm text-gray-500 shrink-0">价格</label> | |
| <div className="flex-1 bg-[#12141C] rounded-lg px-1.5 py-1 lg:px-4 lg:py-4 flex justify-between items-center border border-gray-700/50"> | |
| <span className="text-gray-400 text-[10px] hidden xs:inline">市价</span> | |
| <span className="font-mono text-xs lg:text-xl ml-auto">{currentPrice.toFixed(2)}</span> | |
| </div> | |
| </div> | |
| {/* 数量 - 手机端标签与输入框同行 */} | |
| <div className="flex items-center gap-1 lg:flex-col lg:items-start lg:gap-2 min-w-0"> | |
| <label className="text-[10px] lg:text-sm text-gray-500 shrink-0">数量</label> | |
| <input | |
| type="number" | |
| value={volume} | |
| onChange={(e) => 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} | |
| /> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 gap-2 lg:gap-8 lg:block lg:space-y-4"> | |
| <div className="grid grid-cols-4 gap-1"> | |
| {[1 / 4, 1 / 2, 3 / 4, 1].map((ratio) => ( | |
| <button key={`buy-${ratio}`} onClick={() => setBuyPosition(ratio)} className="bg-[#2A2D3C] text-gray-400 text-[10px] py-1 rounded"> | |
| {ratio === 1 ? '全' : `${ratio * 4}/4`} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="grid grid-cols-4 gap-1"> | |
| {[1 / 4, 1 / 2, 3 / 4, 1].map((ratio) => ( | |
| <button key={`sell-${ratio}`} onClick={() => setSellPosition(ratio)} className="bg-[#2A2D3C] text-gray-400 text-[10px] py-1 rounded"> | |
| {ratio === 1 ? '全' : `${ratio * 4}/4`} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| <div className="flex gap-2 lg:gap-4"> | |
| <button onClick={handleBuy} className="flex-1 py-2.5 lg:py-5 rounded-xl lg:rounded-2xl font-bold text-sm lg:text-2xl bg-gradient-to-r from-[#FD1050] to-[#FF4081] text-white">买入</button> | |
| <button onClick={handleSell} className="flex-1 py-2.5 lg:py-5 rounded-xl lg:rounded-2xl font-bold text-sm lg:text-2xl bg-gradient-to-r from-[#00F0F0] to-[#00E5FF] text-[#12141C]">卖出</button> | |
| </div> | |
| </div> | |
| <div className="p-1.5 lg:p-4 border-t border-[#2A2D3C] bg-[#191B28]"> | |
| <div className="flex flex-row gap-1 lg:flex-col lg:gap-3"> | |
| <div className="flex-1 flex gap-1 lg:gap-3"> | |
| {canFinish ? ( | |
| <button onClick={finish} className="flex-1 flex items-center justify-center gap-1 bg-gradient-to-r from-purple-600 to-purple-500 text-white py-1.5 lg:py-3 rounded-lg text-[10px] lg:text-sm font-medium"><BarChart3 size={12} /><span>结束</span></button> | |
| ) : ( | |
| <button onClick={nextCandle} disabled={currentIndex >= allKlines.length - 1} className="flex-1 flex items-center justify-center bg-[#2A2D3C] text-white py-1.5 lg:py-3 rounded-lg text-[10px] lg:text-sm font-medium">下一天</button> | |
| )} | |
| <button onClick={() => setAutoPlay(!autoPlay)} className={`flex-1 flex items-center justify-center gap-1 py-1.5 lg:py-3 rounded-lg text-[10px] lg:text-sm font-medium ${autoPlay ? 'bg-blue-500/20 text-blue-400' : 'bg-[#2A2D3C] text-white'}`}>{autoPlay ? <Pause size={12} /> : <Play size={12} />}<span>{autoPlay ? '暂停' : '自动'}</span></button> | |
| </div> | |
| <div className="flex-1 flex gap-1 lg:gap-3"> | |
| <button onClick={() => { reveal(); setAutoPlay(false); }} disabled={isRevealed} className="flex-1 flex items-center justify-center gap-1 bg-[#2A2D3C] text-yellow-500 py-1.5 lg:py-3 rounded-lg text-[10px] lg:text-sm font-medium"><Eye size={12} /><span>揭晓</span></button> | |
| <button onClick={() => { setAutoPlay(false); reset(); }} className="flex-1 flex items-center justify-center gap-1 bg-[#2A2D3C] text-gray-400 py-1.5 lg:py-3 rounded-lg text-[10px] lg:text-sm font-medium"><RotateCcw size={12} /><span>重开</span></button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 收益率曲线弹窗组件 | |
| 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<HTMLDivElement>(null); | |
| const modalRef = useRef<HTMLDivElement>(null); | |
| const chartRef = useRef<any>(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 ( | |
| <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> | |
| <div ref={modalRef} className="bg-[#1E212B] rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl border border-[#2A2D3C]"> | |
| <div className="px-4 py-3 border-b border-[#2A2D3C] flex items-center justify-between"> | |
| <h3 className="text-lg font-bold text-white flex items-center gap-2"><TrendingUpIcon size={18} className="text-purple-500" />收益率曲线</h3> | |
| <div className="flex items-center gap-2"> | |
| <button onClick={handleShare} disabled={isSharing} className="w-8 h-8 flex items-center justify-center rounded-lg bg-[#2A2D3C] hover:bg-[#35394B] text-blue-400 hover:text-white transition-colors" title="分享战绩"><Share2 size={18} className={isSharing ? 'animate-pulse' : ''} /></button> | |
| <button onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-lg bg-[#2A2D3C] hover:bg-[#35394B] text-gray-400 hover:text-white transition-colors"><X size={18} /></button> | |
| </div> | |
| </div> | |
| <div className="pt-3 pb-2 px-1 lg:px-2"> | |
| {isLoading ? <div className="w-full h-[240px] bg-[#191B28] rounded-xl flex items-center justify-center text-gray-400">加载中...</div> : benchmarkData.length === 0 ? <div className="w-full h-[240px] bg-[#191B28] rounded-xl flex items-center justify-center text-gray-400">暂无沪深300数据</div> : <div ref={chartContainerRef} className="w-full h-[240px] bg-[#191B28] rounded-xl overflow-hidden" />} | |
| <div className="flex items-center justify-center gap-6 mt-2"> | |
| <div className="flex items-center gap-2"><div className="w-2.5 h-2.5 rounded-full bg-[#A855F7]" /><span className="text-xs text-gray-400">策略收益率</span></div> | |
| <div className="flex items-center gap-2"><div className="w-2.5 h-2.5 rounded-full bg-[#3B82F6]" /><span className="text-xs text-gray-400">沪深300</span></div> | |
| </div> | |
| </div> | |
| <div className="px-3 lg:px-5 pb-5"> | |
| <div className="bg-[#2A2D3C] rounded-xl p-3 lg:p-4"> | |
| <h4 className="text-gray-400 text-xs lg:text-sm mb-3">详细指标</h4> | |
| <div className="grid grid-cols-3 lg:grid-cols-5 gap-y-4 gap-x-2 mb-4"> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">收益率</div><div className={`text-sm lg:text-base font-bold font-mono ${detailedStats.totalReturn >= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.totalReturn)}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">年化收益</div><div className={`text-sm lg:text-base font-bold font-mono ${detailedStats.annualReturn >= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.annualReturn)}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">基准收益</div><div className={`text-sm lg:text-base font-bold font-mono ${detailedStats.benchmarkReturn >= 0 ? 'text-[#3B82F6]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.benchmarkReturn)}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">超额收益</div><div className={`text-sm lg:text-base font-bold font-mono ${detailedStats.excessReturn >= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.excessReturn)}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">最大回撤</div><div className="text-sm lg:text-base font-bold font-mono text-[#00F0F0]">-{formatPercent(detailedStats.maxDrawdown).replace('-', '')}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">盈亏比</div><div className="text-sm lg:text-base font-bold font-mono text-[#FD1050]">{formatNum(detailedStats.profitLossRatio)}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">阿尔法</div><div className={`text-sm lg:text-base font-bold font-mono ${detailedStats.alpha >= 0 ? 'text-[#FD1050]' : 'text-[#00F0F0]'}`}>{formatPercent(detailedStats.alpha)}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">贝塔</div><div className="text-sm lg:text-base font-bold font-mono text-white">{formatNum(detailedStats.beta)}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">夏普比率</div><div className={`text-sm lg:text-base font-bold font-mono ${parseFloat(detailedStats.sharpeRatio) >= 1 ? 'text-[#FD1050]' : 'text-white'}`}>{formatNum(detailedStats.sharpeRatio)}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">行情天数</div><div className="text-sm lg:text-base font-bold font-mono text-white">{detailedStats.tradingDays}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">交易次数</div><div className="text-sm lg:text-base font-bold font-mono text-white">{detailedStats.tradeCount}</div></div> | |
| <div className="text-center"><div className="text-[10px] lg:text-xs text-gray-500 mb-1">胜率</div><div className="text-sm lg:text-base font-bold font-mono text-white">{formatPercent(detailedStats.winRate)}</div></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="share-footer" className="hidden border-t border-gray-700/50 p-4 bg-[#191B28]"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex flex-col gap-1"> | |
| <div className="text-white font-bold text-sm">StockReplay 复盘挑战</div> | |
| <div className="text-blue-400 text-xs font-mono">https://jiaoyi.netlify.app</div> | |
| <div className="text-blue-400 text-xs font-mono">https://jiaoyi.s-ai.sbs</div> | |
| <div className="text-gray-500 text-[10px] mt-1">扫描二维码,开启你的复盘之旅</div> | |
| </div> | |
| <div className="bg-white p-1.5 rounded-lg"> | |
| <QRCodeSVG value="https://jiaoyi.s-ai.sbs" size={60} level="H" /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |