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
说明:每条记录代表一笔完整的交易(开仓+平仓),统计总盈亏时每条记录只计算一次
{/* 筛选面板 */}| 总交易数 | {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 |
| 交易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) : '-'} |