auto_trade_sys/frontend/src/components/StatsDashboard.jsx
薇薇安 bbae52c15f a
2026-01-17 11:52:40 +08:00

426 lines
18 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 [message, setMessage] = useState('')
const [tradingConfig, setTradingConfig] = useState(null)
useEffect(() => {
loadDashboard()
loadTradingConfig()
const interval = setInterval(() => {
loadDashboard()
loadTradingConfig() // 同时刷新配置
}, 30000) // 每30秒刷新
return () => clearInterval(interval)
}, [])
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数据中包含配置也更新配置状态
if (data.trading_config) {
setTradingConfig(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 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 || []
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}
</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>
<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>
) : (
<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>
<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">
<h3>当前持仓</h3>
{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
}
}
// 计算保证金(用于基于保证金的止损止盈)
const entryValue = trade.entry_value_usdt !== undefined
? parseFloat(trade.entry_value_usdt)
: (quantity * entryPrice)
const leverage = parseFloat(trade.leverage || 10)
const margin = 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
}
}
}
// 计算止损止盈金额(相对于保证金)
const stopLossAmount = margin * stopLossPercentMargin
const takeProfitAmount = margin * takeProfitPercentMargin
// 计算止损价和止盈价(基于保证金金额)
// 优先使用后端返回的止损止盈价格,如果没有则自己计算
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 {
// 自己计算止损止盈价格
// 止损金额 = (开仓价 - 止损价) × 数量 或 (止损价 - 开仓价) × 数量
if (side === 'BUY') {
// 做多:止损价 = 开仓价 - (止损金额 / 数量)
stopLossPrice = entryPrice - (stopLossAmount / quantity)
// 做多:止盈价 = 开仓价 + (止盈金额 / 数量)
takeProfitPrice = entryPrice + (takeProfitAmount / quantity)
} else {
// 做空:止损价 = 开仓价 + (止损金额 / 数量)
stopLossPrice = entryPrice + (stopLossAmount / quantity)
// 做空:止盈价 = 开仓价 - (止盈金额 / 数量)
takeProfitPrice = entryPrice - (takeProfitAmount / quantity)
}
}
// 计算止损比例和止盈比例(相对于保证金,用于显示)
const stopLossPercent = stopLossPercentMargin * 100 // 相对于保证金
const takeProfitPercent = 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
// 格式化开仓时间为具体的年月日时分秒
const formatEntryTime = (timeStr) => {
if (!timeStr) return null
try {
const date = new Date(timeStr)
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 timeStr
}
}
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>数量: {parseFloat(trade.quantity || 0).toFixed(4)}</div>
<div>保证金: {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT</div>
<div>入场价: {entryPrice.toFixed(4)}</div>
{trade.mark_price && (
<div>标记价: {markPrice.toFixed(4)}</div>
)}
{trade.leverage && (
<div>杠杆: {trade.leverage}x</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">(: {stopLossPrice.toFixed(4)})</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">(: {takeProfitPrice.toFixed(4)})</span>
<span className="take-amount">(金额: +{takeProfitAmount >= 0.01 ? takeProfitAmount.toFixed(2) : takeProfitAmount.toFixed(4)} USDT)</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="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