'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 (
{/* 用户菜单 - 右上角 */}
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 会员
真实历史行情
·
无限次回测
·
从此走上盈利之路
) : (
)}
标的范围
{/* 自定义下拉箭头 */}
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)}%
交易统计
= 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数据
:
}
详细指标
收益率
= 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)}
);
}