426 lines
18 KiB
JavaScript
426 lines
18 KiB
JavaScript
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
|