auto_trade_sys/frontend/src/components/StatsDashboard.jsx
薇薇安 156acc92e0 a
2026-01-22 09:06:10 +08:00

670 lines
30 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 { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import './StatsDashboard.css'
const StatsDashboard = () => {
const [dashboardData, setDashboardData] = useState(null)
const [loading, setLoading] = useState(true)
const [closingSymbol, setClosingSymbol] = useState(null)
const [sltpSymbol, setSltpSymbol] = useState(null)
const [sltpAllBusy, setSltpAllBusy] = useState(false)
const [sltpDebugText, setSltpDebugText] = useState('')
const [message, setMessage] = useState('')
const [tradingConfig, setTradingConfig] = useState(null)
const getCfgVal = (key, fallback = null) => {
try {
const cfg = tradingConfig || {}
const item = cfg?.[key]
if (item && typeof item === 'object' && 'value' in item) return item.value
return fallback
} catch (e) {
return fallback
}
}
const fmtPrice = (v) => {
const n = Number(v)
if (!isFinite(n)) return '-'
const a = Math.abs(n)
// 低价币需要更高精度,否则“止损价=入场价”只是显示被四舍五入了
const dp =
a >= 1000 ? 2 :
a >= 100 ? 3 :
a >= 1 ? 4 :
a >= 0.01 ? 6 :
a >= 0.0001 ? 8 :
10
return n.toFixed(dp).replace(/\.?0+$/, '')
}
useEffect(() => {
loadDashboard()
loadTradingConfig()
const interval = setInterval(() => {
loadDashboard()
loadTradingConfig() // 同时刷新配置
}, 30000) // 每30秒刷新
// 监听账号切换事件,自动重新加载数据
const handleAccountChange = () => {
loadDashboard()
loadTradingConfig()
}
window.addEventListener('ats:account:changed', handleAccountChange)
return () => {
clearInterval(interval)
window.removeEventListener('ats:account:changed', handleAccountChange)
}
}, [])
const loadTradingConfig = async () => {
try {
const configs = await api.getConfigs()
setTradingConfig(configs)
} catch (error) {
console.error('Failed to load trading config:', error)
}
}
const loadDashboard = async () => {
try {
const data = await api.getDashboard()
setDashboardData(data)
// dashboard 的 trading_config 可能是“子集”,不要覆盖完整配置;合并即可
if (data.trading_config) {
setTradingConfig((prev) => ({ ...(prev || {}), ...(data.trading_config || {}) }))
}
} catch (error) {
console.error('Failed to load dashboard:', error)
} finally {
setLoading(false)
}
}
const handleClosePosition = async (symbol) => {
if (!window.confirm(`确定要平仓 ${symbol} 吗?`)) {
return
}
setClosingSymbol(symbol)
setMessage('')
try {
console.log(`开始平仓 ${symbol}...`)
const result = await api.closePosition(symbol)
console.log('平仓结果:', result)
setMessage(result.message || `${symbol} 平仓成功`)
// 立即刷新数据
await loadDashboard()
// 3秒后清除消息
setTimeout(() => {
setMessage('')
}, 3000)
} catch (error) {
console.error('Close position error:', error)
const errorMessage = error.message || error.toString() || '平仓失败,请检查网络连接或后端服务'
setMessage(`平仓失败: ${errorMessage}`)
// 错误消息5秒后清除
setTimeout(() => {
setMessage('')
}, 5000)
} finally {
setClosingSymbol(null)
}
}
const handleEnsureSLTP = async (symbol) => {
if (!window.confirm(`确定要为 ${symbol} 补挂“币安止损/止盈保护单”吗?\n\n说明:将自动取消该交易对已有的 STOP/TP 保护单并重新挂单(避免重复)。`)) {
return
}
setSltpSymbol(symbol)
setMessage('')
setSltpDebugText('')
try {
const res = await api.ensurePositionSLTP(symbol)
const slId = res?.orders?.stop_market?.orderId
const tpId = res?.orders?.take_profit_market?.orderId
const cnt = Array.isArray(res?.open_protection_orders) ? res.open_protection_orders.length : 0
setMessage(`${symbol} 已尝试补挂SL=${slId || '-'} / TP=${tpId || '-'}(当前检测到保护单 ${cnt} 条)`)
setSltpDebugText(JSON.stringify(res || {}, null, 2))
await loadDashboard()
} catch (error) {
setMessage(`补挂失败 ${symbol}: ${error.message || '未知错误'}`)
} finally {
setSltpSymbol(null)
setTimeout(() => setMessage(''), 4000)
}
}
const handleEnsureAllSLTP = async () => {
if (!window.confirm('确定要为【所有当前持仓】一键补挂“币安止损/止盈保护单”吗?\n\n说明会对每个持仓 symbol 自动取消旧的 STOP/TP 保护单并重新挂单(避免重复)。')) {
return
}
setSltpAllBusy(true)
setMessage('')
setSltpDebugText('')
try {
const res = await api.ensureAllPositionsSLTP(50)
const ok = res?.ok ?? 0
const failed = res?.failed ?? 0
let openCnt = 0
try {
const arr = Array.isArray(res?.results) ? res.results : []
openCnt = arr.reduce((acc, it) => acc + (Array.isArray(it?.open_protection_orders) ? it.open_protection_orders.length : 0), 0)
} catch (e) {
openCnt = 0
}
setMessage(`一键补挂完成:成功 ${ok} / 失败 ${failed}(检测到保护单 ${openCnt} 条)`)
setSltpDebugText(JSON.stringify(res || {}, null, 2))
await loadDashboard()
} catch (error) {
setMessage(`一键补挂失败: ${error.message || '未知错误'}`)
} finally {
setSltpAllBusy(false)
setTimeout(() => setMessage(''), 5000)
}
}
const handleSyncPositions = async () => {
if (!window.confirm('确定要同步持仓状态吗?这将检查币安实际持仓并更新数据库状态。')) {
return
}
setMessage('正在同步持仓状态...')
try {
const result = await api.syncPositions()
console.log('同步结果:', result)
let message = result.message || '同步完成'
if (result.updated_to_closed > 0) {
message += `,已更新 ${result.updated_to_closed} 条记录为已平仓`
}
if (result.missing_in_db && result.missing_in_db.length > 0) {
message += `,发现 ${result.missing_in_db.length} 个币安持仓在数据库中没有记录`
}
setMessage(message)
// 立即刷新数据
await loadDashboard()
// 5秒后清除消息
setTimeout(() => {
setMessage('')
}, 5000)
} catch (error) {
console.error('Sync positions error:', error)
const errorMessage = error.message || error.toString() || '同步失败,请检查网络连接或后端服务'
setMessage(`同步失败: ${errorMessage}`)
// 错误消息5秒后清除
setTimeout(() => {
setMessage('')
}, 5000)
}
}
if (loading) return <div className="loading">加载中...</div>
const account = dashboardData?.account
const openTrades = dashboardData?.open_trades || []
const entryTypeCounts = openTrades.reduce(
(acc, t) => {
const tp = String(t?.entry_order_type || '').toUpperCase()
if (tp === 'LIMIT') acc.limit += 1
else if (tp === 'MARKET') acc.market += 1
else acc.unknown += 1
return acc
},
{ limit: 0, market: 0, unknown: 0 }
)
return (
<div className="dashboard">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2>仪表板</h2>
<button
onClick={handleSyncPositions}
style={{
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}}
title="同步币安实际持仓状态与数据库状态"
>
同步持仓状态
</button>
</div>
{message && (
<div className={`message ${message.includes('失败') || message.includes('错误') ? 'error' : 'success'}`}>
{message}
{sltpDebugText ? (
<details style={{ marginTop: '8px' }}>
<summary style={{ cursor: 'pointer' }}>查看补挂返回详情用于排查</summary>
<pre style={{ marginTop: '8px', whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: '12px' }}>
{sltpDebugText}
</pre>
</details>
) : null}
</div>
)}
<div className="dashboard-notice important">
<div className="notice-title">重要说明币安账户请使用单向持仓模式One-way</div>
<div className="notice-body">
<div className="notice-row">
<span className="notice-label">当前检测:</span>
<span className={`notice-value ${account?.position_mode === 'one_way' ? 'ok' : 'warn'}`}>
{account?.position_mode === 'one_way'
? '单向(推荐)'
: account?.position_mode === 'hedge'
? '对冲(不推荐,容易出现 positionSide/mode 相关错误)'
: '未知(建议重启后端/交易系统后再看)'}
</span>
</div>
<div className="notice-row">
<span className="notice-label">操作指引:</span>
<span className="notice-text">
币安 U本位合约 偏好设置/设置 持仓模式(Position Mode) 选择单向切换通常要求当前无持仓/无挂单
</span>
</div>
<div className="notice-row">
<span className="notice-label">入场逻辑:</span>
<span className="notice-text">
智能入场(方案C)先限价回调入场未成交会有限追价趋势强时在偏离可控范围内可市价兜底减少错过
当前配置SMART_ENTRY_ENABLED={String(getCfgVal('SMART_ENTRY_ENABLED', true))}
LIMIT_ORDER_OFFSET_PCT={String(getCfgVal('LIMIT_ORDER_OFFSET_PCT', '0.5'))}
ENTRY_MAX_DRIFT_PCT_TRENDING={String(getCfgVal('ENTRY_MAX_DRIFT_PCT_TRENDING', '0.6'))}
ENTRY_MAX_DRIFT_PCT_RANGING={String(getCfgVal('ENTRY_MAX_DRIFT_PCT_RANGING', '0.3'))}
</span>
</div>
</div>
</div>
<div className="dashboard-grid">
<div className="dashboard-card">
<h3>账户信息</h3>
{account ? (
<div className="account-info">
<div className="info-item">
<span className="label">总余额:</span>
<span className="value">{parseFloat(account.total_balance).toFixed(2)} USDT</span>
</div>
<div className="info-item">
<span className="label">可用余额:</span>
<span className="value">{parseFloat(account.available_balance).toFixed(2)} USDT</span>
</div>
<div className="info-item">
<span className="label">总仓位(名义):</span>
<span className="value">{parseFloat(account.total_position_value).toFixed(2)} USDT</span>
</div>
{account.total_margin_value !== undefined && account.total_margin_value !== null && (
<div className="info-item">
<span className="label">保证金占用(估算):</span>
<span className="value">{parseFloat(account.total_margin_value).toFixed(2)} USDT</span>
</div>
)}
<div className="info-item">
<span className="label">总盈亏:</span>
<span className={`value ${parseFloat(account.total_pnl) >= 0 ? 'positive' : 'negative'}`}>
{parseFloat(account.total_pnl).toFixed(2)} USDT
</span>
</div>
<div className="info-item">
<span className="label">持仓数量:</span>
<span className="value">{account.open_positions}</span>
</div>
<div className="info-item">
<span className="label">持仓模式:</span>
<span className={`value ${account.position_mode === 'one_way' ? 'positive' : 'negative'}`}>
{account.position_mode === 'one_way' ? '单向' : account.position_mode === 'hedge' ? '对冲' : '未知'}
</span>
</div>
</div>
) : (
<div>暂无数据</div>
)}
</div>
{dashboardData?.position_stats && (
<div className="dashboard-card">
<h3>仓位占比</h3>
<div className="account-info">
<div className="info-item">
<span className="label">当前仓位占比(保证金):</span>
<span className="value">
<span>{dashboardData.position_stats.current_position_percent}%</span>
<span className="position-bar-container">
<span
className="position-bar"
style={{
width: `${Math.min((dashboardData.position_stats.current_position_percent / dashboardData.position_stats.max_position_percent) * 100, 100)}%`,
backgroundColor: dashboardData.position_stats.current_position_percent > dashboardData.position_stats.max_position_percent * 0.8
? '#ff6b6b'
: dashboardData.position_stats.current_position_percent > dashboardData.position_stats.max_position_percent * 0.6
? '#ffa500'
: '#51cf66'
}}
/>
</span>
</span>
</div>
<div className="info-item">
<span className="label">最大仓位占比:</span>
<span className="value">{dashboardData.position_stats.max_position_percent}%</span>
</div>
{dashboardData.position_stats.current_notional_percent !== undefined && (
<div className="info-item">
<span className="label">名义占比(参考):</span>
<span className="value">{dashboardData.position_stats.current_notional_percent}%</span>
</div>
)}
<div className="info-item">
<span className="label">最大保证金:</span>
<span className="value">{dashboardData.position_stats.max_position_value.toFixed(2)} USDT</span>
</div>
<div className="info-item">
<span className="label">已用保证金:</span>
<span className="value">{dashboardData.position_stats.total_position_value.toFixed(2)} USDT</span>
</div>
<div className="info-item">
<span className="label">可用保证金:</span>
<span className="value">
{(dashboardData.position_stats.max_position_value - dashboardData.position_stats.total_position_value).toFixed(2)} USDT
</span>
</div>
</div>
</div>
)}
<div className="dashboard-card">
<div className="positions-header">
<h3>当前持仓</h3>
<button
className="sltp-all-btn"
onClick={handleEnsureAllSLTP}
disabled={sltpAllBusy || openTrades.length === 0}
title="为所有持仓在币安侧补挂 STOP_MARKET + TAKE_PROFIT_MARKET 保护单closePosition"
>
{sltpAllBusy ? '一键补挂中...' : '一键补挂止盈止损'}
</button>
</div>
<div className="entry-type-summary">
<span className="entry-type-badge limit">限价入场: {entryTypeCounts.limit}</span>
<span className="entry-type-badge market">市价入场: {entryTypeCounts.market}</span>
<span className="entry-type-badge unknown">未知: {entryTypeCounts.unknown}</span>
</div>
{openTrades.length > 0 ? (
<div className="trades-list">
{openTrades.map((trade, index) => {
// 计算价格涨跌比例、止损比例、止盈比例
const entryPrice = parseFloat(trade.entry_price || 0)
const markPrice = parseFloat(trade.mark_price || entryPrice)
const side = trade.side || 'BUY'
const quantity = parseFloat(trade.quantity || 0)
// 价格涨跌比例(当前价格相对于入场价)
let priceChangePercent = 0
if (entryPrice > 0) {
if (side === 'BUY') {
priceChangePercent = ((markPrice - entryPrice) / entryPrice) * 100
} else {
priceChangePercent = ((entryPrice - markPrice) / entryPrice) * 100
}
}
// 名义/保证金优先使用后端返回字段ATR接入后也用于统一口径
const entryValue = 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)
: (quantity * entryPrice)
)
const leverage = parseFloat(trade.leverage || 10)
const margin = trade.margin_usdt !== undefined && trade.margin_usdt !== null
? parseFloat(trade.margin_usdt)
: (leverage > 0 ? entryValue / leverage : entryValue)
// 从配置获取止损止盈比例(相对于保证金)
const configSource = dashboardData?.trading_config || tradingConfig
const stopLossConfig = configSource?.STOP_LOSS_PERCENT
const takeProfitConfig = configSource?.TAKE_PROFIT_PERCENT
// 配置值是小数形式0.08表示8%),相对于保证金
let stopLossPercentMargin = 0.08 // 默认8%(相对于保证金,更宽松)
let takeProfitPercentMargin = 0.15 // 默认15%(相对于保证金,给趋势更多空间)
if (stopLossConfig) {
const configValue = stopLossConfig.value
if (typeof configValue === 'number') {
stopLossPercentMargin = configValue
} else {
const parsed = parseFloat(configValue)
if (!isNaN(parsed)) {
stopLossPercentMargin = parsed
}
}
}
if (takeProfitConfig) {
const configValue = takeProfitConfig.value
if (typeof configValue === 'number') {
takeProfitPercentMargin = configValue
} else {
const parsed = parseFloat(configValue)
if (!isNaN(parsed)) {
takeProfitPercentMargin = parsed
}
}
}
// 计算止损价和止盈价(基于保证金金额)
// 优先使用后端返回的止损止盈价格,如果没有则自己计算
let stopLossPrice = 0
let takeProfitPrice = 0
if (trade.stop_loss_price && trade.take_profit_price) {
// 使用后端返回的止损止盈价格(如果可用)
stopLossPrice = parseFloat(trade.stop_loss_price)
takeProfitPrice = parseFloat(trade.take_profit_price)
} else {
// 计算止损止盈金额(相对于保证金)
const stopLossAmount = margin * stopLossPercentMargin
const takeProfitAmount = margin * takeProfitPercentMargin
// 自己计算止损止盈价格
// 止损金额 = (开仓价 - 止损价) × 数量 或 (止损价 - 开仓价) × 数量
if (side === 'BUY') {
// 做多:止损价 = 开仓价 - (止损金额 / 数量)
stopLossPrice = entryPrice - (stopLossAmount / quantity)
// 做多:止盈价 = 开仓价 + (止盈金额 / 数量)
takeProfitPrice = entryPrice + (takeProfitAmount / quantity)
} else {
// 做空:止损价 = 开仓价 + (止损金额 / 数量)
stopLossPrice = entryPrice + (stopLossAmount / quantity)
// 做空:止盈价 = 开仓价 - (止盈金额 / 数量)
takeProfitPrice = entryPrice - (takeProfitAmount / quantity)
}
}
// ATR 接入后,止损/止盈“金额与比例”要以实际价格反推(否则会显示成配置值而非真实值)
let stopLossAmount = 0
let takeProfitAmount = 0
if (entryPrice > 0 && quantity > 0 && stopLossPrice > 0 && takeProfitPrice > 0) {
if (side === 'BUY') {
stopLossAmount = Math.max(0, (entryPrice - stopLossPrice) * quantity)
takeProfitAmount = Math.max(0, (takeProfitPrice - entryPrice) * quantity)
} else {
stopLossAmount = Math.max(0, (stopLossPrice - entryPrice) * quantity)
takeProfitAmount = Math.max(0, (entryPrice - takeProfitPrice) * quantity)
}
} else {
stopLossAmount = margin * stopLossPercentMargin
takeProfitAmount = margin * takeProfitPercentMargin
}
const stopLossPercent = margin > 0 ? (stopLossAmount / margin) * 100 : (stopLossPercentMargin * 100)
const takeProfitPercent = margin > 0 ? (takeProfitAmount / margin) * 100 : (takeProfitPercentMargin * 100)
// 也计算价格百分比(用于参考)
const stopLossPercentPrice = side === 'BUY'
? ((entryPrice - stopLossPrice) / entryPrice) * 100
: ((stopLossPrice - entryPrice) / entryPrice) * 100
const takeProfitPercentPrice = side === 'BUY'
? ((takeProfitPrice - entryPrice) / entryPrice) * 100
: ((entryPrice - takeProfitPrice) / entryPrice) * 100
// 格式化开仓时间为具体的年月日时分秒
// 支持Unix时间戳秒数或日期字符串
const formatEntryTime = (timeValue) => {
if (!timeValue) return null
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)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
} catch (e) {
return String(timeValue)
}
}
return (
<div key={trade.id || trade.symbol || index} className="trade-item">
<div className="trade-symbol">{trade.symbol}</div>
<div className={`trade-side ${trade.side === 'BUY' ? 'buy' : 'sell'}`}>
{trade.side}
</div>
<div className="trade-info">
<div className="entry-type-line">
入场类型:{' '}
<span
className={`entry-type-badge ${
String(trade.entry_order_type || '').toUpperCase() === 'LIMIT'
? 'limit'
: String(trade.entry_order_type || '').toUpperCase() === 'MARKET'
? 'market'
: 'unknown'
}`}
title="来自币安 entry_order_id 查询的订单类型"
>
{String(trade.entry_order_type || '').toUpperCase() === 'LIMIT'
? '限价'
: String(trade.entry_order_type || '').toUpperCase() === 'MARKET'
? '市价'
: '未知'}
</span>
</div>
<div>数量: {parseFloat(trade.quantity || 0).toFixed(4)}</div>
<div>名义: {entryValue >= 0.01 ? entryValue.toFixed(2) : entryValue.toFixed(4)} USDT</div>
<div>保证金: {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT</div>
<div>入场价: {fmtPrice(entryPrice)}</div>
{trade.mark_price && (
<div>标记价: {fmtPrice(markPrice)}</div>
)}
{trade.leverage && (
<div>杠杆: {trade.leverage}x</div>
)}
{trade.atr !== undefined && trade.atr !== null && (
<div>ATR: {parseFloat(trade.atr).toFixed(6)}</div>
)}
{/* 价格涨跌比例 */}
<div className={`price-change ${priceChangePercent >= 0 ? 'positive' : 'negative'}`}>
价格涨跌: {priceChangePercent >= 0 ? '+' : ''}{priceChangePercent.toFixed(2)}%
</div>
{/* 止损止盈比例 */}
{trade.entry_time && (
<div className="entry-time">开仓时间: {formatEntryTime(trade.entry_time)}</div>
)}
</div>
<div className="stop-take-info">
<div className="stop-loss-info">
止损: <span className="negative">-{stopLossPercent.toFixed(2)}%</span>
<span className="stop-note">(of margin)</span>
<span className="stop-price">(: {fmtPrice(stopLossPrice)})</span>
<span className="stop-amount">(金额: -{stopLossAmount >= 0.01 ? stopLossAmount.toFixed(2) : stopLossAmount.toFixed(4)} USDT)</span>
</div>
<div className="take-profit-info">
止盈: <span className="positive">+{takeProfitPercent.toFixed(2)}%</span>
<span className="take-note">(of margin)</span>
<span className="take-price">(: {fmtPrice(takeProfitPrice)})</span>
<span className="take-amount">(金额: +{takeProfitAmount >= 0.01 ? takeProfitAmount.toFixed(2) : takeProfitAmount.toFixed(4)} USDT)</span>
</div>
{(trade.take_profit_1 || trade.take_profit_2) && (
<div style={{ marginTop: '6px', fontSize: '12px', color: '#666' }}>
{trade.take_profit_1 && (
<span style={{ marginRight: '10px' }}>TP1: {fmtPrice(parseFloat(trade.take_profit_1))}</span>
)}
{trade.take_profit_2 && (
<span>TP2: {fmtPrice(parseFloat(trade.take_profit_2))}</span>
)}
</div>
)}
</div>
<div className="trade-actions">
<div className={`trade-pnl ${parseFloat(trade.pnl || 0) >= 0 ? 'positive' : 'negative'}`}>
{parseFloat(trade.pnl || 0).toFixed(2)} USDT
{trade.pnl_percent !== undefined && (
<span> ({parseFloat(trade.pnl_percent).toFixed(2)}%)</span>
)}
</div>
<button
className="sltp-btn"
onClick={() => handleEnsureSLTP(trade.symbol)}
disabled={sltpSymbol === trade.symbol}
title="在币安侧补挂 STOP_MARKET + TAKE_PROFIT_MARKET 保护单closePosition"
>
{sltpSymbol === trade.symbol ? '补挂中...' : '补挂止盈止损'}
</button>
<button
className="close-btn"
onClick={() => handleClosePosition(trade.symbol)}
disabled={closingSymbol === trade.symbol}
title={`平仓 ${trade.symbol}`}
>
{closingSymbol === trade.symbol ? '平仓中...' : '平仓'}
</button>
</div>
</div>
)
})}
</div>
) : (
<div>暂无持仓</div>
)}
</div>
</div>
</div>
)
}
export default StatsDashboard