Paper_Trading / frontend /src /components /TradePanel.tsx
superxu520's picture
更新分享弹框:添加备用地址和二维码
a123f78
'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>
);
}