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 [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)
}
// 导出当前订单数据(含入场/离场原因、入场思路等完整字段,便于后续分析)
const handleExport = () => {
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, '-')
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)
}
if (loading) return
加载中...
return (
交易记录
说明:每条记录代表一笔完整的交易(开仓+平仓),统计总盈亏时每条记录只计算一次
{/* 筛选面板 */}
setSymbol(e.target.value)}
placeholder="如: BTCUSDT"
style={{ width: '150px' }}
/>
{trades.length > 0 && (
)}
{
stats && (
整体统计
总交易数:{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
)
}
{
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
(口径:入场价×数量)
)}
)
}
{
trades.length === 0 ? (
暂无交易记录
) : (
<>
{/* 桌面端表格:用横向滚动包裹,避免整页过宽 */}
| 交易ID |
交易对 |
方向 |
数量 |
名义 |
保证金 |
入场价 |
出场价 |
盈亏 |
盈亏比例 |
状态 |
平仓类型 |
币安订单号 |
入场时间 |
平仓时间 |
{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)
// 计算盈亏比例(盈亏/保证金)
const pnl = parseFloat(trade.pnl || 0)
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 (
| #{trade.id} |
{trade.symbol} |
{trade.side} |
{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)
const pnl = parseFloat(trade.pnl || 0)
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}
交易ID: #{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