diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index 285337d..7e1f91b 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -89,6 +89,17 @@ async def get_realtime_account_data(): except Exception as e: logger.error(f" ✗ 币安API连接失败: {e}", exc_info=True) 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: 获取账户余额...") @@ -194,7 +205,10 @@ async def get_realtime_account_data(): # 保证金占用(名义/杠杆汇总) "total_margin_value": total_margin_value, "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) @@ -227,6 +241,7 @@ async def get_realtime_account(): @router.get("/positions") async def get_realtime_positions(): """获取实时持仓数据""" + client = None try: # 从数据库读取API密钥 api_key = TradingConfig.get_value('BINANCE_API_KEY') @@ -260,7 +275,6 @@ async def get_realtime_positions(): logger.info("连接币安API获取持仓...") await client.connect() positions = await client.get_open_positions() - await client.disconnect() logger.info(f"获取到 {len(positions)} 个持仓") @@ -311,25 +325,44 @@ async def get_realtime_positions(): atr_value = None db_margin_usdt = None db_notional_usdt = None + entry_order_id = None + entry_order_type = None try: from database.models import Trade db_trades = Trade.get_by_symbol(pos.get('symbol'), status='open') if db_trades: - # 找到匹配的交易记录(通过symbol和entry_price匹配) + # 找到匹配的交易记录(优先通过 entry_price 近似匹配;否则取最新一条 open 记录兜底) + matched = None for db_trade in db_trades: - if abs(float(db_trade.get('entry_price', 0)) - entry_price) < 0.01: - entry_time = db_trade.get('entry_time') - # 尝试从数据库获取止损止盈价格(如果存储了) - stop_loss_price = db_trade.get('stop_loss_price') - take_profit_price = db_trade.get('take_profit_price') - take_profit_1 = db_trade.get('take_profit_1') - take_profit_2 = db_trade.get('take_profit_2') - atr_value = db_trade.get('atr') - db_margin_usdt = db_trade.get('margin_usdt') - db_notional_usdt = db_trade.get('notional_usdt') - break + try: + if abs(float(db_trade.get('entry_price', 0)) - entry_price) < 0.01: + matched = db_trade + break + except Exception: + continue + if matched is None: + matched = db_trades[0] + + entry_time = matched.get('entry_time') + 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: 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_2": take_profit_2, "atr": atr_value, + "entry_order_id": entry_order_id, + "entry_order_type": entry_order_type, # LIMIT / MARKET(用于仪表板展示“限价/市价”) }) logger.info(f"格式化后 {len(formatted_positions)} 个有效持仓") @@ -367,6 +402,13 @@ async def get_realtime_positions(): error_msg = f"获取持仓数据失败: {str(e)}" logger.error(error_msg, exc_info=True) 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") diff --git a/frontend/src/components/StatsDashboard.css b/frontend/src/components/StatsDashboard.css index 574e1e2..6fae898 100644 --- a/frontend/src/components/StatsDashboard.css +++ b/frontend/src/components/StatsDashboard.css @@ -54,6 +54,99 @@ 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 { display: flex; flex-direction: column; diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx index 4ac138e..d923153 100644 --- a/frontend/src/components/StatsDashboard.jsx +++ b/frontend/src/components/StatsDashboard.jsx @@ -10,6 +10,17 @@ const StatsDashboard = () => { 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 + } + } + useEffect(() => { loadDashboard() loadTradingConfig() @@ -33,9 +44,9 @@ const StatsDashboard = () => { try { const data = await api.getDashboard() setDashboardData(data) - // 如果dashboard数据中包含配置,也更新配置状态 + // dashboard 的 trading_config 可能是“子集”,不要覆盖完整配置;合并即可 if (data.trading_config) { - setTradingConfig(data.trading_config) + setTradingConfig((prev) => ({ ...(prev || {}), ...(data.trading_config || {}) })) } } catch (error) { console.error('Failed to load dashboard:', error) @@ -123,6 +134,16 @@ const StatsDashboard = () => { 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 (