使用自定义订单号确保与币安一致

This commit is contained in:
薇薇安 2026-02-14 18:38:56 +08:00
parent a52b8c4738
commit 3d9f58f049
5 changed files with 185 additions and 9 deletions

View File

@ -1538,8 +1538,17 @@ async def open_position_from_recommendation(
raise HTTPException(status_code=500, detail=error_msg) 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") @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: try:
logger.info("=" * 60) logger.info("=" * 60)
@ -1750,11 +1759,21 @@ async def sync_positions(account_id: int = Depends(get_account_id)):
else: else:
logger.info("✓ 数据库与币安状态一致,无需更新") logger.info("✓ 数据库与币安状态一致,无需更新")
# 4. 币安有仓但数据库无记录:从币安成交里取 orderId 并补建交易记录,便于在「订单记录」和统计中展示(含系统挂单/条件单) # 4. 币安有仓但数据库无记录:优先用「开仓订单 clientOrderId 前缀」判断是否系统单,仅对系统单补建
missing_in_db = binance_symbols - db_open_symbols missing_in_db = binance_symbols - db_open_symbols
recovered_count = 0 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: if missing_in_db:
logger.info(f"发现 {len(missing_in_db)} 个持仓在币安存在但数据库中没有记录: {', '.join(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: for symbol in missing_in_db:
try: try:
pos = next((p for p in binance_positions if p.get('symbol') == symbol), None) 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') entry_order_id = same_side[0].get('orderId')
except Exception as e: except Exception as e:
logger.debug(f"获取 {symbol} 成交记录失败: {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'): if entry_order_id and hasattr(Trade, 'get_by_entry_order_id'):
try: try:
existing = Trade.get_by_entry_order_id(entry_order_id) existing = Trade.get_by_entry_order_id(entry_order_id)

View File

@ -60,6 +60,9 @@ USER_VISIBLE_DEFAULTS = {
"MAX_TOTAL_POSITION_PERCENT": {"value": 20.0, "type": "number", "category": "position", "description": "总仓位最大保证金占比(%"}, "MAX_TOTAL_POSITION_PERCENT": {"value": 20.0, "type": "number", "category": "position", "description": "总仓位最大保证金占比(%"},
"AUTO_TRADE_ENABLED": {"value": True, "type": "boolean", "category": "risk", "description": "自动交易总开关:关闭后仅生成推荐,不会自动下单"}, "AUTO_TRADE_ENABLED": {"value": True, "type": "boolean", "category": "risk", "description": "自动交易总开关:关闭后仅生成推荐,不会自动下单"},
"MAX_OPEN_POSITIONS": {"value": 3, "type": "number", "category": "position", "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": "每日最多开仓次数"}, "MAX_DAILY_ENTRIES": {"value": 8, "type": "number", "category": "risk", "description": "每日最多开仓次数"},
"TOP_N_SYMBOLS": {"value": 8, "type": "number", "category": "scan", "description": "每次扫描后优先处理的交易对数量"}, "TOP_N_SYMBOLS": {"value": 8, "type": "number", "category": "scan", "description": "每次扫描后优先处理的交易对数量"},
"MIN_SIGNAL_STRENGTH": {"value": 8, "type": "number", "category": "scan", "description": "最小信号强度0-10"}, "MIN_SIGNAL_STRENGTH": {"value": 8, "type": "number", "category": "scan", "description": "最小信号强度0-10"},

View File

@ -3,6 +3,7 @@
""" """
import asyncio import asyncio
import logging import logging
import random
import time import time
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
from binance import AsyncClient, BinanceSocketManager from binance import AsyncClient, BinanceSocketManager
@ -1429,6 +1430,13 @@ class BinanceClient:
if position_side: if position_side:
logger.info(f"{symbol} 单向模式下忽略 positionSide={position_side}(避免 -4061") 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 参数 # 如果是平仓订单,添加 reduceOnly 参数
# 根据币安API文档reduceOnly 应该是字符串 "true" 或 "false" # 根据币安API文档reduceOnly 应该是字符串 "true" 或 "false"
if reduce_only: if reduce_only:
@ -1470,6 +1478,11 @@ class BinanceClient:
# 让 python-binance 重新生成,否则会报 -1022 Signature invalid # 让 python-binance 重新生成,否则会报 -1022 Signature invalid
retry_params.pop('timestamp', None) retry_params.pop('timestamp', None)
retry_params.pop('signature', 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: if code == -4061:
logger.error(f"{symbol} 触发 -4061持仓模式不匹配尝试自动兜底重试一次") logger.error(f"{symbol} 触发 -4061持仓模式不匹配尝试自动兜底重试一次")

View File

@ -306,6 +306,10 @@ DEFAULT_TRADING_CONFIG = {
'ENTRY_MAX_DRIFT_PCT_RANGING': 0.3, # 震荡/弱趋势时允许的最大追价偏离 'ENTRY_MAX_DRIFT_PCT_RANGING': 0.3, # 震荡/弱趋势时允许的最大追价偏离
'LIMIT_ORDER_OFFSET_PCT': 0.5, # 限价单偏移百分比默认0.5% 'LIMIT_ORDER_OFFSET_PCT': 0.5, # 限价单偏移百分比默认0.5%
'MIN_HOLD_TIME_SEC': 0, # 默认30分钟1800秒已取消 'MIN_HOLD_TIME_SEC': 0, # 默认30分钟1800秒已取消
# ===== 系统单标识(用于同步时区分本系统开仓 vs 手动开仓)=====
# 下单时写入 newClientOrderId = SYSTEM_ORDER_ID_PREFIX_时间戳_随机同步/补建时根据订单 clientOrderId 前缀判断是否系统单
'SYSTEM_ORDER_ID_PREFIX': 'SYS',
} }
def _get_trading_config(): def _get_trading_config():

View File

@ -2780,7 +2780,6 @@ class PositionManager:
logger.info("✓ 持仓状态同步完成,数据库与币安状态一致") logger.info("✓ 持仓状态同步完成,数据库与币安状态一致")
# 5. 检查币安有但数据库没有记录的持仓(可能是本策略开仓后未正确落库、或其它来源) # 5. 检查币安有但数据库没有记录的持仓(可能是本策略开仓后未正确落库、或其它来源)
# 默认不再自动创建「手动开仓」记录,避免产生大量无 entry_order_id 的怪单(与币安实际订单对不上)
missing_in_db = binance_symbols - db_open_symbols missing_in_db = binance_symbols - db_open_symbols
if missing_in_db: if missing_in_db:
logger.info( logger.info(
@ -2788,13 +2787,119 @@ class PositionManager:
f"{', '.join(missing_in_db)}" f"{', '.join(missing_in_db)}"
) )
sync_create_manual = config.TRADING_CONFIG.get("SYNC_CREATE_MANUAL_ENTRY_RECORD", False) 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( logger.info(
" → 已跳过自动创建交易记录SYNC_CREATE_MANUAL_ENTRY_RECORD=False" " → 已跳过自动创建交易记录SYNC_CREATE_MANUAL_ENTRY_RECORD=False, SYNC_RECOVER_MISSING_POSITIONS 未开启)。"
" 若确认为本策略开仓可检查开仓时是否保存了 entry_order_id若为手动开仓且需纳入列表可设该配置为 True。" " 若确认为本策略开仓可开启 SYNC_RECOVER_MISSING_POSITIONS=True仅补建有止损止盈单的"
) )
else: elif sync_create_manual:
# 为手动开仓的持仓创建数据库记录并启动监控(仅当显式开启时) # 为手动开仓的持仓创建数据库记录并启动监控(仅当显式开启且未走上面的「补建系统单」时)
for symbol in missing_in_db: for symbol in missing_in_db:
try: try:
# 获取币安持仓详情 # 获取币安持仓详情