This commit is contained in:
薇薇安 2026-01-19 16:26:12 +08:00
parent ed9256899a
commit 7d0b575877
3 changed files with 234 additions and 16 deletions

View File

@ -90,6 +90,17 @@ async def get_realtime_account_data():
logger.error(f" ✗ 币安API连接失败: {e}", exc_info=True) logger.error(f" ✗ 币安API连接失败: {e}", exc_info=True)
raise raise
# 读取持仓模式(单向/对冲),用于前端“重点说明/自检”
dual_side_position = None
position_mode = None
try:
mode_res = await client.client.futures_get_position_mode()
if isinstance(mode_res, dict):
dual_side_position = bool(mode_res.get("dualSidePosition"))
position_mode = "hedge" if dual_side_position else "one_way"
except Exception as e:
logger.warning(f"读取持仓模式失败(将显示为未知): {e}")
# 获取账户余额 # 获取账户余额
logger.info("步骤5: 获取账户余额...") logger.info("步骤5: 获取账户余额...")
try: try:
@ -194,7 +205,10 @@ async def get_realtime_account_data():
# 保证金占用(名义/杠杆汇总) # 保证金占用(名义/杠杆汇总)
"total_margin_value": total_margin_value, "total_margin_value": total_margin_value,
"total_pnl": total_pnl, "total_pnl": total_pnl,
"open_positions": open_positions_count "open_positions": open_positions_count,
# 账户持仓模式(重点:建议使用 one_way
"position_mode": position_mode,
"dual_side_position": dual_side_position,
} }
logger.info("=" * 60) logger.info("=" * 60)
@ -227,6 +241,7 @@ async def get_realtime_account():
@router.get("/positions") @router.get("/positions")
async def get_realtime_positions(): async def get_realtime_positions():
"""获取实时持仓数据""" """获取实时持仓数据"""
client = None
try: try:
# 从数据库读取API密钥 # 从数据库读取API密钥
api_key = TradingConfig.get_value('BINANCE_API_KEY') api_key = TradingConfig.get_value('BINANCE_API_KEY')
@ -260,7 +275,6 @@ async def get_realtime_positions():
logger.info("连接币安API获取持仓...") logger.info("连接币安API获取持仓...")
await client.connect() await client.connect()
positions = await client.get_open_positions() positions = await client.get_open_positions()
await client.disconnect()
logger.info(f"获取到 {len(positions)} 个持仓") logger.info(f"获取到 {len(positions)} 个持仓")
@ -311,26 +325,45 @@ async def get_realtime_positions():
atr_value = None atr_value = None
db_margin_usdt = None db_margin_usdt = None
db_notional_usdt = None db_notional_usdt = None
entry_order_id = None
entry_order_type = None
try: try:
from database.models import Trade from database.models import Trade
db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open') db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open')
if db_trades: if db_trades:
# 找到匹配的交易记录通过symbol和entry_price匹配 # 找到匹配的交易记录(优先通过 entry_price 近似匹配;否则取最新一条 open 记录兜底)
matched = None
for db_trade in db_trades: for db_trade in db_trades:
if abs(float(db_trade.get('entry_price', 0)) - entry_price) < 0.01: try:
entry_time = db_trade.get('entry_time') if abs(float(db_trade.get('entry_price', 0)) - entry_price) < 0.01:
# 尝试从数据库获取止损止盈价格(如果存储了) matched = db_trade
stop_loss_price = db_trade.get('stop_loss_price') break
take_profit_price = db_trade.get('take_profit_price') except Exception:
take_profit_1 = db_trade.get('take_profit_1') continue
take_profit_2 = db_trade.get('take_profit_2') if matched is None:
atr_value = db_trade.get('atr') matched = db_trades[0]
db_margin_usdt = db_trade.get('margin_usdt')
db_notional_usdt = db_trade.get('notional_usdt') entry_time = matched.get('entry_time')
break stop_loss_price = matched.get('stop_loss_price')
take_profit_price = matched.get('take_profit_price')
take_profit_1 = matched.get('take_profit_1')
take_profit_2 = matched.get('take_profit_2')
atr_value = matched.get('atr')
db_margin_usdt = matched.get('margin_usdt')
db_notional_usdt = matched.get('notional_usdt')
entry_order_id = matched.get('entry_order_id')
except Exception as e: except Exception as e:
logger.debug(f"获取数据库信息失败: {e}") logger.debug(f"获取数据库信息失败: {e}")
# 如果数据库中有 entry_order_id尝试从币安查询订单类型LIMIT/MARKET
if entry_order_id:
try:
info = await client.client.futures_get_order(symbol=pos.get('symbol'), orderId=int(entry_order_id))
if isinstance(info, dict):
entry_order_type = info.get("type")
except Exception:
entry_order_type = None
# 如果没有从数据库获取到止损止盈价格,前端会自己计算 # 如果没有从数据库获取到止损止盈价格,前端会自己计算
# 注意:数据库可能没有存储止损止盈价格,这是正常的 # 注意:数据库可能没有存储止损止盈价格,这是正常的
@ -357,6 +390,8 @@ async def get_realtime_positions():
"take_profit_1": take_profit_1, "take_profit_1": take_profit_1,
"take_profit_2": take_profit_2, "take_profit_2": take_profit_2,
"atr": atr_value, "atr": atr_value,
"entry_order_id": entry_order_id,
"entry_order_type": entry_order_type, # LIMIT / MARKET用于仪表板展示“限价/市价”)
}) })
logger.info(f"格式化后 {len(formatted_positions)} 个有效持仓") logger.info(f"格式化后 {len(formatted_positions)} 个有效持仓")
@ -367,6 +402,13 @@ async def get_realtime_positions():
error_msg = f"获取持仓数据失败: {str(e)}" error_msg = f"获取持仓数据失败: {str(e)}"
logger.error(error_msg, exc_info=True) logger.error(error_msg, exc_info=True)
raise HTTPException(status_code=500, detail=error_msg) raise HTTPException(status_code=500, detail=error_msg)
finally:
# 确保断开连接(避免连接泄漏)
try:
if client is not None:
await client.disconnect()
except Exception:
pass
@router.post("/positions/{symbol}/close") @router.post("/positions/{symbol}/close")

