diff --git a/backend/api/routes/account.py b/backend/api/routes/account.py index 614ae56..d415885 100644 --- a/backend/api/routes/account.py +++ b/backend/api/routes/account.py @@ -1538,8 +1538,17 @@ async def open_position_from_recommendation( raise HTTPException(status_code=500, detail=error_msg) +def _order_is_sltp(o: dict, type_key: str = "type") -> bool: + """判断是否为止损/止盈类订单(含普通单与 Algo 条件单)""" + t = str(o.get(type_key) or o.get("orderType") or "").upper() + return t in ("STOP_MARKET", "TAKE_PROFIT_MARKET", "STOP", "TAKE_PROFIT") + + @router.post("/positions/sync") -async def sync_positions(account_id: int = Depends(get_account_id)): +async def sync_positions( + account_id: int = Depends(get_account_id), + only_recover_when_has_sltp: bool = Query(True, description="仅当该持仓存在止损/止盈单时才补建记录(用于区分系统单,减少手动单误建)"), +): """同步币安实际持仓状态与数据库状态""" try: logger.info("=" * 60) @@ -1750,11 +1759,21 @@ async def sync_positions(account_id: int = Depends(get_account_id)): else: logger.info("✓ 数据库与币安状态一致,无需更新") - # 4. 币安有仓但数据库无记录:从币安成交里取 orderId 并补建交易记录,便于在「订单记录」和统计中展示(含系统挂单/条件单) + # 4. 币安有仓但数据库无记录:优先用「开仓订单 clientOrderId 前缀」判断是否系统单,仅对系统单补建 missing_in_db = binance_symbols - db_open_symbols recovered_count = 0 + system_order_prefix = "" + try: + from database.models import TradingConfig + system_order_prefix = (TradingConfig.get_value("SYSTEM_ORDER_ID_PREFIX", "", account_id=account_id) or "").strip() + except Exception: + pass if missing_in_db: logger.info(f"发现 {len(missing_in_db)} 个持仓在币安存在但数据库中没有记录: {', '.join(missing_in_db)}") + if system_order_prefix: + logger.info(f" → 仅对开仓订单 clientOrderId 前缀为「{system_order_prefix}」的持仓补建(系统单标识)") + elif only_recover_when_has_sltp: + logger.info(" → 仅对「存在止损/止盈单」的持仓补建记录(视为系统单),避免手动单误建") for symbol in missing_in_db: try: pos = next((p for p in binance_positions if p.get('symbol') == symbol), None) @@ -1778,6 +1797,38 @@ async def sync_positions(account_id: int = Depends(get_account_id)): entry_order_id = same_side[0].get('orderId') except Exception as e: logger.debug(f"获取 {symbol} 成交记录失败: {e}") + if system_order_prefix: + if not entry_order_id: + logger.debug(f" {symbol} 无法获取开仓订单号,跳过补建") + continue + try: + order_info = await client.client.futures_get_order(symbol=symbol, orderId=int(entry_order_id), recvWindow=20000) + cid = (order_info or {}).get("clientOrderId") or "" + if not cid.startswith(system_order_prefix): + logger.debug(f" {symbol} 开仓订单 clientOrderId={cid!r} 非系统前缀,跳过补建") + continue + except Exception as e: + logger.debug(f" {symbol} 查询开仓订单失败: {e},跳过补建") + continue + elif only_recover_when_has_sltp: + has_sltp = False + try: + normal = await client.get_open_orders(symbol) + for o in (normal or []): + if _order_is_sltp(o, "type"): + has_sltp = True + break + if not has_sltp: + algo = await client.futures_get_open_algo_orders(symbol=symbol, algo_type="CONDITIONAL") + for o in (algo or []): + if _order_is_sltp(o, "orderType"): + has_sltp = True + break + except Exception as e: + logger.debug(f"检查 {symbol} 止盈止损单失败: {e}") + if not has_sltp: + logger.debug(f" {symbol} 无止损/止盈单,跳过补建(视为非系统单)") + continue if entry_order_id and hasattr(Trade, 'get_by_entry_order_id'): try: existing = Trade.get_by_entry_order_id(entry_order_id) diff --git a/backend/api/routes/config.py b/backend/api/routes/config.py index 886d248..16b4b05 100644 --- a/backend/api/routes/config.py +++ b/backend/api/routes/config.py @@ -60,6 +60,9 @@ USER_VISIBLE_DEFAULTS = { "MAX_TOTAL_POSITION_PERCENT": {"value": 20.0, "type": "number", "category": "position", "description": "总仓位最大保证金占比(%)"}, "AUTO_TRADE_ENABLED": {"value": True, "type": "boolean", "category": "risk", "description": "自动交易总开关:关闭后仅生成推荐,不会自动下单"}, "MAX_OPEN_POSITIONS": {"value": 3, "type": "number", "category": "position", "description": "同时持仓数量上限"}, + "SYNC_RECOVER_MISSING_POSITIONS": {"value": True, "type": "boolean", "category": "position", "description": "同步时补建「币安有仓、DB 无记录」的交易记录(便于订单记录与统计)"}, + "SYNC_RECOVER_ONLY_WHEN_HAS_SLTP": {"value": True, "type": "boolean", "category": "position", "description": "仅当该持仓存在止损/止盈单时才补建(未配置 SYSTEM_ORDER_ID_PREFIX 时生效)"}, + "SYSTEM_ORDER_ID_PREFIX": {"value": "SYS", "type": "string", "category": "position", "description": "系统单标识:下单时写入 newClientOrderId 前缀,同步时仅对「开仓订单 clientOrderId 以此前缀开头」的持仓补建;设空则用「是否有止损止盈单」判断"}, "MAX_DAILY_ENTRIES": {"value": 8, "type": "number", "category": "risk", "description": "每日最多开仓次数"}, "TOP_N_SYMBOLS": {"value": 8, "type": "number", "category": "scan", "description": "每次扫描后优先处理的交易对数量"}, "MIN_SIGNAL_STRENGTH": {"value": 8, "type": "number", "category": "scan", "description": "最小信号强度(0-10)"}, diff --git a/trading_system/binance_client.py b/trading_system/binance_client.py index ee328bd..db88143 100644 --- a/trading_system/binance_client.py +++ b/trading_system/binance_client.py @@ -3,6 +3,7 @@ """ import asyncio import logging +import random import time from typing import Dict, List, Optional, Any from binance import AsyncClient, BinanceSocketManager @@ -1429,6 +1430,13 @@ class BinanceClient: if position_side: logger.info(f"{symbol} 单向模式下忽略 positionSide={position_side}(避免 -4061)") + # 开仓单写入自定义订单号前缀,便于同步时根据 clientOrderId 区分系统单(仅本系统开的仓才补建记录) + if not reduce_only: + prefix = (config.TRADING_CONFIG.get('SYSTEM_ORDER_ID_PREFIX') or '').strip() + if prefix: + # 币安 newClientOrderId 最长 36 字符,格式: PREFIX_timestamp_hex + order_params['newClientOrderId'] = f"{prefix}_{int(time.time() * 1000)}_{random.randint(0, 0xFFFF):04x}"[:36] + # 如果是平仓订单,添加 reduceOnly 参数 # 根据币安API文档,reduceOnly 应该是字符串 "true" 或 "false" if reduce_only: @@ -1470,7 +1478,12 @@ class BinanceClient: # 让 python-binance 重新生成,否则会报 -1022 Signature invalid retry_params.pop('timestamp', None) retry_params.pop('signature', None) - + # 重试时生成新的 newClientOrderId,避免与首次提交冲突 + if 'newClientOrderId' in retry_params: + prefix = (config.TRADING_CONFIG.get('SYSTEM_ORDER_ID_PREFIX') or '').strip() + if prefix: + retry_params['newClientOrderId'] = f"{prefix}_{int(time.time() * 1000)}_{random.randint(0, 0xFFFF):04x}"[:36] + if code == -4061: logger.error(f"{symbol} 触发 -4061(持仓模式不匹配),尝试自动兜底重试一次") if "positionSide" in retry_params: diff --git a/trading_system/config.py b/trading_system/config.py index 90f4a42..c09e492 100644 --- a/trading_system/config.py +++ b/trading_system/config.py @@ -306,6 +306,10 @@ DEFAULT_TRADING_CONFIG = { 'ENTRY_MAX_DRIFT_PCT_RANGING': 0.3, # 震荡/弱趋势时允许的最大追价偏离 'LIMIT_ORDER_OFFSET_PCT': 0.5, # 限价单偏移百分比(默认0.5%) 'MIN_HOLD_TIME_SEC': 0, # 默认30分钟(1800秒),已取消 + + # ===== 系统单标识(用于同步时区分本系统开仓 vs 手动开仓)===== + # 下单时写入 newClientOrderId = SYSTEM_ORDER_ID_PREFIX_时间戳_随机,同步/补建时根据订单 clientOrderId 前缀判断是否系统单 + 'SYSTEM_ORDER_ID_PREFIX': 'SYS', } def _get_trading_config(): diff --git a/trading_system/position_manager.py b/trading_system/position_manager.py index 1899b8b..806b65a 100644 --- a/trading_system/position_manager.py +++ b/trading_system/position_manager.py @@ -2780,7 +2780,6 @@ class PositionManager: logger.info("✓ 持仓状态同步完成,数据库与币安状态一致") # 5. 检查币安有但数据库没有记录的持仓(可能是本策略开仓后未正确落库、或其它来源) - # 默认不再自动创建「手动开仓」记录,避免产生大量无 entry_order_id 的怪单(与币安实际订单对不上) missing_in_db = binance_symbols - db_open_symbols if missing_in_db: logger.info( @@ -2788,13 +2787,119 @@ class PositionManager: f"{', '.join(missing_in_db)}" ) sync_create_manual = config.TRADING_CONFIG.get("SYNC_CREATE_MANUAL_ENTRY_RECORD", False) - if not sync_create_manual: + sync_recover = config.TRADING_CONFIG.get("SYNC_RECOVER_MISSING_POSITIONS", True) + sync_recover_only_has_sltp = config.TRADING_CONFIG.get("SYNC_RECOVER_ONLY_WHEN_HAS_SLTP", True) + + def _order_is_sltp(o, type_key="type"): + t = str(o.get(type_key) or o.get("orderType") or "").upper() + return t in ("STOP_MARKET", "TAKE_PROFIT_MARKET", "STOP", "TAKE_PROFIT") + + async def _symbol_has_sltp(sym): + try: + for o in (await self.client.get_open_orders(sym)) or []: + if _order_is_sltp(o, "type"): + return True + for o in (await self.client.futures_get_open_algo_orders(sym, algo_type="CONDITIONAL")) or []: + if _order_is_sltp(o, "orderType"): + return True + except Exception: + pass + return False + + if sync_recover: + system_order_prefix = (config.TRADING_CONFIG.get("SYSTEM_ORDER_ID_PREFIX") or "").strip() + if system_order_prefix: + logger.info(f" → 补建「系统单」记录(仅当开仓订单 clientOrderId 前缀为 {system_order_prefix!r} 时创建)") + else: + logger.info(" → 补建「系统单」记录(仅当存在止损/止盈单时创建,避免手动单误建)" if sync_recover_only_has_sltp else " → 补建缺失持仓记录") + for symbol in missing_in_db: + try: + binance_position = next((p for p in binance_positions if p.get("symbol") == symbol), None) + if not binance_position or float(binance_position.get("positionAmt", 0)) == 0: + continue + position_amt = float(binance_position["positionAmt"]) + quantity = abs(position_amt) + side = "BUY" if position_amt > 0 else "SELL" + entry_price = float(binance_position.get("entryPrice", 0)) + notional = quantity * entry_price + if notional < 1.0: + continue + entry_order_id = None + try: + recent = await self.client.get_recent_trades(symbol, limit=30) + if recent: + same_side = [t for t in recent if str(t.get("side", "")).upper() == side] + if same_side: + same_side.sort(key=lambda x: int(x.get("time", 0)), reverse=True) + entry_order_id = same_side[0].get("orderId") + except Exception: + pass + if system_order_prefix: + if not entry_order_id: + logger.debug(f" {symbol} 无法获取开仓订单号,跳过补建") + continue + try: + order_info = await self.client.client.futures_get_order(symbol=symbol, orderId=int(entry_order_id), recvWindow=20000) + cid = (order_info or {}).get("clientOrderId") or "" + if not cid.startswith(system_order_prefix): + logger.debug(f" {symbol} 开仓订单 clientOrderId={cid!r} 非系统前缀,跳过补建") + continue + except Exception as e: + logger.debug(f" {symbol} 查询开仓订单失败: {e},跳过补建") + continue + elif sync_recover_only_has_sltp and not (await _symbol_has_sltp(symbol)): + logger.debug(f" {symbol} 无止损/止盈单,跳过补建") + continue + if entry_order_id and hasattr(Trade, "get_by_entry_order_id"): + try: + if Trade.get_by_entry_order_id(entry_order_id): + continue + except Exception: + pass + trade_id = Trade.create( + symbol=symbol, + side=side, + quantity=quantity, + entry_price=entry_price, + leverage=binance_position.get("leverage", 10), + entry_reason="sync_recovered", + entry_order_id=entry_order_id, + notional_usdt=notional, + margin_usdt=(notional / float(binance_position.get("leverage", 10) or 10)) if float(binance_position.get("leverage", 10) or 0) > 0 else None, + account_id=self.account_id, + ) + logger.info(f" ✓ {symbol} [状态同步] 已补建交易记录 (ID: {trade_id}, orderId: {entry_order_id or '-'})") + ticker = await self.client.get_ticker_24h(symbol) + current_price = ticker["price"] if ticker else entry_price + lev = float(binance_position.get("leverage", 10)) + stop_loss_pct = config.TRADING_CONFIG.get("STOP_LOSS_PERCENT", 0.08) + if stop_loss_pct is not None and stop_loss_pct > 1: + stop_loss_pct = stop_loss_pct / 100.0 + take_profit_pct = config.TRADING_CONFIG.get("TAKE_PROFIT_PERCENT", 0.15) + if take_profit_pct is not None and take_profit_pct > 1: + take_profit_pct = take_profit_pct / 100.0 + if not take_profit_pct: + take_profit_pct = (stop_loss_pct or 0.08) * 2.0 + stop_loss_price = self.risk_manager.get_stop_loss_price(entry_price, side, quantity, lev, stop_loss_pct=stop_loss_pct) + take_profit_price = self.risk_manager.get_take_profit_price(entry_price, side, quantity, lev, take_profit_pct=take_profit_pct) + position_info = { + "symbol": symbol, "side": side, "quantity": quantity, "entryPrice": entry_price, + "changePercent": 0, "orderId": entry_order_id, "tradeId": trade_id, + "stopLoss": stop_loss_price, "takeProfit": take_profit_price, "initialStopLoss": stop_loss_price, + "leverage": lev, "entryReason": "sync_recovered", "atr": None, "maxProfit": 0.0, "trailingStopActivated": False, + } + self.active_positions[symbol] = position_info + if self._monitoring_enabled: + await self._start_position_monitoring(symbol) + except Exception as e: + logger.warning(f" ✗ {symbol} [状态同步] 补建失败: {e}") + elif not sync_create_manual: logger.info( - " → 已跳过自动创建交易记录(SYNC_CREATE_MANUAL_ENTRY_RECORD=False)。" - " 若确认为本策略开仓可检查开仓时是否保存了 entry_order_id;若为手动开仓且需纳入列表可设该配置为 True。" + " → 已跳过自动创建交易记录(SYNC_CREATE_MANUAL_ENTRY_RECORD=False, SYNC_RECOVER_MISSING_POSITIONS 未开启)。" + " 若确认为本策略开仓可开启 SYNC_RECOVER_MISSING_POSITIONS=True(仅补建有止损止盈单的)。" ) - else: - # 为手动开仓的持仓创建数据库记录并启动监控(仅当显式开启时) + elif sync_create_manual: + # 为手动开仓的持仓创建数据库记录并启动监控(仅当显式开启且未走上面的「补建系统单」时) for symbol in missing_in_db: try: # 获取币安持仓详情