From 16cf4f21576e5032c856706f737ae2abbae1a585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=96=87=E8=96=87=E5=AE=89?= Date: Sat, 14 Feb 2026 18:06:10 +0800 Subject: [PATCH] 1 --- backend/api/routes/account.py | 155 +++++++-------------- backend/api/routes/stats.py | 80 +++++------ frontend/src/components/StatsDashboard.css | 6 + frontend/src/components/StatsDashboard.jsx | 12 +- trading_system/binance_client.py | 13 +- trading_system/config.py | 2 + trading_system/position_manager.py | 14 +- 7 files changed, 127 insertions(+), 155 deletions(-) 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 = () => {
持仓数量: - {account.open_positions} + + 本系统 {openTrades.length} + {account.open_positions != null && Number(account.open_positions) !== openTrades.length && ( + <> · 币安 {account.open_positions} + )} +
持仓模式: @@ -524,6 +529,11 @@ const StatsDashboard = () => {
+ {account?.open_positions != null && openTrades.length !== Number(account.open_positions) && ( +

+ 仅本系统记录 {openTrades.length} 条,币安实际 {account.open_positions} 个 +

+ )}
限价入场: {entryTypeCounts.limit} 市价入场: {entryTypeCounts.market} diff --git a/trading_system/binance_client.py b/trading_system/binance_client.py index e1a2b60..ee328bd 100644 --- a/trading_system/binance_client.py +++ b/trading_system/binance_client.py @@ -776,16 +776,18 @@ class BinanceClient: self.client.futures_position_information(recvWindow=20000), timeout=read_timeout ) - # 只保留真实持仓:非零且名义价值 >= 1 USDT,避免灰尘持仓被当成“有仓”导致同步时批量创建假 manual_entry - min_notional = 1.0 + # 只保留非零持仓,且名义价值 >= 配置阈值,避免灰尘持仓被当成“有仓”;与仪表板不一致时可调低 POSITION_MIN_NOTIONAL_USDT 或设为 0 + min_notional = getattr(config, 'POSITION_MIN_NOTIONAL_USDT', 1.0) open_positions = [] + skipped_low = [] for pos in positions: amt = float(pos['positionAmt']) if amt == 0: continue entry_price = float(pos['entryPrice']) notional = abs(amt) * entry_price - if notional < min_notional: + if min_notional > 0 and notional < min_notional: + skipped_low.append((pos['symbol'], round(notional, 4))) continue open_positions.append({ 'symbol': pos['symbol'], @@ -795,6 +797,11 @@ class BinanceClient: 'unRealizedProfit': float(pos['unRealizedProfit']), 'leverage': int(pos['leverage']) }) + if skipped_low and logger.isEnabledFor(logging.DEBUG): + logger.debug( + f"获取持仓: 过滤掉 {len(skipped_low)} 个名义价值 < {min_notional} USDT 的仓位 {skipped_low}," + "与仪表板不一致时可设 POSITION_MIN_NOTIONAL_USDT=0 或更小" + ) return open_positions except (asyncio.TimeoutError, BinanceAPIException) as e: last_error = e diff --git a/trading_system/config.py b/trading_system/config.py index 7aa6d11..90f4a42 100644 --- a/trading_system/config.py +++ b/trading_system/config.py @@ -391,6 +391,8 @@ CONNECTION_TIMEOUT = int(os.getenv('CONNECTION_TIMEOUT', '30')) # 连接超时 CONNECTION_RETRIES = int(os.getenv('CONNECTION_RETRIES', '3')) # 连接重试次数 # 仅用于 get_open_positions / get_recent_trades 等只读接口的单次等待时间,不影响下单/止损止盈的快速失败 READ_ONLY_REQUEST_TIMEOUT = int(os.getenv('READ_ONLY_REQUEST_TIMEOUT', '60')) +# 获取持仓时过滤掉名义价值低于此值的仓位(USDT),与币安仪表板不一致时可调低或设为 0 +POSITION_MIN_NOTIONAL_USDT = float(os.getenv('POSITION_MIN_NOTIONAL_USDT', '1.0')) # Redis 缓存配置(优先从数据库,回退到环境变量和默认值) REDIS_URL = _get_config_value('REDIS_URL', os.getenv('REDIS_URL', 'redis://localhost:6379')) diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index 632cbb8..b282b6d 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -2951,22 +2951,28 @@ class PositionManager: logger.warning("客户端未初始化,无法启动实时监控") return - # 获取当前所有持仓 + # 获取当前所有持仓(与 sync 一致:仅本系统关心的持仓会进 active_positions) positions = await self.client.get_open_positions() binance_symbols = {p['symbol'] for p in positions} active_symbols = set(self.active_positions.keys()) + sync_create_manual = config.TRADING_CONFIG.get("SYNC_CREATE_MANUAL_ENTRY_RECORD", False) logger.info(f"币安持仓: {len(binance_symbols)} 个 ({', '.join(binance_symbols) if binance_symbols else '无'})") logger.info(f"本地持仓记录: {len(active_symbols)} 个 ({', '.join(active_symbols) if active_symbols else '无'})") - # 为所有币安持仓启动监控(即使不在active_positions中,可能是手动开仓的) + # 仅为本系统已有记录的持仓启动监控;若未开启「同步创建手动开仓记录」,则不为「仅币安有仓」创建临时记录或监控 + only_binance = binance_symbols - active_symbols + if only_binance and not sync_create_manual: + logger.info(f"跳过 {len(only_binance)} 个仅币安持仓的监控(SYNC_CREATE_MANUAL_ENTRY_RECORD=False): {', '.join(only_binance)}") + for position in positions: symbol = position['symbol'] if symbol not in self._monitor_tasks: - # 如果不在active_positions中,先创建记录 + # 若不在 active_positions 且未开启「同步创建手动开仓记录」,不创建临时记录、不为其启动监控 if symbol not in self.active_positions: + if not sync_create_manual: + continue logger.warning(f"{symbol} 在币安有持仓但不在本地记录中,可能是手动开仓,尝试创建记录...") - # 这里会通过sync_positions_with_binance来处理,但先启动监控 try: entry_price = position.get('entryPrice', 0) position_amt = position['positionAmt']