auto_trade_sys/frontend/src/components/TradeList.jsx
薇薇安 a88e114b4c 1
2026-02-14 17:20:34 +08:00

889 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <div className="loading">加载中...</div>
return (
<div className="trade-list">
<h2>交易记录</h2>
<p style={{ color: '#666', fontSize: '14px', marginTop: '-10px', marginBottom: '20px' }}>
说明每条记录代表一笔完整的交易开仓+平仓统计总盈亏时每条记录只计算一次
</p>
{/* 筛选面板 */}
<div className="filter-panel">
<div className="filter-section">
<label>快速筛选</label>
<div className="period-buttons">
<button
className={period === 'today' ? 'active' : ''}
onClick={() => handlePeriodChange('today')}
>
今天
</button>
<button
className={period === 'week' ? 'active' : ''}
onClick={() => handlePeriodChange('week')}
>
本周
</button>
<button
className={period === 'month' ? 'active' : ''}
onClick={() => handlePeriodChange('month')}
>
本月
</button>
<button
className={period === '1d' ? 'active' : ''}
onClick={() => handlePeriodChange('1d')}
>
最近1天
</button>
<button
className={period === '7d' ? 'active' : ''}
onClick={() => handlePeriodChange('7d')}
>
最近7天
</button>
<button
className={period === '30d' ? 'active' : ''}
onClick={() => handlePeriodChange('30d')}
>
最近30天
</button>
<button
className={period === null && !useCustomDate ? 'active' : ''}
onClick={() => handlePeriodChange(null)}
>
全部
</button>
</div>
</div>
<div className="filter-section">
<label>
<input
type="checkbox"
checked={useCustomDate}
onChange={handleCustomDateToggle}
/>
自定义时间段
</label>
{useCustomDate && (
<div className="date-inputs">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
placeholder="开始日期"
/>
<span></span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
placeholder="结束日期"
min={startDate}
/>
</div>
)}
</div>
<div className="filter-section">
<label>交易对</label>
<input
type="text"
value={symbol}
onChange={(e) => setSymbol(e.target.value)}
placeholder="如: BTCUSDT"
style={{ width: '150px' }}
/>
</div>
<div className="filter-section">
<label>交易类型</label>
<select
value={tradeType}
onChange={(e) => setTradeType(e.target.value)}
style={{ width: '120px' }}
>
<option value="">全部</option>
<option value="buy">买入</option>
<option value="sell">卖出</option>
</select>
</div>
<div className="filter-section">
<label>平仓原因</label>
<select
value={exitReason}
onChange={(e) => setExitReason(e.target.value)}
style={{ width: '120px' }}
>
<option value="">全部</option>
<option value="stop_loss">止损</option>
<option value="take_profit">止盈</option>
<option value="trailing_stop">移动止损</option>
<option value="manual">手动平仓</option>
<option value="sync">同步平仓</option>
</select>
</div>
<div className="filter-section">
<label>状态</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
style={{ width: '120px' }}
>
<option value="">全部</option>
<option value="open">持仓中</option>
<option value="closed">已平仓</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div className="filter-actions">
<button className="btn-primary" onClick={loadData}>
查询
</button>
<button className="btn-secondary" onClick={handleReset}>
重置
</button>
{trades.length > 0 && (
<>
<button className="btn-export" onClick={() => handleExport('csv')} title="导出Excel/CSV含入场/离场原因、入场思路等),便于后续分析">
导出 Excel ({trades.length})
</button>
<button className="btn-export" onClick={() => handleExport('json')} style={{ backgroundColor: '#607D8B' }} title="导出JSON数据方便程序处理">
导出 JSON ({trades.length})
</button>
</>
)}
</div>
</div>
{
stats && (
<div className="stats-section">
{/* 卡片式统计(始终显示) */}
<div className="stats-summary">
<div className="stat-card">
<div className="stat-label">总交易数</div>
<div className="stat-value">{stats.total_trades}</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>
{stats.meaningful_trades !== undefined && (
<>有意义: {stats.meaningful_trades}0盈亏: {stats.zero_pnl_trades || 0}</>
)}
{stats.meaningful_trades === undefined && <>已平仓的完整交易</>}
</div>
</div>
<div className="stat-card">
<div className="stat-label">胜率</div>
<div className="stat-value">{stats.win_rate.toFixed(2)}%</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>
{stats.meaningful_trades !== undefined && <>已排除0盈亏订单</>}
</div>
</div>
<div className="stat-card">
<div className="stat-label">总盈亏</div>
<div className={`stat-value ${stats.total_pnl >= 0 ? 'positive' : 'negative'}`}>
{stats.total_pnl.toFixed(2)} USDT
</div>
</div>
<div className="stat-card">
<div className="stat-label">平均盈亏</div>
<div className={`stat-value ${stats.avg_pnl >= 0 ? 'positive' : 'negative'}`}>
{stats.avg_pnl.toFixed(2)} USDT
</div>
</div>
{"avg_duration_minutes" in stats && stats.avg_duration_minutes !== null && stats.avg_duration_minutes !== undefined && (
<div className="stat-card">
<div className="stat-label">平均持仓时长分钟</div>
<div className="stat-value">{Number(stats.avg_duration_minutes || 0).toFixed(0)}</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>
仅统计有意义交易优先使用 duration_minutes缺失时用 exit_time-entry_time 计算
</div>
</div>
)}
{"exit_reason_counts" in stats && stats.exit_reason_counts && (
<div className="stat-card">
<div className="stat-label">平仓原因有意义交易</div>
<div className="stat-value" style={{ fontSize: '1.1rem' }}>
{(() => {
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(' / ') : '—'
})()}
</div>
</div>
)}
{"avg_win_pnl" in stats && "avg_loss_pnl_abs" in stats && Number(stats.total_pnl || 0) > 0 && (
<div className="stat-card">
<div className="stat-label">平均盈利 / 平均亏损期望 3:1</div>
<div
className={`stat-value ${typeof stats.avg_win_loss_ratio === 'number' && stats.avg_win_loss_ratio >= 3 ? 'positive' : ''
}`}
>
{typeof stats.avg_win_loss_ratio === 'number'
? `${stats.avg_win_loss_ratio.toFixed(2)} : 1`
: '—'}
</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>
+{Number(stats.avg_win_pnl || 0).toFixed(2)} / -{Number(stats.avg_loss_pnl_abs || 0).toFixed(2)} USDT
</div>
</div>
)}
{"total_notional_usdt" in stats && (
<div className="stat-card">
<div className="stat-label">总交易量名义</div>
<div className="stat-value">{Number(stats.total_notional_usdt || 0).toFixed(2)} USDT</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '4px' }}>
口径入场价×数量
</div>
</div>
)}
</div>
{/* 折叠控制按钮 */}
<div className="stats-toggle-row">
<button
className="btn-toggle"
onClick={() => setShowStatsTable(!showStatsTable)}
title="查看可复制的详细统计表格"
>
{showStatsTable ? '▲ 收起详细统计' : '▼ 展开详细统计(分析用)'}
</button>
</div>
{/* 详细统计表格(可折叠) */}
{showStatsTable && (
<div className="stats-copy-section">
<div className="stats-header">
<h3>详细统计数据</h3>
<button className="btn-copy" onClick={() => handleCopyStats(stats)}>
复制统计
</button>
</div>
<table className="stats-table">
<tbody>
<tr>
<td>总交易数</td>
<td>{stats.total_trades}</td>
</tr>
<tr>
<td>胜率</td>
<td>{stats.win_rate.toFixed(2)}%</td>
</tr>
<tr>
<td>总盈亏</td>
<td>{stats.total_pnl.toFixed(2)} USDT</td>
</tr>
<tr>
<td>平均盈亏</td>
<td>{stats.avg_pnl.toFixed(2)} USDT</td>
</tr>
<tr>
<td>平均持仓时长</td>
<td>{stats.avg_duration_minutes ? Number(stats.avg_duration_minutes).toFixed(0) : 0} 分钟</td>
</tr>
<tr>
<td>平仓原因分布</td>
<td>
{(() => {
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(' / ') : '—'
})()}
</td>
</tr>
<tr>
<td>盈亏比 (期望 3:1)</td>
<td>{Number(stats.avg_win_loss_ratio || 0).toFixed(2)} : 1</td>
</tr>
<tr>
<td>总交易量 (名义)</td>
<td>{Number(stats.total_notional_usdt || 0).toFixed(2)} USDT</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
)
}
{
trades.length === 0 ? (
<div className="no-data">暂无交易记录</div>
) : (
<>
{/* 桌面端表格:用横向滚动包裹,避免整页过宽 */}
<div className="table-wrapper">
<table className="trades-table">
<thead>
<tr>
<th>交易ID</th>
<th>交易对</th>
<th>方向</th>
<th>数量</th>
<th>名义</th>
<th>保证金</th>
<th>入场价</th>
<th>出场价</th>
<th>盈亏</th>
<th>盈亏比例</th>
<th>状态</th>
<th>平仓类型</th>
<th>币安订单号</th>
<th>入场时间</th>
<th>平仓时间</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={trade.id}>
<td style={{ fontSize: '12px', color: '#999' }}>#{trade.id}</td>
<td>{trade.symbol}</td>
<td>
<span className={`side-badge ${trade.side === 'BUY' ? 'buy' : 'sell'}`}>
{trade.side === 'BUY' ? '做多' : '做空'}
</span>
</td>
<td>{parseFloat(trade.quantity).toFixed(4)}</td>
<td>{notional >= 0.01 ? notional.toFixed(2) : notional.toFixed(4)} USDT</td>
<td>{margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT</td>
<td>{parseFloat(trade.entry_price).toFixed(4)}</td>
<td>{trade.exit_price ? parseFloat(trade.exit_price).toFixed(4) : '-'}</td>
<td className={pnl >= 0 ? 'positive' : 'negative'}>
{pnl.toFixed(2)} USDT
</td>
<td className={pnlPercent >= 0 ? 'positive' : 'negative'}>
{pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%
</td>
<td>
<span className={`status-badge status-${trade.status}`}>{trade.status === 'open' ? '持仓中' : trade.status === 'closed' ? '已平仓' : '已取消'}</span>
</td>
<td>{trade.exit_reason_display || '-'}</td>
<td className="order-id" style={{ fontSize: '12px' }}>{formatOrderIds()}</td>
<td>{formatTime(trade.entry_time)}</td>
<td>{trade.exit_time ? formatTime(trade.exit_time) : '-'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* 移动端卡片 */}
<div className="trades-cards">
{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 (
<div key={trade.id} className="trade-card">
<div className="trade-card-header">
<div>
<span className="trade-card-symbol">{trade.symbol}</span>
<span style={{ fontSize: '12px', color: '#999', marginLeft: '8px' }}>#{trade.id}</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<span className={`trade-card-side ${trade.side === 'BUY' ? 'buy' : 'sell'}`}>
{trade.side === 'BUY' ? '买入' : '卖出'}
</span>
<span className={`status-badge status-${trade.status}`}>
{trade.status === 'open' ? '持仓中' : trade.status === 'closed' ? '已平仓' : '已取消'}
</span>
</div>
</div>
<div className="trade-card-body">
<div className="trade-card-field">
<span className="trade-card-label">数量</span>
<span className="trade-card-value">{parseFloat(trade.quantity).toFixed(4)}</span>
</div>
<div className="trade-card-field">
<span className="trade-card-label">名义</span>
<span className="trade-card-value">{notional >= 0.01 ? notional.toFixed(2) : notional.toFixed(4)} USDT</span>
</div>
<div className="trade-card-field">
<span className="trade-card-label">保证金</span>
<span className="trade-card-value">{margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT</span>
</div>
<div className="trade-card-field">
<span className="trade-card-label">入场价</span>
<span className="trade-card-value">{parseFloat(trade.entry_price).toFixed(4)}</span>
</div>
<div className="trade-card-field">
<span className="trade-card-label">出场价</span>
<span className="trade-card-value">{trade.exit_price ? parseFloat(trade.exit_price).toFixed(4) : '-'}</span>
</div>
<div className="trade-card-field">
<span className="trade-card-label">盈亏</span>
<span className={`trade-card-value ${pnl >= 0 ? 'positive' : 'negative'}`}>
{pnl.toFixed(2)} USDT
</span>
</div>
<div className="trade-card-field">
<span className="trade-card-label">盈亏比例</span>
<span className={`trade-card-value ${pnlPercent >= 0 ? 'positive' : 'negative'}`}>
{pnlPercent >= 0 ? '+' : ''}{pnlPercent.toFixed(2)}%
</span>
</div>
{trade.exit_reason_display && (
<div className="trade-card-field">
<span className="trade-card-label">平仓类型</span>
<span className="trade-card-value">{trade.exit_reason_display}</span>
</div>
)}
{(trade.entry_order_id || trade.exit_order_id) && (
<div className="trade-card-field">
<span className="trade-card-label">币安订单号</span>
<span className="trade-card-value order-id" style={{ fontSize: '12px' }}>
{trade.entry_order_id ? `开仓: ${trade.entry_order_id}` : ''}
{trade.entry_order_id && trade.exit_order_id ? ' / ' : ''}
{trade.exit_order_id ? `平仓: ${trade.exit_order_id}` : ''}
</span>
</div>
)}
</div>
<div className="trade-card-footer">
<div className="trade-time-item">
<span className="time-label">入场:</span>
<span>{formatTime(trade.entry_time)}</span>
</div>
{trade.exit_time && (
<div className="trade-time-item">
<span className="time-label">平仓:</span>
<span>{formatTime(trade.exit_time)}</span>
</div>
)}
</div>
</div>
)
})}
</div>
</>
)
}
</div >
)
}
export default TradeList