auto_trade_sys/frontend/src/components/TradeList.jsx
薇薇安 b1f4cbddac a
2026-01-22 18:53:32 +08:00

606 lines
25 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 { api } from '../services/api'
import './TradeList.css'
const TradeList = () => {
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()
// 监听账号切换事件,自动重新加载数据
const handleAccountChange = () => {
loadData()
}
window.addEventListener('ats:account:changed', handleAccountChange)
return () => {
window.removeEventListener('ats:account:changed', handleAccountChange)
}
}, [])
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)
}
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>
</div>
</div>
{
stats && (
<div className="stats-summary">
<div className="stat-card">
<div className="stat-label">整体统计</div>
<div className="stat-value">总交易数{stats.total_trades}</div>
<div className="stat-value">胜率{stats.win_rate.toFixed(2)}%</div>
<div className="stat-value">总盈亏{stats.total_pnl.toFixed(2)} USDT</div>
<div className="stat-value">平均盈亏{stats.avg_pnl.toFixed(2)} USDT</div>
<div className="stat-value">平均持仓时长分钟{stats.avg_duration_minutes ? Number(stats.avg_duration_minutes):0}</div>
<div className="stat-value">平仓原因有意义交易
<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>
<div className="stat-value">平均盈利 / 平均亏损期望 3:1{stats.avg_win_loss_ratio} : 1</div>
<div className="stat-value">总交易量名义{stats.total_notional_usdt} USDT</div>
</div>
</div>
)
}
{stats && (
<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>
)}
{trades.length === 0 ? (
<div className="no-data">暂无交易记录</div>
) : (
<>
{/* 桌面端表格 */}
<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)
// 计算盈亏比例(盈亏/保证金)
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 (
<tr key={trade.id}>
<td style={{ fontSize: '12px', color: '#999' }}>#{trade.id}</td>
<td>{trade.symbol}</td>
<td className={trade.side === 'BUY' ? 'buy' : 'sell'}>{trade.side}</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 ${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 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)
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 (
<div key={trade.id} className="trade-card">
<div className="trade-card-header">
<span className="trade-card-symbol">{trade.symbol}</span>
<span style={{ fontSize: '11px', color: '#999', marginLeft: '8px' }}>交易ID: #{trade.id}</span>
<span className={`trade-card-side ${trade.side === 'BUY' ? 'buy' : 'sell'} status ${trade.status}`}>
{trade.side === 'BUY' ? '买入' : '卖出'} · {trade.status === 'open' ? '持仓中' : trade.status === 'closed' ? '已平仓' : '已取消'}
</span>
</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