diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index b90ae38..a5af439 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -648,98 +648,64 @@ async def fetch_live_positions_pnl(account_id: int): pass -@router.get("/positions") -async def get_realtime_positions(account_id: int = Depends(get_account_id)): - """获取实时持仓数据""" +async def fetch_realtime_positions(account_id: int): + """ + 获取指定账号的「币安实时持仓」列表(与仪表板/GET /positions 一致)。 + 每条持仓会尝试关联本账号下的 DB 记录(开仓时间、止损止盈、entry_order_id 等)。 + 失败时返回 [],不抛异常,便于仪表板回退到 DB 列表。 + """ client = None try: - logger.info(f"get_realtime_positions: 请求的 account_id={account_id}") api_key, api_secret, use_testnet, status = Account.get_credentials(account_id) - logger.info(f"get_realtime_positions: 获取到的 account_id={account_id}, status={status}, api_key exists={bool(api_key)}") - logger.info(f"get_realtime_positions: API Key 前4位={api_key[:4] if api_key and len(api_key) >= 4 else 'N/A'}, 后4位=...{api_key[-4:] if api_key and len(api_key) >= 4 else 'N/A'}") - - logger.info(f"尝试获取实时持仓数据 (testnet={use_testnet}, account_id={account_id})") - if not api_key or not api_secret: - error_msg = f"API密钥未配置(account_id={account_id})" - logger.warning(f"[account_id={account_id}] {error_msg}") - raise HTTPException( - status_code=400, - detail=error_msg - ) - - # 导入BinanceClient + logger.debug(f"fetch_realtime_positions(account_id={account_id}): 无 API 密钥,返回空列表") + return [] + try: from binance_client import BinanceClient except ImportError: trading_system_path = project_root / 'trading_system' sys.path.insert(0, str(trading_system_path)) from binance_client import BinanceClient - - # 确保传递了正确的 api_key 和 api_secret,避免 BinanceClient 从 config 读取 - client = BinanceClient( - api_key=api_key, # 明确传递,避免从 config 读取 - api_secret=api_secret, # 明确传递,避免从 config 读取 - testnet=use_testnet - ) - logger.info(f"BinanceClient 创建成功 (account_id={account_id})") - - logger.info("连接币安API获取持仓...") + + client = BinanceClient(api_key=api_key, api_secret=api_secret, testnet=use_testnet) await client.connect() positions = await client.get_open_positions() - - logger.info(f"获取到 {len(positions)} 个持仓") - - # 并发获取所有持仓的挂单信息(包括普通挂单和Algo挂单) + open_orders_map = {} try: position_symbols = [p.get('symbol') for p in positions if float(p.get('positionAmt', 0)) != 0] if position_symbols: - # 定义获取函数:同时获取普通挂单和Algo挂单 async def fetch_both_orders(symbol): try: - # 并发调用两个接口 t1 = client.get_open_orders(symbol) t2 = client.futures_get_open_algo_orders(symbol, algo_type="CONDITIONAL") res = await asyncio.gather(t1, t2, return_exceptions=True) - orders = [] - # 1. 普通订单 if isinstance(res[0], list): orders.extend(res[0]) - else: - logger.warning(f"获取 {symbol} 普通挂单失败: {res[0]}") - - # 2. Algo订单 (需要标准化为普通订单格式) if isinstance(res[1], list): for algo in res[1]: - # 标准化 Algo Order 结构 orders.append({ 'orderId': algo.get('algoId'), - 'type': algo.get('orderType'), # Algo订单使用 orderType + 'type': algo.get('orderType'), 'side': algo.get('side'), - 'stopPrice': algo.get('triggerPrice'), # Algo订单使用 triggerPrice - 'price': 0, # 通常为0 + 'stopPrice': algo.get('triggerPrice'), + 'price': 0, 'origType': algo.get('algoType'), 'reduceOnly': algo.get('reduceOnly'), - 'status': 'NEW', # 列表中都是生效中的 + 'status': 'NEW', '_is_algo': True }) - else: - logger.warning(f"获取 {symbol} Algo挂单失败: {res[1]}") - return orders except Exception as e: - logger.error(f"获取 {symbol} 订单组合失败: {e}") + logger.debug(f"获取 {symbol} 订单失败: {e}") return [] - logger.info(f"正在获取挂单信息(包含Algo): {position_symbols}") tasks = [fetch_both_orders(sym) for sym in position_symbols] results = await asyncio.gather(*tasks, return_exceptions=True) - for sym, orders in zip(position_symbols, results): if isinstance(orders, list): - # 过滤出止盈止损单 conditional_orders = [] for o in orders: o_type = o.get('type') @@ -756,50 +722,23 @@ async def get_realtime_positions(account_id: int = Depends(get_account_id)): }) if conditional_orders: open_orders_map[sym] = conditional_orders - else: - logger.warning(f"获取 {sym} 挂单失败: {orders}") except Exception as e: - logger.error(f"批量获取挂单失败: {e}") + logger.debug(f"批量获取挂单失败: {e}") - # 格式化持仓数据 formatted_positions = [] for pos in positions: position_amt = float(pos.get('positionAmt', 0)) if position_amt == 0: continue - entry_price = float(pos.get('entryPrice', 0)) - mark_price = float(pos.get('markPrice', 0)) + mark_price = float(pos.get('markPrice', 0)) or entry_price unrealized_pnl = float(pos.get('unRealizedProfit', 0)) - - if mark_price == 0: - mark_price = entry_price - - # === 名义/保证金口径说明(与币安展示更接近)=== - # - 币安的名义价值/仓位价值通常随标记价(markPrice)变动 - # - DB 中的 notional_usdt/margin_usdt 通常是“开仓时”写入,用于复盘/统计 - # - 若发生部分止盈/减仓:币安 positionAmt 会变小,但 DB 里的 notional/margin 可能仍是“原始开仓量” - # → 会出现:数量=6.8,但名义/保证金像是 13.6 的两倍(与你反馈一致) - # - # 因此:实时持仓展示统一使用“当前数量×标记价”的实时名义/保证金, - # 并额外返回 original_* 字段保留 DB 开仓口径,避免混用导致误解。 - - # 兼容旧字段:entry_value_usdt 仍保留(但它是按入场价计算的名义) entry_value_usdt = abs(position_amt) * entry_price - - leverage = float(pos.get('leverage', 1)) - if leverage <= 0: - leverage = 1.0 - - # 当前持仓名义价值(USDT):按标记价 + leverage = max(1.0, float(pos.get('leverage', 1))) notional_usdt_live = abs(position_amt) * mark_price - # 当前持仓保证金(USDT):名义/杠杆 margin_usdt_live = notional_usdt_live / leverage - pnl_percent = 0 - if margin_usdt_live > 0: - pnl_percent = (unrealized_pnl / margin_usdt_live) * 100 - - # 尝试从数据库获取开仓时间、止损止盈价格(以及交易规模字段) + pnl_percent = (unrealized_pnl / margin_usdt_live * 100) if margin_usdt_live > 0 else 0 + entry_time = None stop_loss_price = None take_profit_price = None @@ -810,11 +749,11 @@ async def get_realtime_positions(account_id: int = Depends(get_account_id)): db_notional_usdt = None entry_order_id = None entry_order_type = None + id = None try: 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', account_id=account_id) if db_trades: - # 找到匹配的交易记录(优先通过 entry_price 近似匹配;否则取最新一条 open 记录兜底) matched = None for db_trade in db_trades: try: @@ -825,7 +764,6 @@ async def get_realtime_positions(account_id: int = Depends(get_account_id)): 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') @@ -835,10 +773,10 @@ async def get_realtime_positions(account_id: int = Depends(get_account_id)): db_margin_usdt = matched.get('margin_usdt') db_notional_usdt = matched.get('notional_usdt') entry_order_id = matched.get('entry_order_id') + id = matched.get('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)) @@ -846,48 +784,37 @@ async def get_realtime_positions(account_id: int = Depends(get_account_id)): entry_order_type = info.get("type") except Exception: entry_order_type = None - - # 如果没有从数据库获取到止损止盈价格,前端会自己计算 - # 注意:数据库可能没有存储止损止盈价格,这是正常的 - + formatted_positions.append({ + "id": id, "symbol": pos.get('symbol'), "side": "BUY" if position_amt > 0 else "SELL", "quantity": abs(position_amt), "entry_price": entry_price, - # 兼容旧字段:entry_value_usdt 仍保留(前端已有使用) "entry_value_usdt": entry_value_usdt, - # 实时展示字段:与币安更一致(按当前数量×标记价) "notional_usdt": notional_usdt_live, "margin_usdt": margin_usdt_live, - # 额外返回“开仓记录口径”(用于排查部分止盈/减仓导致的不一致) "original_notional_usdt": db_notional_usdt, "original_margin_usdt": db_margin_usdt, "mark_price": mark_price, "pnl": unrealized_pnl, - "pnl_percent": pnl_percent, # 基于保证金的盈亏百分比 + "pnl_percent": pnl_percent, "leverage": int(pos.get('leverage', 1)), - "entry_time": entry_time, # 开仓时间 - "stop_loss_price": stop_loss_price, # 止损价格(如果可用) - "take_profit_price": take_profit_price, # 止盈价格(如果可用) + "entry_time": entry_time, + "stop_loss_price": stop_loss_price, + "take_profit_price": take_profit_price, "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(用于仪表板展示“限价/市价”) - "open_orders": open_orders_map.get(pos.get('symbol'), []), # 实时挂单信息 + "entry_order_type": entry_order_type, + "open_orders": open_orders_map.get(pos.get('symbol'), []), }) - - logger.info(f"格式化后 {len(formatted_positions)} 个有效持仓") return formatted_positions - except HTTPException: - raise except Exception as e: - error_msg = f"获取持仓数据失败: {str(e)}" - logger.error(error_msg, exc_info=True) - raise HTTPException(status_code=500, detail=error_msg) + logger.warning(f"fetch_realtime_positions(account_id={account_id}) 失败: {e}", exc_info=True) + return [] finally: - # 确保断开连接(避免连接泄漏) try: if client is not None: await client.disconnect() @@ -895,6 +822,18 @@ async def get_realtime_positions(account_id: int = Depends(get_account_id)): pass +@router.get("/positions") +async def get_realtime_positions(account_id: int = Depends(get_account_id)): + """获取实时持仓数据(币安实际持仓,并关联本账号 DB 记录)""" + api_key, api_secret, _, _ = Account.get_credentials(account_id) + if not api_key or not api_secret: + raise HTTPException(status_code=400, detail=f"API密钥未配置(account_id={account_id})") + result = await fetch_realtime_positions(account_id) + if result is None: + raise HTTPException(status_code=500, detail="获取持仓数据失败") + return result + + @router.post("/positions/{symbol}/close") async def close_position(symbol: str, account_id: int = Depends(get_account_id)): """手动平仓指定交易对的持仓""" diff --git a/backend/api/routes/stats.py b/backend/api/routes/stats.py index 9753534..a2ffd98 100644 --- a/backend/api/routes/stats.py +++ b/backend/api/routes/stats.py @@ -151,48 +151,47 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)): "open_positions": 0 } - # 获取持仓数据(强制使用数据库记录) + # 获取持仓数据:优先「币安实时持仓」(含本系统下的挂单),失败时回退到数据库列表 open_trades = [] positions_error = None try: - db_trades = Trade.get_all(status='open', account_id=account_id) - # 格式化数据库记录,添加 entry_value_usdt 字段 - open_trades = [] - for trade in db_trades: - entry_value_usdt = float(trade.get('quantity', 0)) * float(trade.get('entry_price', 0)) - leverage = float(trade.get('leverage', 1)) - pnl = float(trade.get('pnl', 0)) - - # 数据库中的pnl_percent是价格涨跌幅,需要转换为收益率 - # 收益率 = 盈亏 / 保证金 - margin = entry_value_usdt / leverage if leverage > 0 else entry_value_usdt - pnl_percent = (pnl / margin * 100) if margin > 0 else 0 - - formatted_trade = { - **trade, - 'entry_value_usdt': entry_value_usdt, - 'mark_price': trade.get('entry_price', 0), # 默认入场价,下面用实时数据覆盖 - 'pnl': pnl, - 'pnl_percent': pnl_percent - } - open_trades.append(formatted_trade) - # 合并实时持仓盈亏(mark_price / pnl / pnl_percent),仪表板可显示浮盈浮亏 try: - from api.routes.account import fetch_live_positions_pnl - live_list = await fetch_live_positions_pnl(account_id) - by_symbol = {p["symbol"]: p for p in live_list} - for t in open_trades: - sym = t.get("symbol") - if sym and sym in by_symbol: - lp = by_symbol[sym] - t["mark_price"] = lp.get("mark_price", t.get("entry_price")) - t["pnl"] = lp.get("pnl", 0) - t["pnl_percent"] = lp.get("pnl_percent", 0) - if by_symbol: - logger.info(f"已合并 {len(by_symbol)} 个实时持仓盈亏到仪表板") - except Exception as merge_err: - logger.debug(f"合并实时持仓盈亏失败(仪表板仍显示持仓,盈亏为 0): {merge_err}") - logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓") + from api.routes.account import fetch_realtime_positions + open_trades = await fetch_realtime_positions(account_id) + except Exception as fetch_err: + logger.warning(f"获取币安实时持仓失败,回退到数据库列表: {fetch_err}") + open_trades = [] + if not open_trades: + db_trades = Trade.get_all(status='open', account_id=account_id) + for trade in db_trades: + entry_value_usdt = float(trade.get('quantity', 0)) * float(trade.get('entry_price', 0)) + leverage = float(trade.get('leverage', 1)) + pnl = float(trade.get('pnl', 0)) + margin = entry_value_usdt / leverage if leverage > 0 else entry_value_usdt + pnl_percent = (pnl / margin * 100) if margin > 0 else 0 + open_trades.append({ + **trade, + 'entry_value_usdt': entry_value_usdt, + 'mark_price': trade.get('entry_price', 0), + 'pnl': pnl, + 'pnl_percent': pnl_percent + }) + try: + from api.routes.account import fetch_live_positions_pnl + live_list = await fetch_live_positions_pnl(account_id) + by_symbol = {p["symbol"]: p for p in live_list} + for t in open_trades: + sym = t.get("symbol") + if sym and sym in by_symbol: + lp = by_symbol[sym] + t["mark_price"] = lp.get("mark_price", t.get("entry_price")) + t["pnl"] = lp.get("pnl", 0) + t["pnl_percent"] = lp.get("pnl_percent", 0) + except Exception as merge_err: + logger.debug(f"合并实时持仓盈亏失败: {merge_err}") + logger.info(f"使用数据库记录作为持仓数据: {len(open_trades)} 个持仓") + else: + logger.info(f"使用币安实时持仓作为列表: {len(open_trades)} 个持仓") except Exception as db_error: logger.error(f"从数据库获取持仓记录失败: {db_error}") @@ -286,9 +285,12 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)): except Exception as e: logger.debug(f"获取交易配置失败: {e}") + # 本系统持仓数 = 数据库 status=open 条数,与下方「当前持仓」列表一致;币安持仓数 = 接口/快照中的 open_positions,可能与币安页面一致 + open_trades_count = len(open_trades) result = { "account": account_data, "open_trades": open_trades, + "open_trades_count": open_trades_count, # 本系统持仓数,与列表条数一致 "recent_scans": recent_scans, "recent_signals": recent_signals, "position_stats": position_stats, @@ -296,7 +298,7 @@ async def get_dashboard_data(account_id: int = Depends(get_account_id)): "_debug": { # 添加调试信息 "account_id": account_id, "account_data_total_balance": account_data.get('total_balance', 'N/A') if account_data else 'N/A', - "open_trades_count": len(open_trades), + "open_trades_count": open_trades_count, } } diff --git a/frontend/src/components/StatsDashboard.css b/frontend/src/components/StatsDashboard.css index 51e743c..b0cad95 100644 --- a/frontend/src/components/StatsDashboard.css +++ b/frontend/src/components/StatsDashboard.css @@ -467,6 +467,12 @@ margin: 0; } +.positions-subtitle { + margin: 0 0 10px 0; + font-size: 12px; + color: #6c757d; +} + .sltp-all-btn { padding: 0.5rem 0.9rem; background: #1f7aec; diff --git a/frontend/src/components/StatsDashboard.jsx b/frontend/src/components/StatsDashboard.jsx index 34aa442..c9471c7 100644 --- a/frontend/src/components/StatsDashboard.jsx +++ b/frontend/src/components/StatsDashboard.jsx @@ -408,7 +408,12 @@ const StatsDashboard = () => {
+ 仅本系统记录 {openTrades.length} 条,币安实际 {account.open_positions} 个 +
+ )}