import React, { useState, useEffect } from 'react' import { useSelector } from 'react-redux' import { api } from '../services/api' import { selectAccountId } from '../store/appSlice' import './TradeList.css' const TradeList = () => { const accountId = useSelector(selectAccountId) // 从 Redux 获取当前账号ID const [trades, setTrades] = useState([]) const [stats, setStats] = useState(null) const [loading, setLoading] = useState(true) const [showStatsTable, setShowStatsTable] = useState(false) // 控制详细统计表格折叠 // 筛选状态 const [period, setPeriod] = useState('today') // '1d', '7d', '30d', 'today', 'week', 'month', null const [startDate, setStartDate] = useState('') const [endDate, setEndDate] = useState('') const [symbol, setSymbol] = useState('') const [status, setStatus] = useState('') const [useCustomDate, setUseCustomDate] = useState(false) const [tradeType, setTradeType] = useState('') const [exitReason, setExitReason] = useState('') useEffect(() => { loadData() }, [accountId]) // 当 accountId 变化时重新加载 const loadData = async () => { setLoading(true) try { const params = { limit: 100 } // 如果使用快速时间段筛选 if (!useCustomDate && period) { params.period = period } else if (useCustomDate) { // 使用自定义日期 if (startDate) params.start_date = startDate if (endDate) params.end_date = endDate } if (symbol) params.symbol = symbol if (status) params.status = status if (tradeType) params.trade_type = tradeType if (exitReason) params.exit_reason = exitReason const [tradesData, statsData] = await Promise.all([ api.getTrades(params), api.getTradeStats(params) ]) setTrades(tradesData.trades || []) setStats(statsData) } catch (error) { console.error('Failed to load trades:', error) } finally { setLoading(false) } } const handlePeriodChange = (newPeriod) => { setPeriod(newPeriod) setUseCustomDate(false) setStartDate('') setEndDate('') } const handleCustomDateToggle = () => { setUseCustomDate(!useCustomDate) if (!useCustomDate) { setPeriod(null) } } const handleReset = () => { setPeriod(null) setStartDate('') setEndDate('') setSymbol('') setStatus('') setUseCustomDate(false) } // 导出当前订单数据(含入场/离场原因、入场思路等完整字段,便于后续分析) // type: 'csv' | 'json' const handleExport = (type = 'csv') => { if (trades.length === 0) { alert('暂无数据可导出') return } const exportData = trades.map(trade => { const notional = trade.notional_usdt !== undefined && trade.notional_usdt !== null ? parseFloat(trade.notional_usdt) : (trade.entry_value_usdt !== undefined && trade.entry_value_usdt !== null ? parseFloat(trade.entry_value_usdt) : (parseFloat(trade.quantity || 0) * parseFloat(trade.entry_price || 0))) const leverage = parseFloat(trade.leverage || 10) const margin = trade.margin_usdt !== undefined && trade.margin_usdt !== null ? parseFloat(trade.margin_usdt) : (leverage > 0 ? notional / leverage : 0) const pnl = parseFloat(trade.pnl || 0) const pnlPercent = margin > 0 ? (pnl / margin) * 100 : 0 const row = { 交易ID: trade.id, 交易对: trade.symbol, 方向: trade.side, 数量: parseFloat(trade.quantity || 0), 名义价值: notional, 保证金: margin, 杠杆: leverage, 入场价: parseFloat(trade.entry_price || 0), 出场价: trade.exit_price ? parseFloat(trade.exit_price) : null, 盈亏: pnl, 盈亏比例: pnlPercent, 状态: trade.status === 'open' ? '持仓中' : trade.status === 'closed' ? '已平仓' : '已取消', 平仓类型: trade.exit_reason_display || '-', 开仓订单号: trade.entry_order_id || '-', 平仓订单号: trade.exit_order_id || '-', 入场时间: trade.entry_time, 平仓时间: trade.exit_time || null, // 以下为分析用完整字段 入场原因: trade.entry_reason ?? null, 离场原因: trade.exit_reason ?? null, 持仓时长分钟: trade.duration_minutes ?? null, 止损价: trade.stop_loss_price != null ? parseFloat(trade.stop_loss_price) : null, 止盈价: trade.take_profit_price != null ? parseFloat(trade.take_profit_price) : null, 第一目标止盈价: trade.take_profit_1 != null ? parseFloat(trade.take_profit_1) : null, 第二目标止盈价: trade.take_profit_2 != null ? parseFloat(trade.take_profit_2) : null, ATR: trade.atr != null ? parseFloat(trade.atr) : null, 策略类型: trade.strategy_type ?? null, } if (trade.entry_context != null) { row.入场思路 = trade.entry_context } return row }) const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-') if (type === 'json') { const filename = `交易记录_${timestamp}.json` const dataStr = JSON.stringify(exportData, null, 2) const dataBlob = new Blob([dataStr], { type: 'application/json' }) const url = URL.createObjectURL(dataBlob) const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) } else { // 转换为CSV格式 helper const convertToCSV = (objArray) => { const array = typeof objArray !== 'object' ? JSON.parse(objArray) : objArray; let str = ''; if (array.length === 0) return ''; // Header const headers = Object.keys(array[0]); str += headers.join(',') + '\r\n'; // Rows for (let i = 0; i < array.length; i++) { let line = ''; for (const index in array[i]) { if (line !== '') line += ','; let value = array[i][index]; if (value === null || value === undefined) { value = ''; } else if (typeof value === 'object') { value = JSON.stringify(value); } else { value = String(value); } // Escape quotes and wrap in quotes if necessary // Excel needs double quotes to be escaped as "" if (value.search(/("|,|\n|\r)/g) >= 0) { value = '"' + value.replace(/"/g, '""') + '"'; } line += value; } str += line + '\r\n'; } return str; } const filename = `交易记录_${timestamp}.csv` // 创建并下载文件 (CSV with BOM for Excel) const csvStr = convertToCSV(exportData) const bom = '\uFEFF' const dataBlob = new Blob([bom + csvStr], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(dataBlob) const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) } } // 兼容性复制函数 const fallbackCopyTextToClipboard = (text) => { const textArea = document.createElement("textarea") textArea.value = text // 避免滚动到底部 textArea.style.top = "0" textArea.style.left = "0" textArea.style.position = "fixed" textArea.style.opacity = "0" document.body.appendChild(textArea) textArea.focus() textArea.select() try { const successful = document.execCommand('copy') if (successful) { alert('统计数据已复制到剪贴板(可直接粘贴到Excel)') } else { alert('复制失败,请手动选择复制') } } catch (err) { console.error('Fallback copy failed:', err) alert('复制失败,您的浏览器不支持自动复制') } document.body.removeChild(textArea) } // 复制统计数据到剪贴板 const handleCopyStats = (statsData) => { if (!statsData) return const m = statsData.exit_reason_counts || {} const exitReasonText = [ m.stop_loss ? `止损 ${m.stop_loss}` : '', m.take_profit ? `止盈 ${m.take_profit}` : '', m.trailing_stop ? `移动止损 ${m.trailing_stop}` : '', m.manual ? `手动 ${m.manual}` : '', m.sync ? `同步 ${m.sync}` : '', m.unknown ? `其他 ${m.unknown}` : '' ].filter(Boolean).join(' / ') || '—' const lines = [ `总交易数\t${statsData.total_trades}`, `胜率\t${statsData.win_rate.toFixed(2)}%`, `总盈亏\t${statsData.total_pnl.toFixed(2)} USDT`, `平均盈亏\t${statsData.avg_pnl.toFixed(2)} USDT`, `平均持仓时长\t${statsData.avg_duration_minutes ? Number(statsData.avg_duration_minutes).toFixed(0) : 0} 分钟`, `平仓原因分布\t${exitReasonText}`, `盈亏比\t${Number(statsData.avg_win_loss_ratio || 0).toFixed(2)} : 1`, `总交易量\t${Number(statsData.total_notional_usdt || 0).toFixed(2)} USDT` ] const text = lines.join('\n') // 优先尝试 Clipboard API if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => { alert('统计数据已复制到剪贴板(可直接粘贴到Excel)') }).catch(err => { console.error('Clipboard API failed:', err) fallbackCopyTextToClipboard(text) }) } else { fallbackCopyTextToClipboard(text) } } if (loading) return
加载中...
return (

交易记录

说明:每条记录代表一笔完整的交易(开仓+平仓),统计总盈亏时每条记录只计算一次

{/* 筛选面板 */}
{useCustomDate && (
setStartDate(e.target.value)} placeholder="开始日期" /> setEndDate(e.target.value)} placeholder="结束日期" min={startDate} />
)}
setSymbol(e.target.value)} placeholder="如: BTCUSDT" style={{ width: '150px' }} />
{trades.length > 0 && ( <> )}
{ stats && (
{/* 卡片式统计(始终显示) */}
总交易数
{stats.total_trades}
{stats.meaningful_trades !== undefined && ( <>(有意义: {stats.meaningful_trades},0盈亏: {stats.zero_pnl_trades || 0}) )} {stats.meaningful_trades === undefined && <>(已平仓的完整交易)}
胜率
{stats.win_rate.toFixed(2)}%
{stats.meaningful_trades !== undefined && <>(已排除0盈亏订单)}
总盈亏
= 0 ? 'positive' : 'negative'}`}> {stats.total_pnl.toFixed(2)} USDT
平均盈亏
= 0 ? 'positive' : 'negative'}`}> {stats.avg_pnl.toFixed(2)} USDT
{"avg_duration_minutes" in stats && stats.avg_duration_minutes !== null && stats.avg_duration_minutes !== undefined && (
平均持仓时长(分钟)
{Number(stats.avg_duration_minutes || 0).toFixed(0)}
(仅统计“有意义交易”;优先使用 duration_minutes,缺失时用 exit_time-entry_time 计算)
)} {"exit_reason_counts" in stats && stats.exit_reason_counts && (
平仓原因(有意义交易)
{(() => { const m = stats.exit_reason_counts || {} const stopLoss = Number(m.stop_loss || 0) const takeProfit = Number(m.take_profit || 0) const trailing = Number(m.trailing_stop || 0) const manual = Number(m.manual || 0) const sync = Number(m.sync || 0) const other = Number(m.unknown || 0) const parts = [] if (stopLoss) parts.push(`止损 ${stopLoss}`) if (takeProfit) parts.push(`止盈 ${takeProfit}`) if (trailing) parts.push(`移动止损 ${trailing}`) if (manual) parts.push(`手动 ${manual}`) if (sync) parts.push(`同步 ${sync}`) if (other) parts.push(`其他 ${other}`) return parts.length ? parts.join(' / ') : '—' })()}
)} {"avg_win_pnl" in stats && "avg_loss_pnl_abs" in stats && Number(stats.total_pnl || 0) > 0 && (
平均盈利 / 平均亏损(期望 3:1)
= 3 ? 'positive' : '' }`} > {typeof stats.avg_win_loss_ratio === 'number' ? `${stats.avg_win_loss_ratio.toFixed(2)} : 1` : '—'}
+{Number(stats.avg_win_pnl || 0).toFixed(2)} / -{Number(stats.avg_loss_pnl_abs || 0).toFixed(2)} USDT
)} {"total_notional_usdt" in stats && (
总交易量(名义)
{Number(stats.total_notional_usdt || 0).toFixed(2)} USDT
(口径:入场价×数量)
)}
{/* 折叠控制按钮 */}
{/* 详细统计表格(可折叠) */} {showStatsTable && (

详细统计数据

总交易数 {stats.total_trades}
胜率 {stats.win_rate.toFixed(2)}%
总盈亏 {stats.total_pnl.toFixed(2)} USDT
平均盈亏 {stats.avg_pnl.toFixed(2)} USDT
平均持仓时长 {stats.avg_duration_minutes ? Number(stats.avg_duration_minutes).toFixed(0) : 0} 分钟
平仓原因分布 {(() => { const m = stats.exit_reason_counts || {} const stopLoss = Number(m.stop_loss || 0) const takeProfit = Number(m.take_profit || 0) const trailing = Number(m.trailing_stop || 0) const manual = Number(m.manual || 0) const sync = Number(m.sync || 0) const other = Number(m.unknown || 0) const parts = [] if (stopLoss) parts.push(`止损 ${stopLoss}`) if (takeProfit) parts.push(`止盈 ${takeProfit}`) if (trailing) parts.push(`移动止损 ${trailing}`) if (manual) parts.push(`手动 ${manual}`) if (sync) parts.push(`同步 ${sync}`) if (other) parts.push(`其他 ${other}`) return parts.length ? parts.join(' / ') : '—' })()}
盈亏比 (期望 3:1) {Number(stats.avg_win_loss_ratio || 0).toFixed(2)} : 1
总交易量 (名义) {Number(stats.total_notional_usdt || 0).toFixed(2)} USDT
)}
) } { trades.length === 0 ? (
暂无交易记录
) : ( <> {/* 桌面端表格:用横向滚动包裹,避免整页过宽 */}
{trades.map(trade => { // 名义/保证金:优先使用后端返回字段(notional_usdt / margin_usdt),否则回退计算 const notional = trade.notional_usdt !== undefined && trade.notional_usdt !== null ? parseFloat(trade.notional_usdt) : ( trade.entry_value_usdt !== undefined && trade.entry_value_usdt !== null ? parseFloat(trade.entry_value_usdt) : (parseFloat(trade.quantity || 0) * parseFloat(trade.entry_price || 0)) ) const leverage = parseFloat(trade.leverage || 10) const margin = trade.margin_usdt !== undefined && trade.margin_usdt !== null ? parseFloat(trade.margin_usdt) : (leverage > 0 ? notional / leverage : 0) // 计算盈亏(优先使用币安实际结算盈亏 realized_pnl - commission) let pnl = parseFloat(trade.pnl || 0) const realizedPnl = trade.realized_pnl !== undefined && trade.realized_pnl !== null ? parseFloat(trade.realized_pnl) : null const commission = trade.commission !== undefined && trade.commission !== null ? parseFloat(trade.commission) : 0 const commissionAsset = trade.commission_asset || 'USDT' if (realizedPnl !== null) { pnl = realizedPnl // 如果手续费是 USDT,则从盈亏中扣除(得到净值) if (commissionAsset === 'USDT') { pnl -= commission } } const pnlPercent = margin > 0 ? (pnl / margin) * 100 : 0 // 格式化时间为北京时间 // 支持Unix时间戳(秒数)或日期字符串 const formatTime = (timeValue) => { if (!timeValue) return '-' try { let date // 如果是数字(Unix时间戳),转换为毫秒 if (typeof timeValue === 'number') { date = new Date(timeValue * 1000) } else { date = new Date(timeValue) } if (isNaN(date.getTime())) return String(timeValue) return date.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Shanghai' }) } catch (e) { return String(timeValue) } } // 格式化订单号显示 const formatOrderIds = () => { const entry = trade.entry_order_id || '-' const exit = trade.exit_order_id || '-' if (entry === '-' && exit === '-') return '-' if (entry !== '-' && exit !== '-') { return `开仓: ${entry} / 平仓: ${exit}` } return entry !== '-' ? `开仓: ${entry}` : `平仓: ${exit}` } return ( ) })}
交易ID 交易对 方向 数量 名义 保证金 入场价 出场价 盈亏 盈亏比例 状态 平仓类型 币安订单号 入场时间 平仓时间
#{trade.id} {trade.symbol} {trade.side === 'BUY' ? '做多' : '做空'} {parseFloat(trade.quantity).toFixed(4)} {notional >= 0.01 ? notional.toFixed(2) : notional.toFixed(4)} USDT {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT {parseFloat(trade.entry_price).toFixed(4)} {trade.exit_price ? parseFloat(trade.exit_price).toFixed(4) : '-'} = 0 ? 'positive' : 'negative'}> {pnl.toFixed(2)} USDT = 0 ? 'positive' : 'negative'}> {pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}% {trade.status === 'open' ? '持仓中' : trade.status === 'closed' ? '已平仓' : '已取消'} {trade.exit_reason_display || '-'} {formatOrderIds()} {formatTime(trade.entry_time)} {trade.exit_time ? formatTime(trade.exit_time) : '-'}
{/* 移动端卡片 */}
{trades.map(trade => { // 名义/保证金:优先后端字段 const notional = trade.notional_usdt !== undefined && trade.notional_usdt !== null ? parseFloat(trade.notional_usdt) : ( trade.entry_value_usdt !== undefined && trade.entry_value_usdt !== null ? parseFloat(trade.entry_value_usdt) : (parseFloat(trade.quantity || 0) * parseFloat(trade.entry_price || 0)) ) const leverage = parseFloat(trade.leverage || 10) const margin = trade.margin_usdt !== undefined && trade.margin_usdt !== null ? parseFloat(trade.margin_usdt) : (leverage > 0 ? notional / leverage : 0) // 计算盈亏(优先使用币安实际结算盈亏 realized_pnl - commission) let pnl = parseFloat(trade.pnl || 0) const realizedPnl = trade.realized_pnl !== undefined && trade.realized_pnl !== null ? parseFloat(trade.realized_pnl) : null const commission = trade.commission !== undefined && trade.commission !== null ? parseFloat(trade.commission) : 0 const commissionAsset = trade.commission_asset || 'USDT' if (realizedPnl !== null) { pnl = realizedPnl if (commissionAsset === 'USDT') { pnl -= commission } } const pnlPercent = margin > 0 ? (pnl / margin) * 100 : 0 // 格式化时间为北京时间 // 支持Unix时间戳(秒数)或日期字符串 const formatTime = (timeValue) => { if (!timeValue) return '-' try { let date // 如果是数字(Unix时间戳),转换为毫秒 if (typeof timeValue === 'number') { date = new Date(timeValue * 1000) } else { date = new Date(timeValue) } if (isNaN(date.getTime())) return String(timeValue) return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Shanghai' }) } catch (e) { return String(timeValue) } } return (
{trade.symbol} #{trade.id}
{trade.side === 'BUY' ? '买入' : '卖出'} {trade.status === 'open' ? '持仓中' : trade.status === 'closed' ? '已平仓' : '已取消'}
数量 {parseFloat(trade.quantity).toFixed(4)}
名义 {notional >= 0.01 ? notional.toFixed(2) : notional.toFixed(4)} USDT
保证金 {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT
入场价 {parseFloat(trade.entry_price).toFixed(4)}
出场价 {trade.exit_price ? parseFloat(trade.exit_price).toFixed(4) : '-'}
盈亏 = 0 ? 'positive' : 'negative'}`}> {pnl.toFixed(2)} USDT
盈亏比例 = 0 ? 'positive' : 'negative'}`}> {pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%
{trade.exit_reason_display && (
平仓类型 {trade.exit_reason_display}
)} {(trade.entry_order_id || trade.exit_order_id) && (
币安订单号 {trade.entry_order_id ? `开仓: ${trade.entry_order_id}` : ''} {trade.entry_order_id && trade.exit_order_id ? ' / ' : ''} {trade.exit_order_id ? `平仓: ${trade.exit_order_id}` : ''}
)}
入场: {formatTime(trade.entry_time)}
{trade.exit_time && (
平仓: {formatTime(trade.exit_time)}
)}
) })}
) }
) } export default TradeList