View File

@ -54,6 +54,99 @@
color: #34495e; color: #34495e;
} }
.dashboard-notice {
margin: 12px 0 18px;
padding: 14px 14px;
border-radius: 10px;
border: 1px solid #e9ecef;
background: #f8f9fa;
}
.dashboard-notice.important {
border-color: #ffe8a1;
background: #fff8db;
}
.notice-title {
font-weight: 700;
color: #7a5a00;
margin-bottom: 8px;
}
.notice-body {
display: flex;
flex-direction: column;
gap: 8px;
color: #5c4a00;
font-size: 13px;
line-height: 1.5;
}
.notice-row {
display: flex;
gap: 10px;
align-items: flex-start;
}
.notice-label {
min-width: 70px;
font-weight: 700;
}
.notice-value.ok {
color: #1e7e34;
font-weight: 700;
}
.notice-value.warn {
color: #b04a00;
font-weight: 700;
}
.notice-text {
color: #5c4a00;
}
.entry-type-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.entry-type-line {
margin-bottom: 6px;
color: #444;
font-size: 13px;
}
.entry-type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid rgba(0,0,0,0.08);
}
.entry-type-badge.limit {
background: rgba(33, 150, 243, 0.12);
color: #1565c0;
border-color: rgba(21, 101, 192, 0.25);
}
.entry-type-badge.market {
background: rgba(255, 152, 0, 0.14);
color: #8a4f00;
border-color: rgba(138, 79, 0, 0.25);
}
.entry-type-badge.unknown {
background: rgba(158, 158, 158, 0.14);
color: #555;
border-color: rgba(0,0,0,0.18);
}
.account-info { .account-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -10,6 +10,17 @@ const StatsDashboard = () => {
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [tradingConfig, setTradingConfig] = useState(null) 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
}
}
useEffect(() => { useEffect(() => {
loadDashboard() loadDashboard()
loadTradingConfig() loadTradingConfig()
@ -33,9 +44,9 @@ const StatsDashboard = () => {
try { try {
const data = await api.getDashboard() const data = await api.getDashboard()
setDashboardData(data) setDashboardData(data)
// dashboard // dashboard trading_config
if (data.trading_config) { if (data.trading_config) {
setTradingConfig(data.trading_config) setTradingConfig((prev) => ({ ...(prev || {}), ...(data.trading_config || {}) }))
} }
} catch (error) { } catch (error) {
console.error('Failed to load dashboard:', error) console.error('Failed to load dashboard:', error)
@ -123,6 +134,16 @@ const StatsDashboard = () => {
const account = dashboardData?.account const account = dashboardData?.account
const openTrades = dashboardData?.open_trades || [] 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 ( return (
<div className="dashboard"> <div className="dashboard">
@ -151,6 +172,38 @@ const StatsDashboard = () => {
</div> </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-grid">
<div className="dashboard-card"> <div className="dashboard-card">
<h3>账户信息</h3> <h3>账户信息</h3>
@ -184,6 +237,12 @@ const StatsDashboard = () => {
<span className="label">持仓数量:</span> <span className="label">持仓数量:</span>
<span className="value">{account.open_positions}</span> <span className="value">{account.open_positions}</span>
</div> </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> <div>暂无数据</div>
@ -243,6 +302,11 @@ const StatsDashboard = () => {
<div className="dashboard-card"> <div className="dashboard-card">
<h3>当前持仓</h3> <h3>当前持仓</h3>
<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 ? ( {openTrades.length > 0 ? (
<div className="trades-list"> <div className="trades-list">
{openTrades.map((trade, index) => { {openTrades.map((trade, index) => {
@ -396,6 +460,25 @@ const StatsDashboard = () => {
{trade.side} {trade.side}
</div> </div>
<div className="trade-info"> <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>数量: {parseFloat(trade.quantity || 0).toFixed(4)}</div>
<div>名义: {entryValue >= 0.01 ? entryValue.toFixed(2) : entryValue.toFixed(4)} USDT</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>保证金: {margin >= 0.01 ? margin.toFixed(2) : margin.toFixed(4)} USDT</div>