Updated the position monitoring logic to handle cases where positions exist on Binance without corresponding stop-loss or take-profit orders. Enhanced logging to provide clearer insights into the status of these positions, ensuring better risk management by avoiding unprotected positions. This change allows for automatic monitoring and order creation based on the presence of SL/TP orders.
4983 lines
316 KiB
Python
4983 lines
316 KiB
Python
"""
|
||
仓位管理模块 - 管理持仓和订单
|
||
"""
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import random
|
||
import time
|
||
import aiohttp
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional
|
||
from datetime import datetime
|
||
try:
|
||
from .binance_client import BinanceClient, AlgoOrderPositionUnavailableError
|
||
from .risk_manager import RiskManager
|
||
from . import config
|
||
except ImportError:
|
||
from binance_client import BinanceClient, AlgoOrderPositionUnavailableError
|
||
from risk_manager import RiskManager
|
||
import config
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 尝试导入数据库模型(如果可用)
|
||
DB_AVAILABLE = False
|
||
Trade = None
|
||
get_beijing_time = None
|
||
try:
|
||
import sys
|
||
from pathlib import Path
|
||
project_root = Path(__file__).parent.parent
|
||
backend_path = project_root / 'backend'
|
||
if backend_path.exists():
|
||
sys.path.insert(0, str(backend_path))
|
||
from database.models import Trade, get_beijing_time
|
||
DB_AVAILABLE = True
|
||
logger.info("✓ 数据库模型导入成功,交易记录将保存到数据库")
|
||
else:
|
||
logger.warning("⚠ backend目录不存在,无法使用数据库功能")
|
||
DB_AVAILABLE = False
|
||
except ImportError as e:
|
||
logger.warning(f"⚠ 无法导入数据库模型: {e}")
|
||
logger.warning(" 交易记录将不会保存到数据库")
|
||
DB_AVAILABLE = False
|
||
except Exception as e:
|
||
logger.warning(f"⚠ 数据库初始化失败: {e}")
|
||
logger.warning(" 交易记录将不会保存到数据库")
|
||
DB_AVAILABLE = False
|
||
|
||
# 如果没有导入get_beijing_time,创建一个本地版本
|
||
if get_beijing_time is None:
|
||
from datetime import datetime, timezone, timedelta
|
||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||
def get_beijing_time():
|
||
"""获取当前北京时间(UTC+8)"""
|
||
return datetime.now(BEIJING_TZ).replace(tzinfo=None)
|
||
|
||
# 落库失败独立日志:币安已成交但未写入 DB 时写入此文件,便于排查与对账(不依赖 DB/Redis)
|
||
_FAILURE_LOG_DIR = None
|
||
_FAILURE_LOG_PATH = None
|
||
def _get_db_failure_log_path():
|
||
global _FAILURE_LOG_DIR, _FAILURE_LOG_PATH
|
||
if _FAILURE_LOG_PATH is not None:
|
||
return _FAILURE_LOG_PATH
|
||
try:
|
||
root = Path(__file__).parent.parent
|
||
_FAILURE_LOG_DIR = root / "logs"
|
||
_FAILURE_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||
_FAILURE_LOG_PATH = _FAILURE_LOG_DIR / "trade_db_failures.log"
|
||
return _FAILURE_LOG_PATH
|
||
except Exception:
|
||
return None
|
||
|
||
def _log_trade_db_failure(symbol, entry_order_id, side, quantity, entry_price, account_id, reason, error_type=None, error_message=None):
|
||
"""将「成交但落库失败」记录到独立日志文件(一行 JSON),便于 grep/脚本统计,不依赖 DB/Redis。"""
|
||
path = _get_db_failure_log_path()
|
||
if not path:
|
||
return
|
||
try:
|
||
rec = {
|
||
"ts": datetime.utcnow().isoformat() + "Z",
|
||
"symbol": symbol,
|
||
"entry_order_id": entry_order_id,
|
||
"side": side,
|
||
"quantity": quantity,
|
||
"entry_price": entry_price,
|
||
"account_id": account_id,
|
||
"reason": reason,
|
||
}
|
||
if error_type:
|
||
rec["error_type"] = error_type
|
||
if error_message:
|
||
rec["error_message"] = (str(error_message) or "")[:500]
|
||
with open(path, "a", encoding="utf-8") as f:
|
||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||
except Exception as e:
|
||
logger.warning(f"写入落库失败日志失败: {e}")
|
||
|
||
|
||
_TRAILING_LOG_PATH: Optional[Path] = None
|
||
|
||
def _get_trailing_stop_log_path() -> Optional[Path]:
|
||
"""保本/移动止损专用日志路径:logs/profit_protection.log,便于 tail/grep 确认是否执行。"""
|
||
global _TRAILING_LOG_PATH
|
||
if _TRAILING_LOG_PATH is not None:
|
||
return _TRAILING_LOG_PATH
|
||
try:
|
||
root = Path(__file__).parent.parent
|
||
log_dir = root / "logs"
|
||
log_dir.mkdir(parents=True, exist_ok=True)
|
||
_TRAILING_LOG_PATH = log_dir / "profit_protection.log"
|
||
return _TRAILING_LOG_PATH
|
||
except Exception:
|
||
return None
|
||
|
||
def _log_trailing_stop_event(account_id: int, symbol: str, event: str, **kwargs):
|
||
"""将保本/移动止损相关事件写入专用日志(一行 JSON),便于确认是否正常执行。"""
|
||
path = _get_trailing_stop_log_path()
|
||
if not path:
|
||
return
|
||
try:
|
||
rec = {
|
||
"ts": datetime.utcnow().isoformat() + "Z",
|
||
"account_id": account_id,
|
||
"symbol": symbol,
|
||
"event": event,
|
||
}
|
||
for k, v in kwargs.items():
|
||
if v is not None and k not in rec:
|
||
if isinstance(v, float):
|
||
rec[k] = round(v, 6)
|
||
else:
|
||
rec[k] = v
|
||
with open(path, "a", encoding="utf-8") as f:
|
||
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||
except Exception as e:
|
||
logger.debug(f"写入保本/移动止损日志失败: {e}")
|
||
|
||
|
||
# User Data Stream 缓存(持仓/余额优先读 WS,减少 REST)
|
||
try:
|
||
from .user_data_stream import get_stream_instance, get_positions_from_cache, get_balance_from_cache
|
||
except Exception:
|
||
try:
|
||
from user_data_stream import get_stream_instance, get_positions_from_cache, get_balance_from_cache
|
||
except Exception:
|
||
get_stream_instance = lambda: None
|
||
get_positions_from_cache = lambda min_notional=1.0: []
|
||
get_balance_from_cache = lambda: None
|
||
|
||
|
||
class PositionManager:
|
||
"""仓位管理类"""
|
||
|
||
def __init__(self, client: BinanceClient, risk_manager: RiskManager, account_id: int = None):
|
||
"""
|
||
初始化仓位管理器
|
||
|
||
Args:
|
||
client: 币安客户端
|
||
risk_manager: 风险管理器
|
||
account_id: 账户ID(默认从环境变量或配置获取)
|
||
"""
|
||
self.client = client
|
||
self.risk_manager = risk_manager
|
||
|
||
# 确定 account_id
|
||
if account_id is not None:
|
||
self.account_id = int(account_id)
|
||
else:
|
||
# 尝试从环境变量获取
|
||
import os
|
||
try:
|
||
self.account_id = int(os.getenv("ATS_ACCOUNT_ID") or os.getenv("ACCOUNT_ID") or 1)
|
||
except:
|
||
self.account_id = 1
|
||
|
||
self.active_positions: Dict[str, Dict] = {}
|
||
self._monitor_tasks: Dict[str, asyncio.Task] = {} # WebSocket监控任务字典
|
||
self._monitoring_enabled = True # 是否启用实时监控
|
||
self._pending_entry_orders: Dict[str, Dict] = {} # 未成交的入场订单(避免重复挂单)
|
||
self._last_entry_attempt_ms: Dict[str, int] = {} # 每个symbol的最近一次尝试(冷却/去抖)
|
||
# 自动平仓去抖/限流(避免止损触发后反复下单/刷屏)
|
||
self._last_auto_close_attempt_ms: Dict[str, int] = {}
|
||
self._last_auto_close_fail_log_ms: Dict[str, int] = {}
|
||
|
||
async def _get_open_positions(self, force_rest: bool = False) -> List[Dict]:
|
||
"""优先使用 User Data Stream 持仓缓存(Redis),无缓存或未启动时走 REST。多账号时必须传 account_id 读对应缓存。"""
|
||
if not force_rest and get_stream_instance() is not None:
|
||
min_notional = float(getattr(config, "POSITION_MIN_NOTIONAL_USDT", 1.0) or 1.0)
|
||
redis_cache = getattr(self.client, "redis_cache", None)
|
||
cached = await get_positions_from_cache(min_notional, redis_cache, account_id=self.account_id)
|
||
if cached is not None:
|
||
return cached
|
||
return await self.client.get_open_positions()
|
||
|
||
async def _get_account_balance(self) -> Dict:
|
||
"""优先使用 User Data Stream 余额缓存(Redis),无缓存时走 REST。"""
|
||
if get_stream_instance() is not None:
|
||
redis_cache = getattr(self.client, "redis_cache", None)
|
||
bal = await get_balance_from_cache(redis_cache)
|
||
if bal is not None:
|
||
return bal
|
||
return await self.client.get_account_balance()
|
||
|
||
@staticmethod
|
||
def _pct_like_to_ratio(v: float) -> float:
|
||
"""
|
||
将“看起来像百分比”的值转换为比例(0~1)。
|
||
|
||
兼容两种来源:
|
||
- 前端/后端按“比例”存储:0.006 表示 0.6%
|
||
- 历史/默认值按“百分比数值”存储:0.6 表示 0.6%
|
||
|
||
经验规则:
|
||
- v > 1: 认为是 60/100 这种百分数,除以100
|
||
- 0.05 < v <= 1: 也更可能是“0.6% 这种写法”,除以100
|
||
- v <= 0.05: 更可能已经是比例(<=5%)
|
||
"""
|
||
try:
|
||
x = float(v or 0.0)
|
||
except Exception:
|
||
x = 0.0
|
||
if x <= 0:
|
||
return 0.0
|
||
if x > 1.0:
|
||
return x / 100.0
|
||
if x > 0.05:
|
||
return x / 100.0
|
||
return x
|
||
|
||
def _calc_limit_entry_price(self, current_price: float, side: str, offset_ratio: float) -> float:
|
||
"""根据当前价与偏移比例,计算限价入场价(BUY: 下方回调;SELL: 上方回调)"""
|
||
try:
|
||
cp = float(current_price)
|
||
except Exception:
|
||
cp = 0.0
|
||
try:
|
||
off = float(offset_ratio or 0.0)
|
||
except Exception:
|
||
off = 0.0
|
||
if cp <= 0:
|
||
return 0.0
|
||
if (side or "").upper() == "BUY":
|
||
return cp * (1 - off)
|
||
return cp * (1 + off)
|
||
|
||
async def _wait_for_order_filled(
|
||
self,
|
||
symbol: str,
|
||
order_id: int,
|
||
timeout_sec: int = 30,
|
||
poll_sec: float = 1.0,
|
||
) -> Dict:
|
||
"""
|
||
等待订单成交(FILLED),返回:
|
||
{ ok, status, avg_price, executed_qty, raw }
|
||
"""
|
||
deadline = time.time() + max(1, int(timeout_sec or 1))
|
||
last_status = None
|
||
while time.time() < deadline:
|
||
try:
|
||
info = await self.client.client.futures_get_order(symbol=symbol, orderId=order_id, recvWindow=20000)
|
||
status = info.get("status")
|
||
last_status = status
|
||
if status == "FILLED":
|
||
avg_price = float(info.get("avgPrice", 0) or 0) or float(info.get("price", 0) or 0)
|
||
executed_qty = float(info.get("executedQty", 0) or 0)
|
||
return {"ok": True, "status": status, "avg_price": avg_price, "executed_qty": executed_qty, "raw": info}
|
||
if status in ("CANCELED", "REJECTED", "EXPIRED"):
|
||
return {"ok": False, "status": status, "avg_price": 0, "executed_qty": float(info.get("executedQty", 0) or 0), "raw": info}
|
||
except Exception:
|
||
# 忽略单次失败,继续轮询
|
||
pass
|
||
await asyncio.sleep(max(0.2, float(poll_sec or 1.0)))
|
||
return {"ok": False, "status": last_status or "TIMEOUT", "avg_price": 0, "executed_qty": 0, "raw": None}
|
||
|
||
async def open_position(
|
||
self,
|
||
symbol: str,
|
||
change_percent: float,
|
||
leverage: int = 10,
|
||
trade_direction: Optional[str] = None,
|
||
entry_reason: str = '',
|
||
signal_strength: Optional[int] = None,
|
||
market_regime: Optional[str] = None,
|
||
trend_4h: Optional[str] = None,
|
||
atr: Optional[float] = None,
|
||
klines: Optional[List] = None,
|
||
bollinger: Optional[Dict] = None,
|
||
entry_context: Optional[Dict] = None,
|
||
) -> Optional[Dict]:
|
||
"""
|
||
开仓
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
change_percent: 涨跌幅百分比
|
||
leverage: 杠杆倍数
|
||
|
||
Returns:
|
||
订单信息,失败返回None
|
||
"""
|
||
try:
|
||
# 0) 防止同一 symbol 重复挂入场单/快速重复尝试(去抖 + 冷却)
|
||
now_ms = int(time.time() * 1000)
|
||
|
||
# ⚠️ 关键修复:检查是否已经在持仓中(防止重复开仓)
|
||
if symbol in self.active_positions:
|
||
logger.info(f"{symbol} [入场] 已在持仓中 (active_positions),跳过重复开仓")
|
||
return None
|
||
|
||
cooldown_sec = int(config.TRADING_CONFIG.get("ENTRY_SYMBOL_COOLDOWN_SEC", 120) or 0)
|
||
last_ms = self._last_entry_attempt_ms.get(symbol)
|
||
if last_ms and cooldown_sec > 0 and now_ms - last_ms < cooldown_sec * 1000:
|
||
logger.info(f"{symbol} [入场] 冷却中({cooldown_sec}s),跳过本次自动开仓")
|
||
return None
|
||
|
||
pending = self._pending_entry_orders.get(symbol)
|
||
if pending and pending.get("order_id"):
|
||
logger.info(f"{symbol} [入场] 已有未完成入场订单(orderId={pending.get('order_id')}),跳过重复开仓")
|
||
return None
|
||
|
||
# 标记本次尝试(无论最终是否成交,都避免短时间内反复开仓/挂单)
|
||
self._last_entry_attempt_ms[symbol] = now_ms
|
||
|
||
# 判断是否应该交易
|
||
if not await self.risk_manager.should_trade(symbol, change_percent):
|
||
return None
|
||
|
||
# 设置杠杆(确保为 int,避免动态杠杆传入 float 导致 API/range 报错)
|
||
actual_leverage = await self.client.set_leverage(symbol, int(leverage))
|
||
|
||
if actual_leverage <= 0:
|
||
logger.error(f"{symbol} 无法设置有效杠杆,跳过开仓")
|
||
return None
|
||
|
||
# 使用实际生效的杠杆(可能被降级)
|
||
if actual_leverage != int(leverage):
|
||
logger.warning(f"{symbol} 杠杆被调整: {int(leverage)}x -> {actual_leverage}x")
|
||
leverage = actual_leverage
|
||
|
||
# 计算仓位大小(传入实际使用的杠杆)
|
||
# ⚠️ 优化:先估算止损价格,用于固定风险百分比计算
|
||
logger.info(f"开始为 {symbol} 计算仓位大小...")
|
||
|
||
# 获取当前价格用于估算止损
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
if not ticker:
|
||
return None
|
||
estimated_entry_price = ticker['price']
|
||
estimated_side = trade_direction if trade_direction else ('BUY' if change_percent > 0 else 'SELL')
|
||
|
||
# ===== 趋势入场过滤(防止在趋势尾部追价)=====
|
||
try:
|
||
use_trend_filter = bool(config.TRADING_CONFIG.get("USE_TREND_ENTRY_FILTER", False))
|
||
if use_trend_filter and getattr(self.client, "redis_cache", None):
|
||
trend_state_key = f"trend_state:{symbol}"
|
||
trend_state = await self.client.redis_cache.get(trend_state_key)
|
||
|
||
if trend_state:
|
||
trend_dir = (trend_state.get("direction") or "").upper()
|
||
signal_price = float(trend_state.get("signal_price") or 0) or None
|
||
|
||
if signal_price and trend_dir in ("BUY", "SELL"):
|
||
# 使用更实时的价格(如有),否则使用估算价
|
||
realtime_price = None
|
||
try:
|
||
realtime_price = self.client.get_realtime_price(symbol)
|
||
except Exception:
|
||
realtime_price = None
|
||
current_price = float(realtime_price or estimated_entry_price)
|
||
|
||
max_move = float(config.TRADING_CONFIG.get("MAX_TREND_MOVE_BEFORE_ENTRY", 0.05) or 0.05)
|
||
|
||
too_late = False
|
||
move_pct = 0.0
|
||
|
||
if trend_dir == "BUY":
|
||
# 做多:如果当前价相比信号价已经上涨超过阈值,认为追在高位
|
||
if current_price > signal_price:
|
||
move_pct = (current_price - signal_price) / signal_price
|
||
if move_pct > max_move:
|
||
too_late = True
|
||
elif trend_dir == "SELL":
|
||
# 做空:如果当前价相比信号价已经下跌超过阈值,认为追在低位
|
||
if current_price < signal_price:
|
||
move_pct = (signal_price - current_price) / signal_price
|
||
if move_pct > max_move:
|
||
too_late = True
|
||
|
||
# 只有当当前入场方向与趋势方向一致时,才应用“太晚不追”规则
|
||
if estimated_side.upper() == trend_dir and too_late:
|
||
logger.info(
|
||
f"{symbol} [入场过滤] 趋势方向={trend_dir}, 信号价={signal_price:.6f}, "
|
||
f"当前价={current_price:.6f}, 累计趋势幅度={move_pct*100:.2f}%>允许上限={max_move*100:.2f}%,"
|
||
f"认为本轮趋势入场时机已错过,跳过自动开仓"
|
||
)
|
||
return None
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 趋势入场过滤时出错(忽略,按正常逻辑继续): {e}")
|
||
|
||
# 估算止损价格(用于固定风险计算)
|
||
estimated_stop_loss = None
|
||
if atr and atr > 0:
|
||
atr_multiplier = config.TRADING_CONFIG.get('ATR_STOP_LOSS_MULTIPLIER', 2.5) # 默认2.5,放宽止损提升胜率
|
||
if estimated_side == 'BUY':
|
||
estimated_stop_loss = estimated_entry_price - (atr * atr_multiplier)
|
||
else: # SELL
|
||
estimated_stop_loss = estimated_entry_price + (atr * atr_multiplier)
|
||
|
||
quantity, adjusted_leverage = await self.risk_manager.calculate_position_size(
|
||
symbol, change_percent, leverage=leverage,
|
||
entry_price=estimated_entry_price,
|
||
stop_loss_price=estimated_stop_loss,
|
||
side=estimated_side,
|
||
atr=atr,
|
||
signal_strength=signal_strength
|
||
)
|
||
|
||
# 如果 RiskManager 建议了更低的安全杠杆,则应用它
|
||
if quantity is not None and adjusted_leverage is not None and adjusted_leverage != leverage:
|
||
logger.info(f"{symbol} 风险控制调整杠杆: {leverage}x -> {adjusted_leverage}x (适应宽止损)")
|
||
try:
|
||
new_lev = await self.client.set_leverage(symbol, int(adjusted_leverage))
|
||
if new_lev > 0:
|
||
leverage = new_lev
|
||
else:
|
||
logger.warning(f"{symbol} 调整杠杆失败,保持 {leverage}x")
|
||
except Exception as e:
|
||
logger.error(f"{symbol} 调整杠杆失败: {e},将使用原杠杆 {leverage}x 继续")
|
||
|
||
if quantity is None:
|
||
logger.warning(f"❌ {symbol} 仓位计算失败,跳过交易")
|
||
logger.warning(f" 可能原因:")
|
||
logger.warning(f" 1. 账户余额不足")
|
||
logger.warning(f" 2. 单笔仓位超过限制")
|
||
logger.warning(f" 3. 总仓位超过限制")
|
||
logger.warning(f" 4. 无法获取价格数据")
|
||
logger.warning(f" 5. 保证金不足最小要求(MIN_MARGIN_USDT)")
|
||
logger.warning(f" 6. 名义价值小于0.2 USDT(避免无意义的小单子)")
|
||
return None
|
||
|
||
logger.info(f"✓ {symbol} 仓位计算成功: {quantity:.4f}")
|
||
|
||
# 确定交易方向(优先使用技术指标信号)
|
||
if trade_direction:
|
||
side = trade_direction
|
||
else:
|
||
side = 'BUY' if change_percent > 0 else 'SELL'
|
||
|
||
# 获取当前价格
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
if not ticker:
|
||
return None
|
||
|
||
entry_price = ticker['price']
|
||
|
||
# 获取K线数据用于动态止损计算(从symbol_info中获取,如果可用)
|
||
klines = None
|
||
bollinger = None
|
||
if 'klines' in locals() or 'symbol_info' in locals():
|
||
# 尝试从外部传入的symbol_info获取
|
||
pass
|
||
|
||
# 计算基于支撑/阻力的动态止损
|
||
# 优先使用技术结构(支撑/阻力位、布林带)
|
||
# 如果无法获取K线数据,回退到ATR或固定止损
|
||
if not klines:
|
||
# 如果没有传入K线数据,尝试获取
|
||
try:
|
||
primary_interval = config.TRADING_CONFIG.get('PRIMARY_INTERVAL', '1h')
|
||
klines_data = await self.client.get_klines(
|
||
symbol=symbol,
|
||
interval=primary_interval,
|
||
limit=20 # 获取最近20根K线用于计算支撑/阻力
|
||
)
|
||
klines = klines_data if len(klines_data) >= 10 else None
|
||
except Exception as e:
|
||
logger.debug(f"获取K线数据失败,使用固定止损: {e}")
|
||
klines = None
|
||
|
||
# 在开仓前从Redis重新加载配置,确保使用最新配置(包括ATR参数)
|
||
# 从Redis读取最新配置(轻量级,即时生效)
|
||
try:
|
||
if config._config_manager:
|
||
config._config_manager.reload_from_redis()
|
||
config.TRADING_CONFIG = config._get_trading_config()
|
||
logger.debug(f"{symbol} 开仓前已从Redis重新加载配置")
|
||
except Exception as e:
|
||
logger.debug(f"从Redis重新加载配置失败: {e}")
|
||
|
||
# ===== 智能入场(方案C:趋势强更少错过,震荡更保守)=====
|
||
smart_entry_enabled = bool(config.TRADING_CONFIG.get("SMART_ENTRY_ENABLED", True))
|
||
# LIMIT_ORDER_OFFSET_PCT:兼容“比例/百分比”两种存储方式
|
||
limit_offset_ratio = self._pct_like_to_ratio(float(config.TRADING_CONFIG.get("LIMIT_ORDER_OFFSET_PCT", 0.5) or 0.0))
|
||
|
||
# 规则:趋势(trending) 或 4H共振明显 + 强信号 -> 允许“超时后市价兜底(有追价上限)”
|
||
mr = (market_regime or "").strip().lower() if market_regime else ""
|
||
t4 = (trend_4h or "").strip().lower() if trend_4h else ""
|
||
ss = int(signal_strength) if signal_strength is not None else 0
|
||
|
||
allow_market_fallback = False
|
||
if mr == "trending":
|
||
allow_market_fallback = True
|
||
if ss >= int(config.TRADING_CONFIG.get("SMART_ENTRY_STRONG_SIGNAL", 8) or 8) and t4 in ("up", "down"):
|
||
allow_market_fallback = True
|
||
|
||
# 追价上限:超过就不追,宁愿错过(避免回到“无脑追价高频打损”)
|
||
drift_ratio_trending = self._pct_like_to_ratio(float(config.TRADING_CONFIG.get("ENTRY_MAX_DRIFT_PCT_TRENDING", 0.6) or 0.6))
|
||
drift_ratio_ranging = self._pct_like_to_ratio(float(config.TRADING_CONFIG.get("ENTRY_MAX_DRIFT_PCT_RANGING", 0.3) or 0.3))
|
||
max_drift_ratio = drift_ratio_trending if allow_market_fallback else drift_ratio_ranging
|
||
|
||
# 总等待/追价参数
|
||
timeout_sec = int(config.TRADING_CONFIG.get("ENTRY_TIMEOUT_SEC", 180) or 180)
|
||
step_wait_sec = int(config.TRADING_CONFIG.get("ENTRY_STEP_WAIT_SEC", 15) or 15)
|
||
chase_steps = int(config.TRADING_CONFIG.get("ENTRY_CHASE_MAX_STEPS", 4) or 4)
|
||
market_fallback_after_sec = int(config.TRADING_CONFIG.get("ENTRY_MARKET_FALLBACK_AFTER_SEC", 45) or 45)
|
||
|
||
# 初始限价(回调入场)
|
||
current_px = float(entry_price)
|
||
initial_limit = self._calc_limit_entry_price(current_px, side, limit_offset_ratio)
|
||
if initial_limit <= 0:
|
||
return None
|
||
|
||
order = None
|
||
entry_order_id = None
|
||
order_status = None
|
||
actual_entry_price = None
|
||
filled_quantity = 0.0
|
||
entry_mode_used = "limit-only" if not smart_entry_enabled else ("limit+fallback" if allow_market_fallback else "limit-chase")
|
||
|
||
# 生成 client_order_id:先落库 pending,WS/对账 按 client_order_id 匹配完善,确保限价/条件单事后成交也能对应 DB
|
||
prefix = (config.TRADING_CONFIG.get("SYSTEM_ORDER_ID_PREFIX") or "").strip()
|
||
prefix = prefix or "ats_" # 无配置时用默认前缀,保证系统单始终有 pending 可对账
|
||
client_order_id = f"{prefix}_{int(time.time() * 1000)}_{random.randint(0, 0xFFFF):04x}"[:36]
|
||
pending_trade_id = None
|
||
if DB_AVAILABLE and Trade:
|
||
try:
|
||
logger.info(f"[DB] {symbol} 写入 pending 记录 client_order_id={client_order_id!r} (保证与下单一致性)")
|
||
pending_trade_id = Trade.create(
|
||
symbol=symbol,
|
||
side=side,
|
||
quantity=quantity,
|
||
entry_price=entry_price,
|
||
leverage=leverage,
|
||
entry_reason=entry_reason,
|
||
entry_order_id=None,
|
||
client_order_id=client_order_id,
|
||
stop_loss_price=None,
|
||
take_profit_price=None,
|
||
take_profit_1=None,
|
||
take_profit_2=None,
|
||
atr=atr,
|
||
notional_usdt=None,
|
||
margin_usdt=None,
|
||
account_id=self.account_id,
|
||
entry_context=entry_context,
|
||
status="pending",
|
||
)
|
||
logger.info(f"[DB] {symbol} pending 已落库 id={pending_trade_id} client_order_id={client_order_id!r}")
|
||
except Exception as e:
|
||
logger.error(
|
||
f"[DB] {symbol} 创建 pending 记录失败,将导致后续无法按 client_order_id 完善,易产生补单: "
|
||
f"client_order_id={client_order_id!r} error_type={type(e).__name__} error={e}"
|
||
)
|
||
_log_trade_db_failure(
|
||
symbol=symbol, entry_order_id=None, side=side, quantity=quantity, entry_price=entry_price,
|
||
account_id=self.account_id, reason="pending_create_failed",
|
||
error_type=type(e).__name__, error_message=str(e)
|
||
)
|
||
pending_trade_id = None
|
||
|
||
if not smart_entry_enabled:
|
||
# 根治方案:关闭智能入场后,回归“纯限价单模式”
|
||
# - 不追价
|
||
# - 不市价兜底
|
||
# - 未在确认时间内成交则撤单并跳过(属于策略未触发入场,不是系统错误)
|
||
confirm_timeout = int(config.TRADING_CONFIG.get("ENTRY_CONFIRM_TIMEOUT_SEC", 30) or 30)
|
||
logger.info(
|
||
f"{symbol} [纯限价入场] side={side} | 限价={initial_limit:.6f} (offset={limit_offset_ratio*100:.2f}%) | "
|
||
f"确认超时={confirm_timeout}s(未成交将撤单跳过)"
|
||
)
|
||
order = await self.client.place_order(
|
||
symbol=symbol, side=side, quantity=quantity, order_type="LIMIT", price=initial_limit,
|
||
new_client_order_id=client_order_id
|
||
)
|
||
if not order:
|
||
if pending_trade_id and DB_AVAILABLE and Trade:
|
||
try:
|
||
ok = Trade.update_status(pending_trade_id, "cancelled")
|
||
if not ok:
|
||
logger.error(f"[DB] {symbol} 下单失败后更新 pending 为 cancelled 失败 trade_id={pending_trade_id}")
|
||
except Exception as ex:
|
||
logger.error(f"[DB] {symbol} 下单失败后更新 pending 为 cancelled 异常 trade_id={pending_trade_id} error={ex}")
|
||
return None
|
||
entry_order_id = order.get("orderId")
|
||
if entry_order_id:
|
||
self._pending_entry_orders[symbol] = {"order_id": entry_order_id, "created_at_ms": int(time.time() * 1000)}
|
||
else:
|
||
# 1) 先挂限价单
|
||
logger.info(
|
||
f"{symbol} [智能入场] 模式={entry_mode_used} | side={side} | "
|
||
f"marketRegime={market_regime} trend_4h={trend_4h} strength={ss}/10 | "
|
||
f"初始限价={initial_limit:.6f} (offset={limit_offset_ratio*100:.2f}%) | "
|
||
f"追价上限={max_drift_ratio*100:.2f}%"
|
||
)
|
||
|
||
order = await self.client.place_order(
|
||
symbol=symbol, side=side, quantity=quantity, order_type="LIMIT", price=initial_limit,
|
||
new_client_order_id=client_order_id
|
||
)
|
||
if not order:
|
||
if pending_trade_id and DB_AVAILABLE and Trade:
|
||
try:
|
||
ok = Trade.update_status(pending_trade_id, "cancelled")
|
||
if not ok:
|
||
logger.error(f"[DB] {symbol} 智能入场首单失败后更新 pending 为 cancelled 失败 trade_id={pending_trade_id}")
|
||
except Exception as ex:
|
||
logger.error(f"[DB] {symbol} 智能入场首单失败后更新 pending 为 cancelled 异常 trade_id={pending_trade_id} error={ex}")
|
||
return None
|
||
entry_order_id = order.get("orderId")
|
||
if entry_order_id:
|
||
self._pending_entry_orders[symbol] = {"order_id": entry_order_id, "created_at_ms": int(time.time() * 1000)}
|
||
|
||
start_ts = time.time()
|
||
# 2) 分步等待 + 追价(逐步减少 offset),并在趋势强时允许市价兜底(有追价上限)
|
||
for step in range(max(1, chase_steps)):
|
||
# 先等待一段时间看是否成交
|
||
wait_res = await self._wait_for_order_filled(symbol, int(entry_order_id), timeout_sec=step_wait_sec, poll_sec=1.0)
|
||
order_status = wait_res.get("status")
|
||
if wait_res.get("ok"):
|
||
actual_entry_price = float(wait_res.get("avg_price") or 0)
|
||
filled_quantity = float(wait_res.get("executed_qty") or 0)
|
||
break
|
||
|
||
# 未成交:如果超时太久且允许市价兜底,检查追价上限后转市价
|
||
elapsed = time.time() - start_ts
|
||
ticker2 = await self.client.get_ticker_24h(symbol)
|
||
cur2 = float(ticker2.get("price")) if ticker2 else current_px
|
||
drift_ratio = 0.0
|
||
try:
|
||
base = float(initial_limit) if float(initial_limit) > 0 else cur2
|
||
drift_ratio = abs((cur2 - base) / base)
|
||
except Exception:
|
||
drift_ratio = 0.0
|
||
|
||
if allow_market_fallback and elapsed >= market_fallback_after_sec:
|
||
if drift_ratio <= max_drift_ratio:
|
||
try:
|
||
await self.client.cancel_order(symbol, int(entry_order_id))
|
||
except Exception:
|
||
pass
|
||
self._pending_entry_orders.pop(symbol, None)
|
||
logger.info(f"{symbol} [智能入场] 限价超时,且偏离{drift_ratio*100:.2f}%≤{max_drift_ratio*100:.2f}%,转市价兜底")
|
||
order = await self.client.place_order(
|
||
symbol=symbol, side=side, quantity=quantity, order_type="MARKET",
|
||
new_client_order_id=client_order_id
|
||
)
|
||
# 关键:转市价后必须更新 entry_order_id,否则后续会继续查询“已取消的旧限价单”,导致误判 CANCELED
|
||
try:
|
||
entry_order_id = order.get("orderId") if isinstance(order, dict) else None
|
||
except Exception:
|
||
entry_order_id = None
|
||
break
|
||
else:
|
||
logger.info(f"{symbol} [智能入场] 限价超时,但偏离{drift_ratio*100:.2f}%>{max_drift_ratio*100:.2f}%,取消并放弃本次交易")
|
||
try:
|
||
await self.client.cancel_order(symbol, int(entry_order_id))
|
||
except Exception:
|
||
pass
|
||
self._pending_entry_orders.pop(symbol, None)
|
||
return None
|
||
|
||
# 震荡/不允许市价兜底:尝试追价(减小 offset -> 更靠近当前价),但不突破追价上限
|
||
try:
|
||
await self.client.cancel_order(symbol, int(entry_order_id))
|
||
except Exception:
|
||
pass
|
||
|
||
# offset 逐步减少到 0(越追越接近当前价)
|
||
step_ratio = (step + 1) / max(1, chase_steps)
|
||
cur_offset_ratio = max(0.0, limit_offset_ratio * (1.0 - step_ratio))
|
||
desired = self._calc_limit_entry_price(cur2, side, cur_offset_ratio)
|
||
if side == "BUY":
|
||
cap = initial_limit * (1 + max_drift_ratio)
|
||
desired = min(desired, cap)
|
||
else:
|
||
cap = initial_limit * (1 - max_drift_ratio)
|
||
desired = max(desired, cap)
|
||
|
||
if desired <= 0:
|
||
self._pending_entry_orders.pop(symbol, None)
|
||
return None
|
||
|
||
logger.info(
|
||
f"{symbol} [智能入场] 追价 step={step+1}/{chase_steps} | 当前价={cur2:.6f} | "
|
||
f"offset={cur_offset_ratio*100:.3f}% -> 限价={desired:.6f} | 偏离={drift_ratio*100:.2f}%"
|
||
)
|
||
order = await self.client.place_order(
|
||
symbol=symbol, side=side, quantity=quantity, order_type="LIMIT", price=desired,
|
||
new_client_order_id=client_order_id
|
||
)
|
||
if not order:
|
||
if pending_trade_id and DB_AVAILABLE and Trade:
|
||
try:
|
||
ok = Trade.update_status(pending_trade_id, "cancelled")
|
||
if not ok:
|
||
logger.error(f"[DB] {symbol} 追价下单失败后更新 pending 为 cancelled 失败 trade_id={pending_trade_id}")
|
||
except Exception as ex:
|
||
logger.error(f"[DB] {symbol} 追价下单失败后更新 pending 为 cancelled 异常 trade_id={pending_trade_id} error={ex}")
|
||
self._pending_entry_orders.pop(symbol, None)
|
||
return None
|
||
entry_order_id = order.get("orderId")
|
||
if entry_order_id:
|
||
self._pending_entry_orders[symbol] = {"order_id": entry_order_id, "created_at_ms": int(time.time() * 1000)}
|
||
|
||
# 如果是市价兜底或最终限价成交,这里统一继续后续流程(下面会再查实际成交)
|
||
|
||
# ===== 统一处理:确认订单成交并获取实际成交价/数量 =====
|
||
if order:
|
||
if not entry_order_id:
|
||
entry_order_id = order.get("orderId")
|
||
if entry_order_id:
|
||
logger.info(f"{symbol} [开仓] 币安订单号: {entry_order_id}")
|
||
|
||
# 等待订单成交,检查订单状态并获取实际成交价格
|
||
# 只有在订单真正成交(FILLED)后才保存到数据库
|
||
if entry_order_id:
|
||
# 智能入场的限价订单可能需要更长等待,这里给一个总等待兜底(默认 30s)
|
||
confirm_timeout = int(config.TRADING_CONFIG.get("ENTRY_CONFIRM_TIMEOUT_SEC", 30) or 30)
|
||
res = await self._wait_for_order_filled(symbol, int(entry_order_id), timeout_sec=confirm_timeout, poll_sec=1.0)
|
||
order_status = res.get("status")
|
||
if res.get("ok"):
|
||
actual_entry_price = float(res.get("avg_price") or 0)
|
||
filled_quantity = float(res.get("executed_qty") or 0)
|
||
else:
|
||
# 未成交(NEW/超时/CANCELED 等):撤单。撤单后校验是否已成交(竞态:撤单瞬间订单可能已成交)
|
||
logger.warning(f"{symbol} [开仓] 未成交,状态: {order_status},撤销挂单")
|
||
try:
|
||
await self.client.cancel_order(symbol, int(entry_order_id))
|
||
except Exception:
|
||
pass
|
||
# 正向流程加固:撤单后校验,若已成交则继续流程
|
||
recheck = await self._wait_for_order_filled(symbol, int(entry_order_id), timeout_sec=5, poll_sec=0.5)
|
||
if recheck.get("ok"):
|
||
actual_entry_price = float(recheck.get("avg_price") or 0)
|
||
filled_quantity = float(recheck.get("executed_qty") or 0)
|
||
logger.info(f"{symbol} [开仓] 撤单后发现已成交,继续完善记录 (成交价={actual_entry_price:.4f} 数量={filled_quantity:.4f})")
|
||
else:
|
||
self._pending_entry_orders.pop(symbol, None)
|
||
return None
|
||
|
||
if not actual_entry_price or actual_entry_price <= 0:
|
||
logger.error(f"{symbol} [开仓] ❌ 无法获取实际成交价格,不保存到数据库")
|
||
self._pending_entry_orders.pop(symbol, None)
|
||
return None
|
||
|
||
if filled_quantity <= 0:
|
||
logger.error(f"{symbol} [开仓] ❌ 成交数量为0,不保存到数据库")
|
||
self._pending_entry_orders.pop(symbol, None)
|
||
return None
|
||
|
||
# 使用实际成交价格和数量
|
||
original_entry_price = entry_price
|
||
entry_price = actual_entry_price
|
||
quantity = filled_quantity # 使用实际成交数量
|
||
logger.info(f"{symbol} [开仓] ✓ 使用实际成交价格: {entry_price:.4f} USDT (下单时价格: {original_entry_price:.4f}), 成交数量: {quantity:.4f}")
|
||
|
||
# 成交后清理 pending
|
||
self._pending_entry_orders.pop(symbol, None)
|
||
|
||
# ===== 成交后基于“实际成交价/数量”重新计算止损止盈(修复限价/滑点导致的偏差)=====
|
||
stop_loss_pct_margin = config.TRADING_CONFIG.get('STOP_LOSS_PERCENT', 0.03)
|
||
# ⚠️ 关键修复:配置值格式转换(兼容百分比形式和比例形式)
|
||
if stop_loss_pct_margin is not None and stop_loss_pct_margin > 1:
|
||
stop_loss_pct_margin = stop_loss_pct_margin / 100.0
|
||
stop_loss_price = self.risk_manager.get_stop_loss_price(
|
||
entry_price, side, quantity, leverage,
|
||
stop_loss_pct=stop_loss_pct_margin,
|
||
klines=klines,
|
||
bollinger=bollinger,
|
||
atr=atr
|
||
)
|
||
|
||
# ⚠️ 2026-01-29优化:计算止损距离用于盈亏比止盈计算(确保达到3:1目标)
|
||
# 实际止损距离(用于 TP1 最小盈亏比,必须与真实挂单一致,避免 SL 被保证金封顶后 TP 仍按 ATR 拉很远)
|
||
actual_stop_distance = (entry_price - stop_loss_price) if side == 'BUY' else (stop_loss_price - entry_price)
|
||
stop_distance_for_tp = actual_stop_distance
|
||
# 如果ATR可用,使用ATR计算更准确的止损距离(仅用于 get_take_profit_price 的盈亏比,不用于 TP1 的 MIN_RR_FOR_TP1)
|
||
if atr is not None and atr > 0 and entry_price > 0:
|
||
atr_percent = atr / entry_price
|
||
atr_multiplier = config.TRADING_CONFIG.get('ATR_STOP_LOSS_MULTIPLIER', 3.0) # 2026-02-12优化:默认3.0,避免噪音止损
|
||
stop_distance_for_tp = entry_price * atr_percent * atr_multiplier
|
||
|
||
take_profit_pct_margin = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.30)
|
||
# ⚠️ 关键修复:配置值格式转换(兼容百分比形式和比例形式)
|
||
if take_profit_pct_margin is not None and take_profit_pct_margin > 1:
|
||
take_profit_pct_margin = take_profit_pct_margin / 100.0
|
||
if take_profit_pct_margin is None or take_profit_pct_margin == 0:
|
||
take_profit_pct_margin = float(stop_loss_pct_margin or 0) * 3.0
|
||
take_profit_price = self.risk_manager.get_take_profit_price(
|
||
entry_price, side, quantity, leverage,
|
||
take_profit_pct=take_profit_pct_margin,
|
||
atr=atr,
|
||
stop_distance=stop_distance_for_tp
|
||
)
|
||
|
||
# 分步止盈(基于“实际成交价 + 已计算的止损/止盈”)
|
||
# 交易规模:名义/保证金(用于统计总交易量与UI展示)
|
||
try:
|
||
notional_usdt = float(entry_price) * float(quantity)
|
||
except Exception:
|
||
notional_usdt = None
|
||
try:
|
||
margin_usdt = (float(notional_usdt) / float(leverage)) if notional_usdt is not None and float(leverage) > 0 else notional_usdt
|
||
except Exception:
|
||
margin_usdt = None
|
||
|
||
# 分步止盈(基于"实际成交价 + 已计算的止损/止盈")
|
||
# ⚠️ 第一目标使用 TAKE_PROFIT_1_PERCENT(默认15%),与第二目标 TAKE_PROFIT_PERCENT 分离,提高整体盈亏比
|
||
# 第一目标和触发条件必须一致,都使用 TAKE_PROFIT_1_PERCENT
|
||
take_profit_1_pct_margin = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20)
|
||
# 兼容百分比形式和比例形式
|
||
if take_profit_1_pct_margin is not None and take_profit_1_pct_margin > 1:
|
||
take_profit_1_pct_margin = take_profit_1_pct_margin / 100.0
|
||
|
||
# 计算基于保证金的止盈距离
|
||
tp1_distance = 0.0
|
||
if margin_usdt and margin_usdt > 0 and quantity > 0:
|
||
take_profit_1_amount = margin_usdt * take_profit_1_pct_margin
|
||
tp1_distance = take_profit_1_amount / quantity
|
||
|
||
# ⚠️ 2026-02-10优化:确保TP1至少有 1.2倍 的盈亏比 (相对于【实际】止损距离)
|
||
# 使用 actual_stop_distance(真实挂单距离),避免 SL 被保证金封顶后仍用 ATR 宽距离推高 TP 导致 4% SL vs 62% TP 的畸形
|
||
if actual_stop_distance is not None and actual_stop_distance > 0:
|
||
min_rr_for_tp1 = config.TRADING_CONFIG.get('MIN_RR_FOR_TP1', 1.5)
|
||
min_tp1_distance = actual_stop_distance * min_rr_for_tp1
|
||
if min_tp1_distance > tp1_distance:
|
||
logger.info(f"{symbol} [优化] TP1距离 ({tp1_distance:.4f}) 小于 {min_rr_for_tp1}倍实际止损距离 ({min_tp1_distance:.4f}),已自动调整以保证盈亏比")
|
||
tp1_distance = min_tp1_distance
|
||
|
||
# ⚠️ 2026-02-12优化:确保TP1至少覆盖双向手续费(避免微利单实际上亏损)
|
||
# 手续费率估算:0.05% (Taker) * 2 (双向) * 1.5 (安全系数) = 0.15% 价格变动
|
||
min_fee_distance = entry_price * 0.0015
|
||
if min_fee_distance > tp1_distance:
|
||
logger.info(f"{symbol} [优化] TP1距离 ({tp1_distance:.4f}) 小于 手续费磨损距离 ({min_fee_distance:.4f}),已自动调整以覆盖成本")
|
||
tp1_distance = min_fee_distance
|
||
|
||
# ⚠️ 止盈上限:TP1 不超过配置的止盈比例,避免“4% 止损 vs 62% 止盈”的畸形(与 USE_MARGIN_CAP_FOR_TP 一致)
|
||
if margin_usdt and margin_usdt > 0 and quantity > 0:
|
||
tp1_max_pct = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20)
|
||
if tp1_max_pct is not None and tp1_max_pct > 1:
|
||
tp1_max_pct = tp1_max_pct / 100.0
|
||
tp1_max_pct = (tp1_max_pct or 0.20) * 1.5 # 允许第一目标最多约 30% 保证金
|
||
tp1_max_amount = margin_usdt * tp1_max_pct
|
||
tp1_max_distance = tp1_max_amount / quantity
|
||
if tp1_distance > tp1_max_distance:
|
||
logger.info(f"{symbol} [优化] TP1距离 ({tp1_distance:.4f}) 超过止盈上限 ({tp1_max_distance:.4f},约{tp1_max_pct*100:.0f}% margin),已封顶")
|
||
tp1_distance = tp1_max_distance
|
||
|
||
if tp1_distance > 0:
|
||
if side == 'BUY':
|
||
take_profit_1 = entry_price + tp1_distance
|
||
else:
|
||
take_profit_1 = entry_price - tp1_distance
|
||
else:
|
||
if side == 'BUY':
|
||
take_profit_1 = entry_price + (entry_price - stop_loss_price)
|
||
else:
|
||
take_profit_1 = entry_price - (stop_loss_price - entry_price)
|
||
take_profit_2 = take_profit_price
|
||
if take_profit_1 is not None and take_profit_2 is not None:
|
||
if side == 'BUY':
|
||
if take_profit_1 >= take_profit_2 and take_profit_2 > entry_price:
|
||
closer = min(take_profit_1, take_profit_2)
|
||
further = max(take_profit_1, take_profit_2)
|
||
if closer > entry_price:
|
||
take_profit_1 = closer
|
||
take_profit_2 = further
|
||
else:
|
||
if take_profit_1 <= take_profit_2 and take_profit_2 < entry_price:
|
||
closer = max(take_profit_1, take_profit_2)
|
||
further = min(take_profit_1, take_profit_2)
|
||
if closer < entry_price:
|
||
take_profit_1 = closer
|
||
take_profit_2 = further
|
||
|
||
# 记录到数据库:有 pending 则完善(WS 也会按 client_order_id 完善),否则新建
|
||
trade_id = None
|
||
if DB_AVAILABLE and Trade:
|
||
try:
|
||
if client_order_id:
|
||
row = Trade.get_by_client_order_id(client_order_id, self.account_id)
|
||
if row and str(row.get("status")) == "pending":
|
||
ok = Trade.update_pending_to_filled(
|
||
client_order_id, self.account_id,
|
||
entry_order_id, entry_price, quantity
|
||
)
|
||
if ok:
|
||
row = Trade.get_by_client_order_id(client_order_id, self.account_id)
|
||
trade_id = row.get("id") if row else None
|
||
if trade_id:
|
||
try:
|
||
Trade.update_open_fields(
|
||
trade_id,
|
||
stop_loss_price=stop_loss_price,
|
||
take_profit_price=take_profit_price,
|
||
take_profit_1=take_profit_1,
|
||
take_profit_2=take_profit_2,
|
||
notional_usdt=notional_usdt,
|
||
margin_usdt=margin_usdt,
|
||
entry_context=entry_context,
|
||
atr=atr,
|
||
)
|
||
logger.info(f"[DB] {symbol} 已完善 pending→open (ID: {trade_id}, orderId: {entry_order_id}, 成交价: {entry_price:.4f}, 数量: {quantity:.4f})")
|
||
except Exception as of_ex:
|
||
logger.error(f"[DB] {symbol} 完善 open 字段失败 trade_id={trade_id} (SL/TP/notional 等未写入): {of_ex}")
|
||
else:
|
||
logger.error(
|
||
f"[DB] {symbol} 完善 pending→open 失败,将走新建兜底或依赖补单: "
|
||
f"client_order_id={client_order_id!r} entry_order_id={entry_order_id}"
|
||
)
|
||
elif row and str(row.get("status")) != "pending":
|
||
logger.debug(f"[DB] {symbol} 已有非 pending 记录 status={row.get('status')},将新建或跳过")
|
||
if trade_id is None:
|
||
# 无 pending 或未匹配到:走新建(兜底)
|
||
fallback_client_order_id = (order.get("clientOrderId") if order else None) or client_order_id
|
||
logger.info(f"[DB] {symbol} 无 pending 记录,新建 open 记录 client_order_id={fallback_client_order_id!r} entry_order_id={entry_order_id}")
|
||
# 如果 REST 已获取到 entry_order_id,直接写入;否则留空,等待 WS 推送或后续同步补全
|
||
trade_id = Trade.create(
|
||
symbol=symbol,
|
||
side=side,
|
||
quantity=quantity,
|
||
entry_price=entry_price,
|
||
leverage=leverage,
|
||
entry_reason=entry_reason,
|
||
entry_order_id=entry_order_id, # REST 已获取则直接写入
|
||
client_order_id=fallback_client_order_id,
|
||
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,
|
||
notional_usdt=notional_usdt,
|
||
margin_usdt=margin_usdt,
|
||
entry_context=entry_context,
|
||
account_id=self.account_id,
|
||
)
|
||
if entry_order_id:
|
||
logger.info(f"✓ {symbol} 交易记录已保存到数据库 (ID: {trade_id}, 订单号: {entry_order_id}, 成交价: {entry_price:.4f}, 成交数量: {quantity:.4f})")
|
||
else:
|
||
logger.warning(f"⚠ {symbol} 交易记录已保存但 entry_order_id 为空 (ID: {trade_id}),等待 WS 推送或后续同步补全")
|
||
# 如果有 client_order_id,尝试通过 REST 查询订单号补全
|
||
if fallback_client_order_id:
|
||
try:
|
||
# 延迟查询,确保订单已入库
|
||
await asyncio.sleep(1)
|
||
orders = await self.client.client.futures_get_all_orders(
|
||
symbol=symbol, limit=10, recvWindow=10000
|
||
)
|
||
for o in orders or []:
|
||
if str(o.get("clientOrderId", "")).strip() == fallback_client_order_id:
|
||
found_order_id = o.get("orderId")
|
||
if found_order_id:
|
||
Trade.update_entry_order_id(trade_id, found_order_id)
|
||
logger.info(f"✓ {symbol} 已通过 REST 补全 entry_order_id: {found_order_id}")
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"[DB] {symbol} REST 补全 entry_order_id 失败 trade_id={trade_id}: {e}")
|
||
except Exception as e:
|
||
logger.error(f"❌ 保存交易记录到数据库失败: {e}")
|
||
logger.error(f" 错误类型: {type(e).__name__}")
|
||
import traceback
|
||
logger.error(f" 错误详情:\n{traceback.format_exc()}")
|
||
_log_trade_db_failure(
|
||
symbol=symbol, entry_order_id=entry_order_id, side=side,
|
||
quantity=quantity, entry_price=entry_price, account_id=self.account_id,
|
||
reason="create_exception", error_type=type(e).__name__, error_message=str(e)
|
||
)
|
||
return None
|
||
elif not DB_AVAILABLE:
|
||
logger.error(f"[DB] 数据库不可用,无法保存成交记录 symbol={symbol} entry_order_id={entry_order_id} client_order_id={client_order_id!r},将依赖补单")
|
||
_log_trade_db_failure(
|
||
symbol=symbol, entry_order_id=entry_order_id, side=side,
|
||
quantity=quantity, entry_price=entry_price, account_id=self.account_id,
|
||
reason="DB_AVAILABLE=False"
|
||
)
|
||
elif not Trade:
|
||
logger.error(f"[DB] Trade 模型未导入,无法保存成交记录 symbol={symbol} entry_order_id={entry_order_id},将依赖补单")
|
||
_log_trade_db_failure(
|
||
symbol=symbol, entry_order_id=entry_order_id, side=side,
|
||
quantity=quantity, entry_price=entry_price, account_id=self.account_id,
|
||
reason="Trade=None"
|
||
)
|
||
|
||
# 记录持仓信息(包含动态止损止盈和分步止盈)
|
||
from datetime import datetime
|
||
position_info = {
|
||
'symbol': symbol,
|
||
'side': side,
|
||
'quantity': quantity,
|
||
'entryPrice': entry_price,
|
||
'changePercent': change_percent,
|
||
'orderId': order.get('orderId'),
|
||
'tradeId': trade_id, # 数据库交易ID
|
||
'stopLoss': stop_loss_price,
|
||
'takeProfit': take_profit_price, # 保留原始止盈价(第二目标)
|
||
'takeProfit1': take_profit_1, # 第一目标(盈亏比1:1,了结50%)
|
||
'takeProfit2': take_profit_2, # 第二目标(原始止盈价,剩余50%)
|
||
'partialProfitTaken': False, # 是否已部分止盈
|
||
'remainingQuantity': quantity, # 剩余仓位数量
|
||
'initialStopLoss': stop_loss_price, # 初始止损(用于移动止损)
|
||
'leverage': leverage,
|
||
'entryReason': entry_reason,
|
||
'entryTime': get_beijing_time(), # 记录入场时间(使用北京时间,用于计算持仓持续时间)
|
||
'strategyType': 'trend_following', # 策略类型(简化后只有趋势跟踪)
|
||
'atr': atr,
|
||
'maxProfit': 0.0, # 记录最大盈利(用于移动止损/滞涨判断)
|
||
'lastNewHighTs': None, # 最后一次创新高的时间戳(秒),用于滞涨早止盈
|
||
'stagnationExitTriggered': False, # 是否已执行滞涨分批减仓+抬止损(只执行一次)
|
||
'trailingStopActivated': False, # 移动止损是否已激活
|
||
'breakevenStopSet': False, # 是否已执行“盈利达阈值后移至保本”
|
||
'account_id': self.account_id
|
||
}
|
||
|
||
self.active_positions[symbol] = position_info
|
||
|
||
logger.info(
|
||
f"开仓成功: {symbol} {side} {quantity} @ {entry_price:.4f} "
|
||
f"(涨跌幅: {change_percent:.2f}%)"
|
||
)
|
||
|
||
# 验证持仓是否真的在币安存在
|
||
try:
|
||
await asyncio.sleep(0.5) # 等待一小段时间让币安更新持仓
|
||
positions = await self._get_open_positions()
|
||
binance_position = next(
|
||
(p for p in positions if p['symbol'] == symbol and float(p.get('positionAmt', 0)) != 0),
|
||
None
|
||
)
|
||
if binance_position:
|
||
logger.info(
|
||
f"{symbol} [开仓验证] ✓ 币安持仓确认: "
|
||
f"数量={float(binance_position.get('positionAmt', 0)):.4f}, "
|
||
f"入场价={float(binance_position.get('entryPrice', 0)):.4f}"
|
||
)
|
||
# 在币安侧挂“止损/止盈保护单”,避免仅依赖本地监控(服务重启/网络波动时更安全)
|
||
try:
|
||
current_mark = None
|
||
try:
|
||
current_mark = float(binance_position.get("markPrice", 0) or 0) or None
|
||
except Exception:
|
||
current_mark = None
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_mark)
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} 挂币安止盈止损失败(不影响持仓监控): {e}")
|
||
else:
|
||
logger.warning(
|
||
f"{symbol} [开仓验证] ⚠️ 币安账户中没有持仓,可能订单未成交或被立即平仓"
|
||
)
|
||
# 清理本地记录
|
||
if symbol in self.active_positions:
|
||
del self.active_positions[symbol]
|
||
# 如果数据库已保存,标记为取消
|
||
if trade_id and DB_AVAILABLE and Trade:
|
||
try:
|
||
from database.connection import db
|
||
db.execute_update(
|
||
"UPDATE trades SET status = 'cancelled' WHERE id = %s",
|
||
(trade_id,)
|
||
)
|
||
logger.info(f"{symbol} [开仓验证] 已更新数据库状态为 cancelled (ID: {trade_id})")
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} [开仓验证] 更新数据库状态失败: {e}")
|
||
return None
|
||
except Exception as verify_error:
|
||
logger.warning(f"{symbol} [开仓验证] 验证持仓时出错: {verify_error},继续使用本地记录")
|
||
|
||
# 启动WebSocket实时监控
|
||
if self._monitoring_enabled:
|
||
await self._start_position_monitoring(symbol)
|
||
|
||
# 记录“今日开仓次数”(用于用户风控旋钮 MAX_DAILY_ENTRIES)
|
||
try:
|
||
await self.risk_manager.record_entry(symbol)
|
||
except Exception:
|
||
pass
|
||
|
||
return position_info
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
err_msg = str(e).strip() if str(e) else f"{type(e).__name__}(无详情)"
|
||
logger.error(f"开仓失败 {symbol}: {err_msg}", exc_info=True)
|
||
return None
|
||
|
||
async def close_position(self, symbol: str, reason: str = 'manual') -> bool:
|
||
"""
|
||
平仓
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
reason: 平仓原因(manual, stop_loss, take_profit, trailing_stop, sync, take_profit_partial_then_take_profit, take_profit_partial_then_stop, take_profit_partial_then_trailing_stop)
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
try:
|
||
logger.info(f"{symbol} [平仓] 开始平仓操作 (原因: {reason})")
|
||
|
||
# 先取消币安侧的保护单(避免平仓后残留委托导致反向开仓/误触发)
|
||
try:
|
||
info0 = self.active_positions.get(symbol) if hasattr(self, "active_positions") else None
|
||
if info0 and isinstance(info0, dict):
|
||
for k in ("exchangeSlOrderId", "exchangeTpOrderId"):
|
||
oid = info0.get(k)
|
||
if oid:
|
||
try:
|
||
await self.client.futures_cancel_algo_order(int(oid))
|
||
except Exception:
|
||
pass
|
||
info0.pop("exchangeSlOrderId", None)
|
||
info0.pop("exchangeTpOrderId", None)
|
||
except Exception:
|
||
pass
|
||
|
||
# 获取当前持仓
|
||
positions = await self._get_open_positions()
|
||
position = next(
|
||
(p for p in positions if p['symbol'] == symbol),
|
||
None
|
||
)
|
||
|
||
if not position:
|
||
logger.warning(f"{symbol} [平仓] 币安账户中没有持仓,可能已被平仓")
|
||
# 即使币安没有持仓,也要更新数据库状态
|
||
updated = False
|
||
if DB_AVAILABLE and Trade and symbol in self.active_positions:
|
||
position_info = self.active_positions[symbol]
|
||
trade_id = position_info.get('tradeId')
|
||
if trade_id:
|
||
logger.info(f"{symbol} [平仓] 更新数据库状态为已平仓 (ID: {trade_id})...")
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
exit_price = float(ticker['price']) if ticker else float(position_info['entryPrice'])
|
||
entry_price = float(position_info['entryPrice'])
|
||
quantity = float(position_info['quantity'])
|
||
if position_info['side'] == 'BUY':
|
||
pnl = (exit_price - entry_price) * quantity
|
||
pnl_percent = ((exit_price - entry_price) / entry_price) * 100
|
||
else:
|
||
pnl = (entry_price - exit_price) * quantity
|
||
pnl_percent = ((entry_price - exit_price) / entry_price) * 100
|
||
entry_time = position_info.get('entryTime')
|
||
duration_minutes = None
|
||
if entry_time:
|
||
try:
|
||
if isinstance(entry_time, str):
|
||
entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S')
|
||
else:
|
||
entry_dt = entry_time
|
||
exit_dt = get_beijing_time()
|
||
duration = exit_dt - entry_dt
|
||
duration_minutes = int(duration.total_seconds() / 60)
|
||
except Exception as e:
|
||
logger.debug(f"计算持仓持续时间失败: {e}")
|
||
strategy_type = position_info.get('strategyType', 'trend_following')
|
||
db_update_retries = 3
|
||
for db_attempt in range(db_update_retries):
|
||
try:
|
||
Trade.update_exit(
|
||
trade_id=trade_id,
|
||
exit_price=exit_price,
|
||
exit_reason=reason,
|
||
pnl=pnl,
|
||
pnl_percent=pnl_percent,
|
||
exit_order_id=None,
|
||
strategy_type=strategy_type,
|
||
duration_minutes=duration_minutes
|
||
)
|
||
logger.info(f"{symbol} [平仓] ✓ 数据库状态已更新")
|
||
updated = True
|
||
break
|
||
except Exception as e:
|
||
err_msg = str(e).strip() or f"{type(e).__name__}"
|
||
if db_attempt < db_update_retries - 1:
|
||
wait_sec = 2
|
||
logger.warning(
|
||
f"{symbol} [平仓] 更新数据库失败 (第 {db_attempt + 1}/{db_update_retries} 次): {err_msg},"
|
||
f"{wait_sec}秒后重试"
|
||
)
|
||
await asyncio.sleep(wait_sec)
|
||
else:
|
||
logger.error(f"{symbol} [平仓] ❌ 更新数据库状态失败: {err_msg}")
|
||
|
||
# 清理本地记录
|
||
await self._stop_position_monitoring(symbol)
|
||
if symbol in self.active_positions:
|
||
del self.active_positions[symbol]
|
||
|
||
# 如果更新了数据库,返回成功;否则返回失败
|
||
return updated
|
||
|
||
# 确定平仓方向(与开仓相反)
|
||
position_amt = position['positionAmt']
|
||
side = 'SELL' if position_amt > 0 else 'BUY'
|
||
quantity = abs(position_amt)
|
||
position_side = 'LONG' if position_amt > 0 else 'SHORT'
|
||
|
||
# 二次校验:用币安实时持仓数量兜底,避免 reduceOnly 被拒绝(-2022)
|
||
live_amt = await self._get_live_position_amt(symbol, position_side=position_side)
|
||
if live_amt is None or abs(live_amt) <= 0:
|
||
logger.warning(f"{symbol} [平仓] 实时查询到持仓已为0,跳过下单并按已平仓处理")
|
||
# 复用“币安无持仓”的处理逻辑:走上面的分支
|
||
position = None
|
||
# 触发上方逻辑:直接返回 updated/清理
|
||
# 这里简单调用同步函数路径
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
exit_price = float(ticker['price']) if ticker else float(position.get('entryPrice', 0) if position else 0)
|
||
await self._stop_position_monitoring(symbol)
|
||
if symbol in self.active_positions:
|
||
del self.active_positions[symbol]
|
||
logger.info(f"{symbol} [平仓] 已清理本地记录(币安无持仓)")
|
||
return True
|
||
|
||
# 以币安实时持仓数量为准(并向下截断到不超过持仓)
|
||
quantity = min(quantity, abs(live_amt))
|
||
quantity = await self._adjust_close_quantity(symbol, quantity)
|
||
if quantity <= 0:
|
||
logger.warning(f"{symbol} [平仓] 数量调整后为0,跳过下单并清理本地记录")
|
||
await self._stop_position_monitoring(symbol)
|
||
if symbol in self.active_positions:
|
||
del self.active_positions[symbol]
|
||
return True
|
||
|
||
logger.info(
|
||
f"{symbol} [平仓] 下单信息: {side} {quantity:.4f} @ MARKET "
|
||
f"(持仓数量: {position_amt:.4f})"
|
||
)
|
||
|
||
# 平仓(使用 reduceOnly=True 确保只减少持仓,不增加反向持仓)
|
||
try:
|
||
logger.debug(f"{symbol} [平仓] 调用 place_order: {side} {quantity:.4f} @ MARKET (reduceOnly=True)")
|
||
order = await self.client.place_order(
|
||
symbol=symbol,
|
||
side=side,
|
||
quantity=quantity,
|
||
order_type='MARKET',
|
||
reduce_only=True, # 平仓时使用 reduceOnly=True
|
||
position_side=position_side, # 兼容对冲模式(Hedge):必须指定 LONG/SHORT
|
||
)
|
||
logger.debug(f"{symbol} [平仓] place_order 返回: {order}")
|
||
except Exception as order_error:
|
||
logger.error(f"{symbol} [平仓] ❌ 下单失败: {order_error}")
|
||
logger.error(f" 下单参数: symbol={symbol}, side={side}, quantity={quantity:.4f}, order_type=MARKET")
|
||
logger.error(f" 错误类型: {type(order_error).__name__}")
|
||
import traceback
|
||
logger.error(f" 完整错误堆栈:\n{traceback.format_exc()}")
|
||
raise # 重新抛出异常,让外层捕获
|
||
|
||
if order:
|
||
order_id = order.get('orderId')
|
||
logger.info(f"{symbol} [平仓] ✓ 平仓订单已提交 (订单ID: {order_id}),DB 由 WS ORDER_TRADE_UPDATE 更新")
|
||
# 支付式闭环:平仓数据仅由 WS 推送更新,此处只做本地清理
|
||
await self._stop_position_monitoring(symbol)
|
||
if symbol in self.active_positions:
|
||
del self.active_positions[symbol]
|
||
logger.info(f"{symbol} [平仓] ✓ 平仓完成: {side} {quantity:.4f} (原因: {reason})")
|
||
return True
|
||
else:
|
||
# place_order 返回 None:可能是 -2022(ReduceOnly rejected)等竞态场景
|
||
# 兜底:再查一次实时持仓,如果已经为0,则当作“已平仓”处理,避免刷屏与误判失败
|
||
try:
|
||
live2 = await self._get_live_position_amt(symbol, position_side=position_side)
|
||
except Exception:
|
||
live2 = None
|
||
if live2 is None or abs(live2) <= 0:
|
||
logger.warning(f"{symbol} [平仓] 下单返回None,但实时持仓已为0,按已平仓处理(可能竞态/手动平仓)")
|
||
await self._stop_position_monitoring(symbol)
|
||
if symbol in self.active_positions:
|
||
del self.active_positions[symbol]
|
||
return True
|
||
|
||
logger.error(f"{symbol} [平仓] ❌ 下单返回 None(实时持仓仍存在: {live2}),可能的原因:")
|
||
logger.error(f" 1. ReduceOnly 被拒绝(-2022)但持仓未同步")
|
||
logger.error(f" 2. 数量精度调整后为 0 或负数")
|
||
logger.error(f" 3. 无法获取价格信息")
|
||
logger.error(f" 4. 其他下单错误(已在 place_order 中记录)")
|
||
logger.error(f" 持仓信息: {side} {quantity:.4f} @ MARKET")
|
||
|
||
# 尝试获取更多诊断信息
|
||
try:
|
||
symbol_info = await self.client.get_symbol_info(symbol)
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
if ticker:
|
||
current_price = ticker['price']
|
||
notional_value = quantity * current_price
|
||
min_notional = symbol_info.get('minNotional', 5.0) if symbol_info else 5.0
|
||
logger.error(f" 当前价格: {current_price:.4f} USDT")
|
||
logger.error(f" 订单名义价值: {notional_value:.2f} USDT")
|
||
logger.error(f" 最小名义价值: {min_notional:.2f} USDT")
|
||
if notional_value < min_notional:
|
||
logger.error(f" ⚠ 订单名义价值不足,无法平仓")
|
||
except Exception as diag_error:
|
||
logger.warning(f" 无法获取诊断信息: {diag_error}")
|
||
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"{symbol} [平仓] ❌ 平仓失败: {e}")
|
||
logger.error(f" 错误类型: {type(e).__name__}")
|
||
import traceback
|
||
logger.error(f" 完整错误堆栈:\n{traceback.format_exc()}")
|
||
|
||
# 关键:平仓失败时不要盲目清理本地持仓/停止监控,否则会导致“仍有仓位但不再监控/不再自动止损止盈”
|
||
# 仅当确认币安已无持仓(或实时持仓为0)时,才清理本地记录。
|
||
try:
|
||
amt0 = await self._get_live_position_amt(symbol, position_side=None)
|
||
except Exception:
|
||
amt0 = None
|
||
if amt0 is not None and abs(amt0) <= 0:
|
||
try:
|
||
await self._stop_position_monitoring(symbol)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
if symbol in self.active_positions:
|
||
del self.active_positions[symbol]
|
||
except Exception:
|
||
pass
|
||
logger.warning(f"{symbol} [平仓] 异常后检查:币安持仓已为0,已清理本地记录")
|
||
else:
|
||
logger.warning(
|
||
f"{symbol} [平仓] 异常后检查:币安持仓仍可能存在(amt={amt0}),保留本地记录与监控,等待下次同步/重试"
|
||
)
|
||
|
||
return False
|
||
|
||
async def _get_live_position_amt(self, symbol: str, position_side: Optional[str] = None) -> Optional[float]:
|
||
"""
|
||
从币安原始接口读取持仓数量(避免本地状态/缓存不一致导致 reduceOnly 被拒绝)。
|
||
- 单向模式:通常只有一个 net 持仓
|
||
- 对冲模式:可能同时有 LONG/SHORT,两条腿用 positionSide 区分
|
||
"""
|
||
try:
|
||
if not getattr(self.client, "client", None):
|
||
return None
|
||
res = await self.client.client.futures_position_information(symbol=symbol, recvWindow=20000)
|
||
if not isinstance(res, list):
|
||
return None
|
||
ps = (position_side or "").upper()
|
||
nonzero = []
|
||
for p in res:
|
||
if not isinstance(p, dict):
|
||
continue
|
||
try:
|
||
amt = float(p.get("positionAmt", 0))
|
||
except Exception:
|
||
continue
|
||
if abs(amt) <= 0:
|
||
continue
|
||
nonzero.append((amt, p))
|
||
if not nonzero:
|
||
return 0.0
|
||
if ps in ("LONG", "SHORT"):
|
||
for amt, p in nonzero:
|
||
pps = (p.get("positionSide") or "").upper()
|
||
if pps == ps:
|
||
return amt
|
||
# 如果没匹配到 positionSide,退化为按符号推断
|
||
if ps == "LONG":
|
||
cand = next((amt for amt, _ in nonzero if amt > 0), None)
|
||
return cand if cand is not None else 0.0
|
||
if ps == "SHORT":
|
||
cand = next((amt for amt, _ in nonzero if amt < 0), None)
|
||
return cand if cand is not None else 0.0
|
||
# 没提供 position_side:返回净持仓(单向模式)
|
||
return sum([amt for amt, _ in nonzero])
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 读取实时持仓失败: {e}")
|
||
return None
|
||
|
||
async def _adjust_close_quantity(self, symbol: str, quantity: float) -> float:
|
||
"""
|
||
平仓数量调整:只允许向下取整(避免超过持仓导致 reduceOnly 被拒绝)。
|
||
"""
|
||
try:
|
||
symbol_info = await self.client.get_symbol_info(symbol)
|
||
except Exception:
|
||
symbol_info = None
|
||
|
||
try:
|
||
q = float(quantity)
|
||
except Exception:
|
||
return 0.0
|
||
|
||
if not symbol_info:
|
||
return max(0.0, q)
|
||
|
||
try:
|
||
step_size = float(symbol_info.get("stepSize", 0) or 0)
|
||
except Exception:
|
||
step_size = 0.0
|
||
qty_precision = int(symbol_info.get("quantityPrecision", 8) or 8)
|
||
|
||
if step_size and step_size > 0:
|
||
q = float(int(q / step_size)) * step_size
|
||
else:
|
||
q = round(q, qty_precision)
|
||
q = round(q, qty_precision)
|
||
return max(0.0, q)
|
||
|
||
async def _symbol_has_sltp_orders(self, symbol: str) -> bool:
|
||
"""该交易对在交易所是否存在止损/止盈类条件单(用于识别「可能为系统单」的持仓)。"""
|
||
try:
|
||
for o in (await self.client.get_open_orders(symbol)) or []:
|
||
t = str(o.get("type") or "").upper()
|
||
if t in ("STOP_MARKET", "TAKE_PROFIT_MARKET", "STOP", "TAKE_PROFIT"):
|
||
return True
|
||
for o in (await self.client.futures_get_open_algo_orders(symbol, algo_type="CONDITIONAL")) or []:
|
||
t = str(o.get("orderType") or o.get("type") or "").upper()
|
||
if t in ("STOP_MARKET", "TAKE_PROFIT_MARKET", "STOP", "TAKE_PROFIT"):
|
||
return True
|
||
except Exception:
|
||
pass
|
||
return False
|
||
|
||
async def _get_sltp_from_exchange(self, symbol: str, side: str) -> tuple:
|
||
"""从币安条件单读取当前止损价、止盈价,便于重启后不覆盖已有保护。(stop_price, take_profit_price),缺的为 None。"""
|
||
sl_price, tp_price = None, None
|
||
try:
|
||
for o in (await self.client.get_open_orders(symbol)) or []:
|
||
t = str(o.get("type") or "").upper()
|
||
sp = o.get("stopPrice") or o.get("triggerPrice")
|
||
try:
|
||
p = float(sp) if sp is not None else None
|
||
except (TypeError, ValueError):
|
||
p = None
|
||
if p is None:
|
||
continue
|
||
if t in ("STOP_MARKET", "STOP"):
|
||
sl_price = p
|
||
elif t in ("TAKE_PROFIT_MARKET", "TAKE_PROFIT"):
|
||
tp_price = p
|
||
# 条件单多在 CONDITIONAL 里
|
||
for o in (await self.client.futures_get_open_algo_orders(symbol, algo_type="CONDITIONAL")) or []:
|
||
t = str(o.get("orderType") or o.get("type") or "").upper()
|
||
sp = o.get("stopPrice") or o.get("triggerPrice") or o.get("tp")
|
||
try:
|
||
p = float(sp) if sp is not None else None
|
||
except (TypeError, ValueError):
|
||
p = None
|
||
if p is None:
|
||
continue
|
||
if t in ("STOP_MARKET", "STOP"):
|
||
sl_price = p
|
||
elif t in ("TAKE_PROFIT_MARKET", "TAKE_PROFIT"):
|
||
tp_price = p
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 从交易所读取止损/止盈失败: {e}")
|
||
return (sl_price, tp_price)
|
||
|
||
def _breakeven_stop_price(self, entry_price: float, side: str, fee_buffer_pct: Optional[float] = None) -> float:
|
||
"""含手续费的保本止损价:做多=入场*(1+费率),做空=入场*(1-费率),平仓后不亏。"""
|
||
if fee_buffer_pct is None:
|
||
fee_buffer_pct = float(config.TRADING_CONFIG.get("FEE_BUFFER_PCT", 0.0015) or 0.0015)
|
||
if fee_buffer_pct > 0.01:
|
||
fee_buffer_pct = fee_buffer_pct / 100.0
|
||
if side == "BUY":
|
||
return entry_price * (1 + fee_buffer_pct)
|
||
return entry_price * (1 - fee_buffer_pct)
|
||
|
||
def _min_protect_amount_for_fees(self, margin: float, leverage: float) -> float:
|
||
"""移动止损时至少保护的金额(覆盖开平双向手续费,避免“保护”后仍为负)。"""
|
||
lev = max(float(leverage or 10), 1)
|
||
# 双向约 0.05%*2=0.1% 名义 → 保证金上约 0.1%*leverage
|
||
return margin * (0.001 * lev)
|
||
|
||
def _stop_price_to_lock_pct(
|
||
self, entry_price: float, side: str, margin: float, quantity: float, lock_pct: float
|
||
) -> float:
|
||
"""计算「锁住 lock_pct% 保证金利润」对应的止损价。lock_pct 为百分比数值,如 5 表示 5%。"""
|
||
if margin <= 0 or quantity <= 0:
|
||
return self._breakeven_stop_price(entry_price, side)
|
||
lock_ratio = float(lock_pct) / 100.0
|
||
if lock_ratio > 1:
|
||
lock_ratio = lock_ratio / 100.0
|
||
lock_amount = margin * lock_ratio
|
||
if side == 'BUY':
|
||
return entry_price + lock_amount / quantity
|
||
return entry_price - lock_amount / quantity
|
||
|
||
async def _ensure_exchange_sltp_orders(self, symbol: str, position_info: Dict, current_price: Optional[float] = None) -> None:
|
||
"""
|
||
在币安侧挂止损/止盈保护单(STOP_MARKET + TAKE_PROFIT_MARKET)。
|
||
目的:
|
||
- 服务重启/网络波动时仍有交易所级别保护
|
||
- 用户在币安界面能看到止损/止盈委托
|
||
流程(与 docs/bian 条件订单交易更新推送 一致):
|
||
- 先检查持仓存在 → 取消旧保护单 → 再次检查持仓(避免条件单已触发平仓后仍挂单导致 -4509/超时)→ 再挂 SL/TP。
|
||
- 条件单触发平仓后,交易所会将同 symbol 其余条件单置为 EXPIRED,见 ALGO_UPDATE。
|
||
"""
|
||
try:
|
||
enabled = bool(config.TRADING_CONFIG.get("EXCHANGE_SLTP_ENABLED", True))
|
||
except Exception:
|
||
enabled = True
|
||
if not enabled:
|
||
return
|
||
|
||
if not position_info or not isinstance(position_info, dict):
|
||
return
|
||
|
||
# 用于 ALGO_UPDATE 时按 entry_order_id 精确匹配 DB 平仓记录
|
||
entry_order_id = position_info.get("orderId") or position_info.get("entry_order_id")
|
||
client_algo_id_sl = None
|
||
client_algo_id_tp = None
|
||
if entry_order_id:
|
||
eid = str(entry_order_id).strip()
|
||
if eid and len(eid) <= 30: # 留足 SL_/TP_ 前缀
|
||
client_algo_id_sl = f"SL_{eid}"[:36]
|
||
client_algo_id_tp = f"TP_{eid}"[:36]
|
||
|
||
side = (position_info.get("side") or "").upper()
|
||
if side not in {"BUY", "SELL"}:
|
||
return
|
||
|
||
stop_loss = position_info.get("stopLoss")
|
||
take_profit = position_info.get("takeProfit2") or position_info.get("takeProfit")
|
||
try:
|
||
stop_loss = float(stop_loss) if stop_loss is not None else None
|
||
except Exception:
|
||
stop_loss = None
|
||
try:
|
||
take_profit = float(take_profit) if take_profit is not None else None
|
||
except Exception:
|
||
take_profit = None
|
||
|
||
if not stop_loss or not take_profit:
|
||
logger.warning(f"[账号{self.account_id}] {symbol} 同步跳过: 止损或止盈为空 stop_loss={stop_loss} take_profit={take_profit}")
|
||
return
|
||
|
||
sync_type = "保本/移动止损同步" if (position_info.get("breakevenStopSet") or position_info.get("trailingStopActivated")) else "SL/TP同步"
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} 开始同步止损/止盈至交易所 [{sync_type}]: "
|
||
f"止损触发价={float(stop_loss):.4f} 止盈触发价={float(take_profit):.4f}"
|
||
)
|
||
|
||
# 验证止损价格是否合理。保本/移动止损时:多单止损可≥入场价、空单止损可≤入场价,不得被改回亏损价
|
||
entry_price = position_info.get("entryPrice")
|
||
if entry_price:
|
||
try:
|
||
entry_price_val = float(entry_price)
|
||
stop_loss_val = float(stop_loss)
|
||
min_gap_pct = 0.005 # 最小 0.5% 距离,与 risk_manager 一致
|
||
# 多单止损≥入场价 = 保本/移动止损,有效;空单止损≤入场价 = 保本/移动止损,有效
|
||
if side == "BUY" and stop_loss_val >= entry_price_val:
|
||
# 保本或移动止损,保留不修正
|
||
pass
|
||
elif side == "SELL" and stop_loss_val <= entry_price_val:
|
||
pass
|
||
elif side == "BUY" and stop_loss_val < entry_price_val:
|
||
if stop_loss_val > entry_price_val * (1 - min_gap_pct):
|
||
safe_sl = entry_price_val * (1 - min_gap_pct)
|
||
logger.warning(
|
||
f"{symbol} 止损价({stop_loss_val:.8f})距入场过近(BUY),已修正为 {safe_sl:.8f}"
|
||
)
|
||
stop_loss = safe_sl
|
||
position_info["stopLoss"] = stop_loss
|
||
elif side == "SELL" and stop_loss_val > entry_price_val:
|
||
if stop_loss_val < entry_price_val * (1 + min_gap_pct):
|
||
safe_sl = entry_price_val * (1 + min_gap_pct)
|
||
logger.warning(
|
||
f"{symbol} 止损价({stop_loss_val:.8f})距入场过近(SELL),已修正为 {safe_sl:.8f}"
|
||
)
|
||
stop_loss = safe_sl
|
||
position_info["stopLoss"] = stop_loss
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} 验证止损价格时出错: {e}")
|
||
|
||
# ⚠️ 关键修复:挂单前先检查持仓是否真的存在,避免 -4509(没有持仓)和 -4061(positionSide 不匹配)
|
||
try:
|
||
positions = await self._get_open_positions()
|
||
live_position = next((p for p in positions if p.get('symbol') == symbol), None)
|
||
if not live_position:
|
||
logger.warning(f"{symbol} ⚠️ 持仓已不存在(可能已平仓),跳过挂止损/止盈单,避免 -4509/-4061 错误")
|
||
return
|
||
position_amt = float(live_position.get('positionAmt', 0) or 0)
|
||
if abs(position_amt) <= 0:
|
||
logger.warning(f"{symbol} ⚠️ 持仓数量为0,跳过挂止损/止盈单")
|
||
return
|
||
# 验证持仓方向是否匹配(对冲模式下需要检查 positionSide)
|
||
live_side = "BUY" if position_amt > 0 else "SELL"
|
||
if live_side != side:
|
||
logger.warning(f"{symbol} ⚠️ 持仓方向不匹配(本地记录: {side}, 实际: {live_side}),跳过挂单,避免 -4061")
|
||
return
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} 检查持仓存在性时出错,继续尝试挂单(可能误报): {e}")
|
||
|
||
# 防重复:先取消旧的保护单(仅取消特定类型,避免误伤普通挂单)
|
||
try:
|
||
await self.client.cancel_open_algo_orders_by_order_types(
|
||
symbol, {"STOP_MARKET", "TAKE_PROFIT_MARKET", "TRAILING_STOP_MARKET"}
|
||
)
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} 已取消旧保护单,准备挂新单 [{sync_type}] "
|
||
f"条件价: 止损={float(stop_loss):.4f} 止盈={float(take_profit):.4f}"
|
||
)
|
||
if sync_type == "保本/移动止损同步":
|
||
_log_trailing_stop_event(
|
||
self.account_id, symbol, "sync_to_exchange",
|
||
stop_loss=float(stop_loss), take_profit=float(take_profit), msg="准备挂新单"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"[账号{self.account_id}] {symbol} 取消旧保护单异常: {e},继续尝试挂新单")
|
||
|
||
# 取消后再次确认持仓仍存在(docs/bian:条件单触发平仓后,交易所会 EXPIRED 其他条件单;若先触发了 TP/SL 平仓,此处再挂会 -4509 或请求卡住)
|
||
try:
|
||
positions = await self._get_open_positions()
|
||
live_position = next((p for p in positions if p.get('symbol') == symbol), None)
|
||
if not live_position or abs(float(live_position.get('positionAmt', 0) or 0)) <= 0:
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} 取消旧保护单后持仓已不存在(可能已被条件单平仓),跳过挂新单"
|
||
)
|
||
return
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 取消后复检持仓失败: {e},继续尝试挂单")
|
||
|
||
# 获取当前价格(如果未提供):优先 WS 缓存(bookTicker/ticker24h)→ 持仓 markPrice → REST ticker
|
||
if current_price is None:
|
||
try:
|
||
# 1) 优先 Redis/WS:最优挂单中点价或 24h ticker(进程内不保留全量)
|
||
try:
|
||
try:
|
||
from .book_ticker_stream import get_book_ticker, get_book_ticker_from_redis
|
||
except ImportError:
|
||
from book_ticker_stream import get_book_ticker, get_book_ticker_from_redis
|
||
redis_cache = getattr(self.client, "redis_cache", None)
|
||
if redis_cache:
|
||
book = await get_book_ticker_from_redis(redis_cache, symbol)
|
||
else:
|
||
book = get_book_ticker(symbol)
|
||
if book and float(book.get("bidPrice", 0)) > 0 and float(book.get("askPrice", 0)) > 0:
|
||
mid = (float(book["bidPrice"]) + float(book["askPrice"])) / 2
|
||
current_price = mid
|
||
logger.debug(f"{symbol} 从 bookTicker 获取当前价格: {current_price}")
|
||
except Exception:
|
||
pass
|
||
if current_price is None:
|
||
try:
|
||
try:
|
||
from .ticker_24h_stream import (
|
||
get_tickers_24h_cache,
|
||
get_tickers_24h_from_redis,
|
||
is_ticker_24h_cache_fresh,
|
||
)
|
||
except ImportError:
|
||
from ticker_24h_stream import (
|
||
get_tickers_24h_cache,
|
||
get_tickers_24h_from_redis,
|
||
is_ticker_24h_cache_fresh,
|
||
)
|
||
if is_ticker_24h_cache_fresh(max_age_sec=120):
|
||
redis_cache = getattr(self.client, "redis_cache", None)
|
||
tickers = await get_tickers_24h_from_redis(redis_cache) if redis_cache else get_tickers_24h_cache()
|
||
t = tickers.get(symbol) if tickers else None
|
||
if t and t.get("price"):
|
||
current_price = float(t["price"])
|
||
logger.debug(f"{symbol} 从 ticker24h 获取当前价格: {current_price}")
|
||
except Exception:
|
||
pass
|
||
# 2) 持仓标记价格(MARK_PRICE,与止损单触发基准一致)
|
||
if current_price is None:
|
||
positions = await self._get_open_positions()
|
||
position = next((p for p in positions if p['symbol'] == symbol), None)
|
||
if position:
|
||
mark_price = position.get('markPrice')
|
||
if mark_price and float(mark_price) > 0:
|
||
current_price = float(mark_price)
|
||
logger.debug(f"{symbol} 从持仓获取标记价格: {current_price}")
|
||
# 3) REST:深度最优档中点(权重低)→ 24h ticker
|
||
if current_price is None:
|
||
depth = await self.client.get_depth(symbol, limit=5)
|
||
if depth:
|
||
bids = depth.get("bids") or []
|
||
asks = depth.get("asks") or []
|
||
if bids and asks and len(bids[0]) >= 1 and len(asks[0]) >= 1:
|
||
try:
|
||
mid = (float(bids[0][0]) + float(asks[0][0])) / 2
|
||
if mid > 0:
|
||
current_price = mid
|
||
logger.debug(f"{symbol} 从 depth REST 获取当前价格: {current_price}")
|
||
except (ValueError, TypeError, IndexError):
|
||
pass
|
||
if current_price is None:
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
if ticker:
|
||
current_price = ticker.get('price') and float(ticker['price']) or None
|
||
if current_price:
|
||
logger.debug(f"{symbol} 从 ticker REST 获取当前价格: {current_price}")
|
||
except Exception as e:
|
||
err_msg = getattr(e, "message", str(e)) or repr(e)
|
||
logger.warning(f"{symbol} 获取当前价格失败: {type(e).__name__}: {err_msg}")
|
||
|
||
# 如果仍然没有当前价格,记录警告
|
||
if current_price is None:
|
||
logger.warning(f"{symbol} ⚠️ 无法获取当前价格(已尝试 WS bookTicker/ticker24h、持仓、depth、REST ticker),止损单可能无法正确验证触发条件")
|
||
|
||
# 在挂止损单前,检查当前价格是否已经触发止损(避免 -2021 错误)
|
||
sl_failed_due_to_gte = False # GTE/持仓未就绪时仅打 WARNING,不刷 ERROR
|
||
if current_price and stop_loss:
|
||
try:
|
||
current_price_val = float(current_price)
|
||
stop_loss_val = float(stop_loss)
|
||
entry_price_val = float(entry_price) if entry_price else None
|
||
|
||
# 检查是否已经触发止损
|
||
if side == "BUY":
|
||
# 做多:当前价 <= 止损价,说明已触发止损
|
||
if current_price_val <= stop_loss_val:
|
||
entry_price_str = f"{entry_price_val:.8f}" if entry_price_val is not None else 'N/A'
|
||
logger.error(
|
||
f"{symbol} ⚠️ 当前价格({current_price_val:.8f})已触发止损价({stop_loss_val:.8f}),无法挂止损单,立即执行市价平仓保护!"
|
||
f" | 入场价: {entry_price_str}"
|
||
)
|
||
# 立即执行市价平仓
|
||
await self.close_position(symbol, reason='stop_loss')
|
||
return
|
||
else:
|
||
sl_order = await self.client.place_trigger_close_position_order(
|
||
symbol=symbol,
|
||
position_direction=side,
|
||
trigger_type="STOP_MARKET",
|
||
stop_price=stop_loss,
|
||
current_price=current_price,
|
||
working_type="MARK_PRICE",
|
||
client_algo_id=client_algo_id_sl,
|
||
)
|
||
elif side == "SELL":
|
||
# 做空:当前价 >= 止损价,说明已触发止损
|
||
if current_price_val >= stop_loss_val:
|
||
entry_price_str = f"{entry_price_val:.8f}" if entry_price_val is not None else 'N/A'
|
||
logger.error(
|
||
f"{symbol} ⚠️ 当前价格({current_price_val:.8f})已触发止损价({stop_loss_val:.8f}),无法挂止损单,立即执行市价平仓保护!"
|
||
f" | 入场价: {entry_price_str}"
|
||
)
|
||
# 立即执行市价平仓
|
||
await self.close_position(symbol, reason='stop_loss')
|
||
return
|
||
else:
|
||
sl_order = await self.client.place_trigger_close_position_order(
|
||
symbol=symbol,
|
||
position_direction=side,
|
||
trigger_type="STOP_MARKET",
|
||
stop_price=stop_loss,
|
||
current_price=current_price,
|
||
working_type="MARK_PRICE",
|
||
client_algo_id=client_algo_id_sl,
|
||
)
|
||
else:
|
||
sl_order = await self.client.place_trigger_close_position_order(
|
||
symbol=symbol,
|
||
position_direction=side,
|
||
trigger_type="STOP_MARKET",
|
||
stop_price=stop_loss,
|
||
current_price=current_price,
|
||
working_type="MARK_PRICE",
|
||
client_algo_id=client_algo_id_sl,
|
||
)
|
||
except AlgoOrderPositionUnavailableError:
|
||
sl_failed_due_to_gte = True
|
||
sl_order = None
|
||
except Exception as e:
|
||
# 检查是否是 -2021 (立即触发)
|
||
error_msg = str(e)
|
||
if "-2021" in error_msg or "immediately trigger" in error_msg:
|
||
logger.error(f"{symbol} ⚠️ 止损单会立即触发(-2021),视为已触发止损,立即执行市价平仓")
|
||
await self.close_position(symbol, reason='stop_loss')
|
||
return
|
||
|
||
logger.warning(f"{symbol} 检查止损触发条件时出错: {e},继续尝试挂单")
|
||
try:
|
||
sl_order = await self.client.place_trigger_close_position_order(
|
||
symbol=symbol,
|
||
position_direction=side,
|
||
trigger_type="STOP_MARKET",
|
||
stop_price=stop_loss,
|
||
current_price=current_price,
|
||
working_type="MARK_PRICE",
|
||
client_algo_id=client_algo_id_sl,
|
||
)
|
||
except AlgoOrderPositionUnavailableError:
|
||
sl_failed_due_to_gte = True
|
||
sl_order = None
|
||
except Exception as retry_e:
|
||
retry_msg = str(retry_e)
|
||
if "-2021" in retry_msg or "immediately trigger" in retry_msg:
|
||
logger.error(f"{symbol} ⚠️ 重试挂止损单会立即触发(-2021),立即执行市价平仓")
|
||
await self.close_position(symbol, reason='stop_loss')
|
||
return
|
||
logger.error(f"{symbol} 重试挂止损单失败: {retry_e}")
|
||
sl_order = None
|
||
else:
|
||
try:
|
||
sl_order = await self.client.place_trigger_close_position_order(
|
||
symbol=symbol,
|
||
position_direction=side,
|
||
trigger_type="STOP_MARKET",
|
||
stop_price=stop_loss,
|
||
current_price=current_price,
|
||
working_type="MARK_PRICE",
|
||
client_algo_id=client_algo_id_sl,
|
||
)
|
||
except AlgoOrderPositionUnavailableError:
|
||
sl_failed_due_to_gte = True
|
||
sl_order = None
|
||
except Exception as e:
|
||
error_msg = str(e)
|
||
if "-2021" in error_msg or "immediately trigger" in error_msg:
|
||
logger.error(f"{symbol} ⚠️ 止损单会立即触发(-2021),视为已触发止损,立即执行市价平仓")
|
||
await self.close_position(symbol, reason='stop_loss')
|
||
return
|
||
logger.error(f"{symbol} 挂止损单失败: {e}")
|
||
sl_order = None
|
||
|
||
if sl_order and entry_order_id:
|
||
algo_id = sl_order.get("algoId")
|
||
if algo_id is not None:
|
||
try:
|
||
rc = getattr(self.client, "redis_cache", None)
|
||
if rc:
|
||
await rc.set(
|
||
f"ats:algo2entry:{self.account_id}:{algo_id}",
|
||
str(entry_order_id),
|
||
ttl=7 * 86400,
|
||
)
|
||
except Exception:
|
||
pass
|
||
if sl_order:
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} ✓ 止损单已成功挂到交易所: {sl_order.get('algoId', 'N/A')} "
|
||
f"触发价={float(stop_loss):.4f}"
|
||
)
|
||
else:
|
||
if sl_failed_due_to_gte:
|
||
logger.warning(f"{symbol} 条件单被拒(持仓未就绪或已平),跳过交易所止损单,将依赖 WebSocket 监控")
|
||
else:
|
||
logger.error(f"{symbol} ❌ 止损单挂单失败!将依赖WebSocket监控,但可能无法及时止损")
|
||
logger.error(f" 止损价格: {stop_loss:.8f}")
|
||
logger.error(f" 当前价格: {current_price if current_price else '无(已尝试 WS bookTicker/ticker24h、持仓、REST)'}")
|
||
logger.error(f" 持仓方向: {side}")
|
||
logger.error(f" ⚠️ 警告: 没有交易所级别的止损保护,如果系统崩溃或网络中断,可能无法及时止损!")
|
||
logger.error(f" 💡 建议: 检查止损价格计算是否正确,或手动在币安界面设置止损")
|
||
|
||
# ⚠️ 关键修复:止损单挂单失败后,立即检查当前价格是否已触发止损
|
||
# 如果已触发,立即执行市价平仓,避免亏损扩大
|
||
if current_price and stop_loss:
|
||
try:
|
||
current_price_val = float(current_price)
|
||
stop_loss_val = float(stop_loss)
|
||
entry_price_val = float(entry_price) if entry_price else None
|
||
|
||
# 检查是否已触发止损
|
||
should_close = False
|
||
if side == "BUY":
|
||
# 做多:当前价 <= 止损价,触发止损
|
||
if current_price_val <= stop_loss_val:
|
||
should_close = True
|
||
elif side == "SELL":
|
||
# 做空:当前价 >= 止损价,触发止损
|
||
if current_price_val >= stop_loss_val:
|
||
should_close = True
|
||
|
||
if should_close:
|
||
entry_price_str = f"{entry_price_val:.8f}" if entry_price_val is not None else 'N/A'
|
||
logger.error("=" * 80)
|
||
logger.error(f"{symbol} ⚠️ 止损单挂单失败,但当前价格已触发止损,立即执行市价平仓保护!")
|
||
logger.error(f" 当前价格: {current_price_val:.8f}")
|
||
logger.error(f" 止损价格: {stop_loss_val:.8f}")
|
||
logger.error(f" 入场价格: {entry_price_str}")
|
||
logger.error(f" 持仓方向: {side}")
|
||
logger.error(f" 价格偏离: {abs(current_price_val - stop_loss_val):.8f} ({abs(current_price_val - stop_loss_val)/stop_loss_val*100:.2f}%)")
|
||
logger.error("=" * 80)
|
||
# 立即执行市价平仓
|
||
if await self.close_position(symbol, reason='stop_loss'):
|
||
logger.info(f"{symbol} ✓ 止损平仓成功(止损单挂单失败后的保护措施)")
|
||
return
|
||
else:
|
||
# 未触发止损,但需要增强WebSocket监控
|
||
logger.warning(f"{symbol} ⚠️ 止损单挂单失败,当前价格未触发止损,将依赖WebSocket监控")
|
||
logger.warning(f" 当前价格: {current_price_val:.8f}, 止损价格: {stop_loss_val:.8f}")
|
||
logger.warning(f" 价格距离止损: {abs(current_price_val - stop_loss_val):.8f} ({abs(current_price_val - stop_loss_val)/stop_loss_val*100:.2f}%)")
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} 检查止损触发条件时出错: {e},继续依赖WebSocket监控")
|
||
|
||
# 在挂止盈单前,检查当前价格是否已经触发止盈
|
||
if current_price and take_profit:
|
||
try:
|
||
current_price_val = float(current_price)
|
||
take_profit_val = float(take_profit)
|
||
|
||
# 检查是否已经触发止盈
|
||
# ⚠️ 关键修复:增加0.5%的缓冲,避免因价格轻微触及或市场波动导致的过早止盈
|
||
# 如果价格只是刚好碰到止盈价,不立即触发,而是尝试挂单或让WebSocket监控决定
|
||
triggered_tp = False
|
||
buffer_pct = 0.005 # 0.5% buffer
|
||
|
||
if side == "BUY":
|
||
# 做多:当前价 >= 止盈价 * (1 + buffer)
|
||
if current_price_val >= take_profit_val * (1 + buffer_pct):
|
||
triggered_tp = True
|
||
elif side == "SELL":
|
||
# 做空:当前价 <= 止盈价 * (1 - buffer)
|
||
if current_price_val <= take_profit_val * (1 - buffer_pct):
|
||
triggered_tp = True
|
||
|
||
if triggered_tp:
|
||
logger.info(f"{symbol} 🎯 当前价格({current_price_val:.8f})已显著超过止盈价({take_profit_val:.8f}),立即执行市价止盈!")
|
||
await self.close_position(symbol, reason='take_profit')
|
||
return
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 检查止盈触发条件时出错: {e}")
|
||
|
||
try:
|
||
tp_order = await self.client.place_trigger_close_position_order(
|
||
symbol=symbol,
|
||
position_direction=side,
|
||
trigger_type="TAKE_PROFIT_MARKET",
|
||
stop_price=take_profit,
|
||
current_price=current_price,
|
||
working_type="MARK_PRICE",
|
||
client_algo_id=client_algo_id_tp,
|
||
)
|
||
except Exception as e:
|
||
# 处理 -2021: Order would immediately trigger
|
||
error_msg = str(e)
|
||
if "-2021" in error_msg or "immediately trigger" in error_msg:
|
||
# ⚠️ 关键修复:如果止盈单会立即触发,不立即平仓,而是依赖WebSocket监控
|
||
# 这样可以避免因价格微小波动导致的过早止盈,让利润奔跑(WebSocket有最小盈利检查)
|
||
logger.warning(f"{symbol} ⚠️ 止盈单会立即触发(-2021),跳过挂交易所止盈单,将依赖WebSocket监控执行止盈")
|
||
# await self.close_position(symbol, reason='take_profit') <-- 已移除立即平仓
|
||
return
|
||
else:
|
||
logger.warning(f"{symbol} 挂止盈单失败: {e}")
|
||
tp_order = None
|
||
if tp_order and entry_order_id:
|
||
algo_id = tp_order.get("algoId")
|
||
if algo_id is not None:
|
||
try:
|
||
rc = getattr(self.client, "redis_cache", None)
|
||
if rc:
|
||
await rc.set(
|
||
f"ats:algo2entry:{self.account_id}:{algo_id}",
|
||
str(entry_order_id),
|
||
ttl=7 * 86400,
|
||
)
|
||
except Exception:
|
||
pass
|
||
if tp_order:
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} ✓ 止盈单已成功挂到交易所: {tp_order.get('algoId', 'N/A')} "
|
||
f"触发价={float(take_profit):.4f}"
|
||
)
|
||
else:
|
||
logger.warning(f"{symbol} ⚠️ 止盈单挂单失败,将依赖WebSocket监控")
|
||
|
||
try:
|
||
# Algo 接口返回 algoId
|
||
position_info["exchangeSlOrderId"] = sl_order.get("algoId") if isinstance(sl_order, dict) else None
|
||
except Exception:
|
||
position_info["exchangeSlOrderId"] = None
|
||
try:
|
||
position_info["exchangeTpOrderId"] = tp_order.get("algoId") if isinstance(tp_order, dict) else None
|
||
except Exception:
|
||
position_info["exchangeTpOrderId"] = None
|
||
|
||
if position_info.get("exchangeSlOrderId") or position_info.get("exchangeTpOrderId"):
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} 已挂币安保护单 [{sync_type}]: "
|
||
f"SL={position_info.get('exchangeSlOrderId') or '-'} 触发价={float(stop_loss):.4f} | "
|
||
f"TP={position_info.get('exchangeTpOrderId') or '-'} 触发价={float(take_profit):.4f}"
|
||
)
|
||
if sync_type == "保本/移动止损同步":
|
||
_log_trailing_stop_event(
|
||
self.account_id, symbol, "sync_to_exchange_ok",
|
||
stop_loss=float(stop_loss), take_profit=float(take_profit), msg="已挂币安保护单"
|
||
)
|
||
logger.info(f"[账号{self.account_id}] {symbol} 止损/止盈同步至交易所完成")
|
||
else:
|
||
logger.warning(f"[账号{self.account_id}] {symbol} 同步结束但未挂上保护单(止损或止盈挂单均失败),将依赖 WebSocket 监控")
|
||
|
||
async def check_stop_loss_take_profit(self) -> List[str]:
|
||
"""
|
||
检查止损止盈
|
||
|
||
Returns:
|
||
需要平仓的交易对列表
|
||
"""
|
||
closed_positions = []
|
||
|
||
try:
|
||
# 获取当前持仓
|
||
positions = await self._get_open_positions()
|
||
position_dict = {p['symbol']: p for p in positions}
|
||
|
||
for symbol, position_info in list(self.active_positions.items()):
|
||
if symbol not in position_dict:
|
||
# 持仓已不存在,移除记录
|
||
del self.active_positions[symbol]
|
||
continue
|
||
|
||
current_position = position_dict[symbol]
|
||
# 统一转为 float,避免 Decimal 与 float 运算报错(DB/补建可能返回 Decimal)
|
||
entry_price = float(position_info['entryPrice'])
|
||
quantity = float(position_info['quantity'])
|
||
# 获取当前标记价格
|
||
current_price = current_position.get('markPrice', 0)
|
||
if current_price == 0:
|
||
# 如果标记价格为0,尝试从ticker获取
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
if ticker:
|
||
current_price = float(ticker.get('price', 0) or 0)
|
||
else:
|
||
current_price = entry_price
|
||
else:
|
||
current_price = float(current_price)
|
||
|
||
# 计算当前盈亏(基于保证金)
|
||
leverage = float(position_info.get('leverage', 10) or 10)
|
||
position_value = entry_price * quantity
|
||
margin = position_value / leverage if leverage > 0 else position_value
|
||
|
||
# 计算盈亏金额
|
||
if position_info['side'] == 'BUY':
|
||
pnl_amount = (current_price - entry_price) * quantity
|
||
else:
|
||
pnl_amount = (entry_price - current_price) * quantity
|
||
|
||
# 计算盈亏百分比(相对于保证金,与币安一致)
|
||
pnl_percent_margin = (pnl_amount / margin * 100) if margin > 0 else 0
|
||
|
||
# 也计算价格百分比(用于显示和移动止损)
|
||
if position_info['side'] == 'BUY':
|
||
pnl_percent_price = ((current_price - entry_price) / entry_price) * 100
|
||
else:
|
||
pnl_percent_price = ((entry_price - current_price) / entry_price) * 100
|
||
|
||
if bool(config.TRADING_CONFIG.get('POSITION_DETAILED_LOG_ENABLED', False)):
|
||
stop_loss_snapshot = position_info.get('stopLoss')
|
||
take_profit_1_snapshot = position_info.get('takeProfit1')
|
||
take_profit_2_snapshot = position_info.get('takeProfit2', position_info.get('takeProfit'))
|
||
logger.info(
|
||
f"{symbol} [持仓监控快照] side={position_info['side']} | entry={entry_price:.6f} | "
|
||
f"price={current_price:.6f} | sl={stop_loss_snapshot if stop_loss_snapshot is not None else '-'} | "
|
||
f"tp1={take_profit_1_snapshot if take_profit_1_snapshot is not None else '-'} | "
|
||
f"tp2={take_profit_2_snapshot if take_profit_2_snapshot is not None else '-'} | "
|
||
f"roe_margin={pnl_percent_margin:.2f}% | price_change={pnl_percent_price:.2f}%"
|
||
)
|
||
|
||
# 更新最大盈利(基于保证金)及最后一次创新高时间(用于滞涨早止盈)
|
||
max_profit = float(position_info.get('maxProfit', 0) or 0)
|
||
if pnl_percent_margin > max_profit:
|
||
position_info['maxProfit'] = pnl_percent_margin
|
||
position_info['lastNewHighTs'] = time.time()
|
||
|
||
# 滞涨早止盈:曾涨到约 N% 后 X 小时内未创新高 → 分批减仓 + 抬止损(与早止盈/移动止损并列,不冲突)
|
||
stagnation_enabled = bool(config.TRADING_CONFIG.get('STAGNATION_EARLY_EXIT_ENABLED', False))
|
||
if stagnation_enabled and not position_info.get('stagnationExitTriggered', False):
|
||
min_runup = float(config.TRADING_CONFIG.get('STAGNATION_MIN_RUNUP_PCT', 10) or 10)
|
||
stall_hours = float(config.TRADING_CONFIG.get('STAGNATION_NO_NEW_HIGH_HOURS', 3) or 3)
|
||
partial_pct = float(config.TRADING_CONFIG.get('STAGNATION_PARTIAL_CLOSE_PCT', 0.5) or 0.5)
|
||
lock_pct = float(config.TRADING_CONFIG.get('STAGNATION_LOCK_PCT', 5) or 5)
|
||
last_high_ts = position_info.get('lastNewHighTs')
|
||
if last_high_ts is not None and max_profit >= min_runup and pnl_percent_margin < max_profit:
|
||
stall_sec = stall_hours * 3600
|
||
if (time.time() - last_high_ts) >= stall_sec:
|
||
logger.info(
|
||
f"{symbol} [滞涨早止盈] 曾达浮盈{max_profit:.2f}%≥{min_runup}%,"
|
||
f"已{stall_hours:.1f}h未创新高,执行分批减仓+抬止损"
|
||
)
|
||
try:
|
||
close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY'
|
||
close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT'
|
||
remaining_qty = float(position_info.get('remainingQuantity', quantity))
|
||
partial_quantity = remaining_qty * partial_pct
|
||
live_amt = await self._get_live_position_amt(symbol, position_side=close_position_side)
|
||
if live_amt is not None and abs(live_amt) > 0:
|
||
partial_quantity = min(partial_quantity, abs(live_amt))
|
||
partial_quantity = await self._adjust_close_quantity(symbol, partial_quantity)
|
||
if partial_quantity > 0:
|
||
partial_order = await self.client.place_order(
|
||
symbol=symbol, side=close_side, quantity=partial_quantity,
|
||
order_type='MARKET', reduce_only=True, position_side=close_position_side,
|
||
)
|
||
if partial_order:
|
||
position_info['partialProfitTaken'] = True
|
||
position_info['remainingQuantity'] = remaining_qty - partial_quantity
|
||
logger.info(f"{symbol} [滞涨早止盈] 部分平仓{partial_quantity:.4f},剩余{position_info['remainingQuantity']:.4f}")
|
||
remaining_after = float(position_info.get('remainingQuantity', quantity))
|
||
lev = float(position_info.get('leverage', 10) or 10)
|
||
rem_margin = (entry_price * remaining_after) / lev if lev > 0 else (entry_price * remaining_after)
|
||
lock_pct_use = max(lock_pct, max_profit / 2.0)
|
||
side_here = position_info['side']
|
||
new_sl = self._stop_price_to_lock_pct(entry_price, side_here, rem_margin, remaining_after, lock_pct_use)
|
||
breakeven = self._breakeven_stop_price(entry_price, side_here)
|
||
current_sl = position_info.get('stopLoss')
|
||
set_sl = (side_here == 'BUY' and (current_sl is None or new_sl > current_sl) and new_sl >= breakeven) or (
|
||
side_here == 'SELL' and (current_sl is None or new_sl < current_sl) and new_sl <= breakeven)
|
||
if set_sl:
|
||
position_info['stopLoss'] = new_sl
|
||
logger.info(f"{symbol} [滞涨早止盈] 剩余仓位止损上移至 {new_sl:.4f},锁定约{lock_pct_use:.1f}%利润")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
except Exception as sync_e:
|
||
logger.warning(f"{symbol} [滞涨早止盈] 同步止损至交易所失败: {sync_e}")
|
||
position_info['stagnationExitTriggered'] = True
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} [滞涨早止盈] 执行异常: {e}", exc_info=False)
|
||
|
||
# 移动止损逻辑(盈利后保护利润,基于保证金)
|
||
# 每次检查时从Redis重新加载配置,确保配置修改能即时生效
|
||
try:
|
||
if config._config_manager:
|
||
config._config_manager.reload_from_redis()
|
||
config.TRADING_CONFIG = config._get_trading_config()
|
||
except Exception as e:
|
||
logger.debug(f"从Redis重新加载配置失败: {e}")
|
||
|
||
# 山寨币早止盈:盈利达标且持仓满 N 小时则市价止盈,不必等挂单 TP,避免反转回吐
|
||
early_tp_enabled = config.TRADING_CONFIG.get('EARLY_TAKE_PROFIT_ENABLED', True)
|
||
if early_tp_enabled and pnl_percent_margin > 0:
|
||
min_pnl_pct = float(config.TRADING_CONFIG.get('EARLY_TAKE_PROFIT_MIN_PNL_PCT', 10) or 10)
|
||
min_hold_hours = float(config.TRADING_CONFIG.get('EARLY_TAKE_PROFIT_MIN_HOLD_HOURS', 2) or 2)
|
||
hold_hours = 0.0
|
||
entry_time = position_info.get('entryTime')
|
||
if entry_time is not None:
|
||
try:
|
||
if isinstance(entry_time, datetime):
|
||
hold_hours = (get_beijing_time() - entry_time).total_seconds() / 3600.0
|
||
elif isinstance(entry_time, str) and len(entry_time) >= 19:
|
||
entry_dt = datetime.strptime(entry_time[:19], '%Y-%m-%d %H:%M:%S')
|
||
hold_hours = (get_beijing_time() - entry_dt).total_seconds() / 3600.0
|
||
else:
|
||
hold_hours = (time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0)) / 3600.0
|
||
except Exception:
|
||
pass
|
||
if pnl_percent_margin >= min_pnl_pct and hold_hours >= min_hold_hours:
|
||
logger.info(
|
||
f"{symbol} [早止盈] 盈利{pnl_percent_margin:.2f}%≥{min_pnl_pct}% 且持仓{hold_hours:.1f}h≥{min_hold_hours}h,市价止盈离场(山寨币不必久拿)"
|
||
)
|
||
if await self.close_position(symbol, reason='early_take_profit'):
|
||
closed_positions.append(symbol)
|
||
continue
|
||
|
||
# 检查是否启用移动止损(默认False,需要显式启用)
|
||
profit_protection_enabled = bool(config.TRADING_CONFIG.get('PROFIT_PROTECTION_ENABLED', True))
|
||
use_trailing = profit_protection_enabled and bool(config.TRADING_CONFIG.get('USE_TRAILING_STOP', True))
|
||
if use_trailing:
|
||
logger.debug(f"{symbol} [移动止损] 已启用,将检查移动止损逻辑")
|
||
else:
|
||
if not profit_protection_enabled:
|
||
logger.debug(f"{symbol} [移动止损/保本] 已禁用(PROFIT_PROTECTION_ENABLED=False)")
|
||
else:
|
||
logger.debug(f"{symbol} [移动止损] 已禁用(USE_TRAILING_STOP=False),跳过移动止损检查")
|
||
if use_trailing:
|
||
trailing_activation = config.TRADING_CONFIG.get('TRAILING_STOP_ACTIVATION', 0.10) # 相对于保证金,默认 10%
|
||
trailing_protect = config.TRADING_CONFIG.get('TRAILING_STOP_PROTECT', 0.01) # 相对于保证金
|
||
|
||
# ⚠️ 关键修复:配置值格式转换(兼容百分比形式和比例形式)
|
||
# 如果值>1,认为是百分比形式,转换为比例形式
|
||
if trailing_activation > 1:
|
||
trailing_activation = trailing_activation / 100.0
|
||
if trailing_protect > 1:
|
||
trailing_protect = trailing_protect / 100.0
|
||
lock_pct = config.TRADING_CONFIG.get('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT')
|
||
if lock_pct is None:
|
||
lock_pct = 0.03
|
||
if lock_pct and lock_pct > 1:
|
||
lock_pct = lock_pct / 100.0
|
||
|
||
if not position_info.get('trailingStopActivated', False):
|
||
# 盈利达一定比例时尽早将止损移至含手续费保本(与实时监控一致)
|
||
if lock_pct > 0 and not position_info.get('breakevenStopSet', False) and pnl_percent_margin >= lock_pct * 100:
|
||
breakeven = self._breakeven_stop_price(entry_price, side)
|
||
current_sl = position_info.get('stopLoss')
|
||
set_be = (side == 'BUY' and (current_sl is None or current_sl < breakeven)) or (side == 'SELL' and (current_sl is None or current_sl > breakeven))
|
||
if set_be:
|
||
position_info['stopLoss'] = breakeven
|
||
position_info['breakevenStopSet'] = True
|
||
logger.info(f"{symbol} [定时检查] 盈利{pnl_percent_margin:.2f}%≥{lock_pct*100:.0f}%,止损已移至含手续费保本价 {breakeven:.4f}")
|
||
_log_trailing_stop_event(self.account_id, symbol, "breakeven_set", breakeven=breakeven, pnl_pct=pnl_percent_margin, source="定时检查")
|
||
logger.info(f"{symbol} [定时检查] 尝试将保本止损同步至交易所")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
_log_trailing_stop_event(self.account_id, symbol, "breakeven_sync_ok", breakeven=breakeven, source="定时检查")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "breakeven_sync_fail", breakeven=breakeven, error=str(sync_e), source="定时检查")
|
||
logger.warning(
|
||
f"{symbol} [定时检查] 同步保本止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
# 盈利超过阈值后(相对于保证金),激活移动止损
|
||
if pnl_percent_margin > trailing_activation * 100:
|
||
position_info['trailingStopActivated'] = True
|
||
breakeven = self._breakeven_stop_price(entry_price, side)
|
||
position_info['stopLoss'] = breakeven
|
||
logger.info(
|
||
f"{symbol} 移动止损激活: 止损移至含手续费保本价 {breakeven:.4f} (入场: {entry_price:.4f}) "
|
||
f"(盈利: {pnl_percent_margin:.2f}% of margin)"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_activated", breakeven=breakeven, pnl_pct=pnl_percent_margin, source="定时检查")
|
||
logger.info(f"{symbol} [定时检查] 尝试将移动止损同步至交易所")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=breakeven, source="定时检查")
|
||
logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=breakeven, error=str(sync_e), source="定时检查")
|
||
logger.warning(
|
||
f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
else:
|
||
# 盈利超过阈值后,止损移至保护利润位(基于保证金)
|
||
# 如果已经部分止盈,使用剩余仓位计算
|
||
if position_info.get('partialProfitTaken', False):
|
||
remaining_quantity = position_info.get('remainingQuantity', quantity)
|
||
remaining_margin = (entry_price * remaining_quantity) / leverage if leverage > 0 else (entry_price * remaining_quantity)
|
||
protect_amount = max(remaining_margin * trailing_protect, self._min_protect_amount_for_fees(remaining_margin, leverage))
|
||
if position_info['side'] == 'BUY':
|
||
remaining_pnl = (current_price - entry_price) * remaining_quantity
|
||
else:
|
||
remaining_pnl = (entry_price - current_price) * remaining_quantity
|
||
if position_info['side'] == 'BUY':
|
||
new_stop_loss = entry_price + (remaining_pnl - protect_amount) / remaining_quantity
|
||
new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'BUY'))
|
||
current_sl = float(position_info['stopLoss']) if position_info.get('stopLoss') is not None else None
|
||
if current_sl is None or new_stop_loss > current_sl:
|
||
position_info['stopLoss'] = new_stop_loss
|
||
logger.info(
|
||
f"{symbol} 移动止损更新(剩余仓位): {new_stop_loss:.4f} "
|
||
f"(保护{trailing_protect*100:.1f}% of remaining margin = {protect_amount:.4f} USDT, "
|
||
f"剩余数量: {remaining_quantity:.4f})"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="定时检查-剩余仓位")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="定时检查")
|
||
logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="定时检查")
|
||
logger.warning(
|
||
f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
else:
|
||
# 做空锁利:止损下移,锁住 protect_amount 利润。(entry - stop)*q = protect → stop = entry - protect/q
|
||
new_stop_loss = entry_price - protect_amount / remaining_quantity
|
||
new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'SELL'))
|
||
current_sl = float(position_info['stopLoss']) if position_info.get('stopLoss') is not None else None
|
||
# 仅当新止损低于当前止损(下移锁利)且高于市价(锁住的是利润)时更新
|
||
if (current_sl is None or new_stop_loss < current_sl) and new_stop_loss > current_price and remaining_pnl > 0:
|
||
position_info['stopLoss'] = new_stop_loss
|
||
logger.info(
|
||
f"{symbol} 移动止损更新(剩余仓位-做空锁利): {new_stop_loss:.4f} "
|
||
f"(保护{trailing_protect*100:.1f}% of remaining margin = {protect_amount:.4f} USDT, "
|
||
f"剩余数量: {remaining_quantity:.4f})"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="定时检查-剩余仓位")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="定时检查")
|
||
logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="定时检查")
|
||
logger.warning(
|
||
f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
else:
|
||
# 未部分止盈,使用原始仓位计算;保护金额至少覆盖手续费
|
||
protect_amount = max(margin * trailing_protect, self._min_protect_amount_for_fees(margin, leverage))
|
||
if position_info['side'] == 'BUY':
|
||
new_stop_loss = entry_price + (pnl_amount - protect_amount) / quantity
|
||
new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'BUY'))
|
||
current_sl = float(position_info['stopLoss']) if position_info.get('stopLoss') is not None else None
|
||
if current_sl is None or new_stop_loss > current_sl:
|
||
position_info['stopLoss'] = new_stop_loss
|
||
logger.info(
|
||
f"{symbol} 移动止损更新: {new_stop_loss:.4f} "
|
||
f"(保护{trailing_protect*100:.1f}% of margin = {protect_amount:.4f} USDT)"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="定时检查")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="定时检查")
|
||
logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="定时检查")
|
||
logger.warning(
|
||
f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
else:
|
||
# 做空锁利:止损下移(从初始高位往市价方向移),锁住 protect_amount。(entry - stop)*q = protect → stop = entry - protect/q
|
||
new_stop_loss = entry_price - protect_amount / quantity
|
||
new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'SELL'))
|
||
current_sl = float(position_info['stopLoss']) if position_info.get('stopLoss') is not None else None
|
||
# 仅当新止损低于当前止损(下移锁利)且高于市价(锁住的是利润)时更新
|
||
if (current_sl is None or new_stop_loss < current_sl) and new_stop_loss > current_price and pnl_amount > 0:
|
||
position_info['stopLoss'] = new_stop_loss
|
||
logger.info(
|
||
f"{symbol} 移动止损更新(做空锁利): {new_stop_loss:.4f} "
|
||
f"(保护{trailing_protect*100:.1f}% of margin = {protect_amount:.4f} USDT)"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="定时检查")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="定时检查")
|
||
logger.info(f"{symbol} [定时检查] 已同步移动止损至交易所")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="定时检查")
|
||
logger.warning(
|
||
f"{symbol} [定时检查] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
|
||
# 检查止损(使用更新后的止损价,基于保证金收益比)
|
||
# ⚠️ 重要:止损检查应该在时间锁之前,止损必须立即执行
|
||
stop_loss_raw = position_info.get('stopLoss')
|
||
stop_loss = float(stop_loss_raw) if stop_loss_raw is not None else None
|
||
should_close_due_to_sl = False
|
||
exit_reason_sl = None
|
||
|
||
if stop_loss is None:
|
||
logger.warning(f"{symbol} 止损价未设置,跳过止损检查")
|
||
elif stop_loss is not None:
|
||
# 计算止损对应的保证金百分比目标
|
||
if position_info['side'] == 'BUY':
|
||
stop_loss_amount = (entry_price - stop_loss) * quantity
|
||
else: # SELL
|
||
stop_loss_amount = (stop_loss - entry_price) * quantity
|
||
|
||
stop_loss_pct_margin = (stop_loss_amount / margin * 100) if margin > 0 else 0
|
||
|
||
# 直接比较当前盈亏百分比与止损目标(基于保证金)
|
||
if pnl_percent_margin <= -stop_loss_pct_margin:
|
||
should_close_due_to_sl = True
|
||
# ⚠️ 2026-01-27优化:如果已部分止盈,细分状态
|
||
partial_profit_taken = position_info.get('partialProfitTaken', False)
|
||
if partial_profit_taken:
|
||
if position_info.get('trailingStopActivated'):
|
||
exit_reason_sl = 'take_profit_partial_then_trailing_stop'
|
||
else:
|
||
exit_reason_sl = 'take_profit_partial_then_stop'
|
||
else:
|
||
exit_reason_sl = 'trailing_stop' if position_info.get('trailingStopActivated') else 'stop_loss'
|
||
|
||
# 计算持仓时间
|
||
entry_time = position_info.get('entryTime')
|
||
hold_time_minutes = 0
|
||
if entry_time:
|
||
try:
|
||
if isinstance(entry_time, datetime):
|
||
hold_time_sec = int((get_beijing_time() - entry_time).total_seconds())
|
||
else:
|
||
hold_time_sec = int(time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0))
|
||
hold_time_minutes = hold_time_sec / 60.0
|
||
except Exception:
|
||
hold_time_minutes = 0
|
||
|
||
# 详细诊断日志:记录平仓时的所有关键信息
|
||
logger.warning("=" * 80)
|
||
logger.warning(f"{symbol} [平仓诊断日志] ===== 触发止损平仓 =====")
|
||
logger.warning(f" 平仓原因: {exit_reason_sl}")
|
||
logger.warning(f" 入场价格: {entry_price:.6f} USDT")
|
||
logger.warning(f" 当前价格: {current_price:.4f} USDT")
|
||
logger.warning(f" 止损价格: {stop_loss:.4f} USDT")
|
||
logger.warning(f" 持仓数量: {quantity:.4f}")
|
||
logger.warning(f" 持仓时间: {hold_time_minutes:.1f} 分钟")
|
||
logger.warning(f" 入场时间: {entry_time}")
|
||
logger.warning(f" 当前盈亏: {pnl_percent_margin:.2f}% of margin")
|
||
logger.warning(f" 止损目标: -{stop_loss_pct_margin*100:.2f}% of margin")
|
||
logger.warning(f" 亏损金额: {abs(pnl_amount):.4f} USDT")
|
||
if position_info.get('trailingStopActivated'):
|
||
logger.warning(f" 移动止损: 已激活(从初始止损 {position_info.get('initialStopLoss', 'N/A')} 调整)")
|
||
logger.warning("=" * 80)
|
||
|
||
# 止损必须立即执行,不受时间锁限制
|
||
# 更新数据库
|
||
# 更新数据库
|
||
if DB_AVAILABLE:
|
||
trade_id = position_info.get('tradeId')
|
||
if trade_id:
|
||
try:
|
||
# 计算持仓持续时间
|
||
entry_time = position_info.get('entryTime')
|
||
duration_minutes = None
|
||
if entry_time:
|
||
try:
|
||
if isinstance(entry_time, str):
|
||
entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S')
|
||
else:
|
||
entry_dt = entry_time
|
||
exit_dt = get_beijing_time() # 使用北京时间计算持续时间
|
||
duration = exit_dt - entry_dt
|
||
duration_minutes = int(duration.total_seconds() / 60)
|
||
except Exception as e:
|
||
logger.debug(f"计算持仓持续时间失败: {e}")
|
||
|
||
# 获取策略类型
|
||
strategy_type = position_info.get('strategyType', 'trend_following')
|
||
|
||
Trade.update_exit(
|
||
trade_id=trade_id,
|
||
exit_price=current_price,
|
||
exit_reason=exit_reason_sl,
|
||
pnl=pnl_amount,
|
||
pnl_percent=pnl_percent_margin,
|
||
strategy_type=strategy_type,
|
||
duration_minutes=duration_minutes
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"更新止损记录失败: {e}")
|
||
# ⚠️ 关键修复:止损必须立即执行,不受时间锁限制
|
||
if await self.close_position(symbol, reason=exit_reason_sl):
|
||
closed_positions.append(symbol)
|
||
continue # 止损已执行,跳过后续止盈检查
|
||
|
||
# 检查分步止盈(基于保证金收益比)
|
||
# ⚠️ 优化:已移除止盈时间锁,止盈可以立即执行(与止损一致)
|
||
# 理由:1) 止损已不受时间锁限制,止盈也应该一致
|
||
# 2) 分步止盈策略本身已提供利润保护(50%在1:1止盈,剩余保本)
|
||
# 3) 交易所级别止盈单已提供保护
|
||
# 4) 及时止盈可以保护利润,避免价格回落
|
||
take_profit_1_raw = position_info.get('takeProfit1') # 第一目标(盈亏比1:1)
|
||
take_profit_1 = float(take_profit_1_raw) if take_profit_1_raw is not None else None
|
||
take_profit_2_raw = position_info.get('takeProfit2', position_info.get('takeProfit')) # 第二目标
|
||
take_profit_2 = float(take_profit_2_raw) if take_profit_2_raw is not None else None
|
||
partial_profit_taken = position_info.get('partialProfitTaken', False)
|
||
remaining_quantity = float(position_info.get('remainingQuantity', quantity))
|
||
|
||
# 第一目标:TAKE_PROFIT_1_PERCENT 止盈(默认15%保证金),了结50%仓位
|
||
# ✅ 已移除时间锁限制,可以立即执行
|
||
if not partial_profit_taken and take_profit_1 is not None:
|
||
# 直接使用配置的 TAKE_PROFIT_1_PERCENT,与开仓时计算的第一目标一致
|
||
take_profit_1_pct_margin_config = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20)
|
||
# 兼容百分比形式和比例形式
|
||
if take_profit_1_pct_margin_config > 1:
|
||
take_profit_1_pct_margin_config = take_profit_1_pct_margin_config / 100.0
|
||
take_profit_1_pct_margin = take_profit_1_pct_margin_config * 100 # 转换为百分比
|
||
|
||
# 直接比较当前盈亏百分比与第一目标(基于保证金,使用配置值)
|
||
if pnl_percent_margin >= take_profit_1_pct_margin:
|
||
logger.info(
|
||
f"{symbol} 触发第一目标止盈(盈亏比1:1,基于保证金): "
|
||
f"当前盈亏={pnl_percent_margin:.2f}% of margin >= 目标={take_profit_1_pct_margin:.2f}% of margin | "
|
||
f"当前价={current_price:.4f}, 目标价={take_profit_1:.4f}"
|
||
)
|
||
# 部分平仓50%
|
||
partial_quantity = quantity * 0.5
|
||
try:
|
||
# 部分平仓
|
||
close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY'
|
||
close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT'
|
||
# 二次校验并截断数量,避免 reduceOnly 被拒绝(-2022)
|
||
live_amt = await self._get_live_position_amt(symbol, position_side=close_position_side)
|
||
if live_amt is None or abs(live_amt) <= 0:
|
||
logger.warning(f"{symbol} 部分止盈:实时持仓已为0,跳过部分平仓")
|
||
continue
|
||
partial_quantity = min(partial_quantity, abs(live_amt))
|
||
partial_quantity = await self._adjust_close_quantity(symbol, partial_quantity)
|
||
if partial_quantity <= 0:
|
||
logger.warning(f"{symbol} 部分止盈:数量调整后为0,跳过")
|
||
continue
|
||
partial_order = await self.client.place_order(
|
||
symbol=symbol,
|
||
side=close_side,
|
||
quantity=partial_quantity,
|
||
order_type='MARKET',
|
||
reduce_only=True, # 部分止盈必须 reduceOnly,避免反向开仓
|
||
position_side=close_position_side, # 兼容对冲模式:指定要减少的持仓方向
|
||
)
|
||
if partial_order:
|
||
position_info['partialProfitTaken'] = True
|
||
position_info['remainingQuantity'] = remaining_quantity - partial_quantity
|
||
logger.info(
|
||
f"{symbol} 部分止盈成功: 平仓{partial_quantity:.4f},剩余{position_info['remainingQuantity']:.4f}"
|
||
)
|
||
# 分步止盈后的“保本”处理:仅在盈利保护总开关开启时移至含手续费保本价
|
||
if profit_protection_enabled:
|
||
breakeven = self._breakeven_stop_price(entry_price, side)
|
||
position_info['stopLoss'] = breakeven
|
||
logger.info(
|
||
f"{symbol} 部分止盈后:剩余仓位止损移至含手续费保本价 {breakeven:.4f}(入场: {entry_price:.4f}),"
|
||
f"剩余50%仓位追求1.5:1止盈目标"
|
||
)
|
||
else:
|
||
# 兜底:可能遇到 -2022(reduceOnly rejected)等竞态,重新查一次持仓
|
||
try:
|
||
live2 = await self._get_live_position_amt(symbol, position_side=close_position_side)
|
||
except Exception:
|
||
live2 = None
|
||
if live2 is None or abs(live2) <= 0:
|
||
logger.warning(f"{symbol} 部分止盈下单返回None,但实时持仓已为0,跳过")
|
||
continue
|
||
logger.warning(f"{symbol} 部分止盈下单返回None(实时持仓仍存在: {live2}),稍后将继续由止损/止盈逻辑处理")
|
||
except Exception as e:
|
||
logger.error(f"{symbol} 部分止盈失败: {e}")
|
||
|
||
# 第二目标:原始止盈价,平掉剩余仓位(基于保证金收益比)
|
||
# ✅ 已移除时间锁限制,可以立即执行
|
||
if partial_profit_taken and take_profit_2 is not None:
|
||
# 计算第二目标对应的保证金百分比
|
||
if position_info['side'] == 'BUY':
|
||
take_profit_2_amount = (take_profit_2 - entry_price) * remaining_quantity
|
||
else: # SELL
|
||
take_profit_2_amount = (entry_price - take_profit_2) * remaining_quantity
|
||
# 使用剩余仓位的保证金
|
||
remaining_margin = (entry_price * remaining_quantity) / leverage if leverage > 0 else (entry_price * remaining_quantity)
|
||
take_profit_2_pct_margin = (take_profit_2_amount / remaining_margin * 100) if remaining_margin > 0 else 0
|
||
# 计算剩余仓位的当前盈亏
|
||
if position_info['side'] == 'BUY':
|
||
remaining_pnl_amount = (current_price - entry_price) * remaining_quantity
|
||
else:
|
||
remaining_pnl_amount = (entry_price - current_price) * remaining_quantity
|
||
remaining_pnl_pct_margin = (remaining_pnl_amount / remaining_margin * 100) if remaining_margin > 0 else 0
|
||
|
||
# 直接比较剩余仓位盈亏百分比与第二目标(基于保证金)
|
||
if remaining_pnl_pct_margin >= take_profit_2_pct_margin:
|
||
logger.info(
|
||
f"{symbol} 触发第二目标止盈(基于保证金): "
|
||
f"剩余仓位盈亏={remaining_pnl_pct_margin:.2f}% of margin >= 目标={take_profit_2_pct_margin:.2f}% of margin | "
|
||
f"当前价={current_price:.4f}, 目标价={take_profit_2:.4f}, "
|
||
f"剩余数量={remaining_quantity:.4f}"
|
||
)
|
||
# ⚠️ 关键修复:统一使用 'take_profit_partial_then_take_profit' 来区分"第一目标止盈后第二目标止盈"
|
||
exit_reason = 'take_profit_partial_then_take_profit'
|
||
# 计算总盈亏(原始仓位 + 部分止盈的盈亏)
|
||
# 部分止盈时的价格需要从数据库或记录中获取,这里简化处理
|
||
total_pnl_amount = remaining_pnl_amount # 简化:只计算剩余仓位盈亏
|
||
total_pnl_percent = (total_pnl_amount / margin * 100) if margin > 0 else 0
|
||
|
||
# 更新数据库
|
||
if DB_AVAILABLE:
|
||
trade_id = position_info.get('tradeId')
|
||
if trade_id:
|
||
try:
|
||
# 计算持仓持续时间
|
||
entry_time = position_info.get('entryTime')
|
||
duration_minutes = None
|
||
if entry_time:
|
||
try:
|
||
if isinstance(entry_time, str):
|
||
entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S')
|
||
else:
|
||
entry_dt = entry_time
|
||
exit_dt = get_beijing_time() # 使用北京时间计算持续时间
|
||
duration = exit_dt - entry_dt
|
||
duration_minutes = int(duration.total_seconds() / 60)
|
||
except Exception as e:
|
||
logger.debug(f"计算持仓持续时间失败: {e}")
|
||
|
||
# 获取策略类型
|
||
strategy_type = position_info.get('strategyType', 'trend_following')
|
||
|
||
Trade.update_exit(
|
||
trade_id=trade_id,
|
||
exit_price=current_price,
|
||
exit_reason=exit_reason,
|
||
pnl=total_pnl_amount,
|
||
pnl_percent=total_pnl_percent,
|
||
strategy_type=strategy_type,
|
||
duration_minutes=duration_minutes
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"更新止盈记录失败: {e}")
|
||
if await self.close_position(symbol, reason=exit_reason):
|
||
closed_positions.append(symbol)
|
||
continue
|
||
else:
|
||
# 如果未部分止盈,但达到【第二目标】止盈价对应的收益比时,才全部平仓
|
||
# ⚠️ 修复:不再使用 TAKE_PROFIT_PERCENT(10%) 作为全平条件,否则会“刚赚一点就整仓止盈”
|
||
# 改为使用 take_profit_2 价格对应的保证金收益%,与第一目标(20%) 取较大者,避免盈利过少
|
||
take_profit_2_full = position_info.get('takeProfit2', position_info.get('takeProfit'))
|
||
take_profit_2_full = float(take_profit_2_full) if take_profit_2_full is not None else None
|
||
if take_profit_2_full is not None and margin and margin > 0:
|
||
if position_info['side'] == 'BUY':
|
||
take_profit_2_amount = (take_profit_2_full - entry_price) * quantity
|
||
else:
|
||
take_profit_2_amount = (entry_price - take_profit_2_full) * quantity
|
||
take_profit_2_pct_margin = (take_profit_2_amount / margin * 100) if margin > 0 else 0
|
||
# 至少要求达到第一目标对应的收益%(如 20%),避免过早全平
|
||
take_profit_1_pct = (config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20) or 0.20)
|
||
if take_profit_1_pct > 1:
|
||
take_profit_1_pct = take_profit_1_pct / 100.0
|
||
min_pct_for_full_tp = max(take_profit_2_pct_margin, take_profit_1_pct * 100)
|
||
|
||
if pnl_percent_margin >= min_pct_for_full_tp:
|
||
logger.info(
|
||
f"{symbol} 触发止盈(第二目标/保证金): "
|
||
f"当前盈亏={pnl_percent_margin:.2f}% of margin >= 目标={min_pct_for_full_tp:.2f}% | "
|
||
f"当前价={current_price:.4f}, 第二目标价={take_profit_2_full:.4f}"
|
||
)
|
||
exit_reason = 'take_profit'
|
||
# 更新数据库
|
||
if DB_AVAILABLE:
|
||
trade_id = position_info.get('tradeId')
|
||
if trade_id:
|
||
try:
|
||
# 计算持仓持续时间和策略类型
|
||
entry_time = position_info.get('entryTime')
|
||
duration_minutes = None
|
||
if entry_time:
|
||
try:
|
||
from datetime import datetime
|
||
if isinstance(entry_time, str):
|
||
entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S')
|
||
else:
|
||
entry_dt = entry_time
|
||
exit_dt = get_beijing_time() # 使用北京时间计算持续时间
|
||
duration = exit_dt - entry_dt
|
||
duration_minutes = int(duration.total_seconds() / 60)
|
||
except Exception as e:
|
||
logger.debug(f"计算持仓持续时间失败: {e}")
|
||
|
||
strategy_type = position_info.get('strategyType', 'trend_following')
|
||
|
||
Trade.update_exit(
|
||
trade_id=trade_id,
|
||
exit_price=current_price,
|
||
exit_reason=exit_reason,
|
||
pnl=pnl_amount,
|
||
pnl_percent=pnl_percent_margin,
|
||
strategy_type=strategy_type,
|
||
duration_minutes=duration_minutes
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"更新止盈记录失败: {e}")
|
||
if await self.close_position(symbol, reason=exit_reason):
|
||
closed_positions.append(symbol)
|
||
continue
|
||
|
||
except Exception as e:
|
||
err_msg = str(e).strip() or repr(e) or type(e).__name__
|
||
logger.error(f"检查止损止盈失败: {err_msg}")
|
||
|
||
return closed_positions
|
||
|
||
async def get_position_summary(self) -> Dict:
|
||
"""
|
||
获取持仓摘要
|
||
|
||
Returns:
|
||
持仓摘要信息
|
||
"""
|
||
try:
|
||
positions = await self._get_open_positions()
|
||
balance = await self._get_account_balance()
|
||
|
||
total_pnl = sum(p['unRealizedProfit'] for p in positions)
|
||
|
||
position_list = []
|
||
for p in positions:
|
||
symbol = p['symbol']
|
||
pos_data = {
|
||
'symbol': symbol,
|
||
'positionAmt': p['positionAmt'],
|
||
'entryPrice': p['entryPrice'],
|
||
'pnl': p['unRealizedProfit'],
|
||
'leverage': p.get('leverage', 10)
|
||
}
|
||
|
||
# 尝试从内存或数据库补充详细信息
|
||
active_pos = self.active_positions.get(symbol)
|
||
if active_pos:
|
||
pos_data.update({
|
||
'entry_time': active_pos.get('entryTime'),
|
||
'stop_loss_price': active_pos.get('stopLoss'),
|
||
'take_profit_price': active_pos.get('takeProfit'),
|
||
'atr': active_pos.get('atr'),
|
||
'entry_reason': active_pos.get('entryReason')
|
||
})
|
||
elif DB_AVAILABLE and Trade:
|
||
# 从数据库查询
|
||
try:
|
||
trades = Trade.get_by_symbol(symbol, status='open', account_id=self.account_id)
|
||
if trades:
|
||
# 取最新的一个
|
||
trade = trades[0]
|
||
pos_data.update({
|
||
'entry_time': trade.get('entry_time'),
|
||
'stop_loss_price': trade.get('stop_loss_price'),
|
||
'take_profit_price': trade.get('take_profit_price'),
|
||
'atr': trade.get('atr'),
|
||
'entry_reason': trade.get('entry_reason')
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
position_list.append(pos_data)
|
||
|
||
return {
|
||
'totalPositions': len(positions),
|
||
'totalBalance': balance.get('total', 0),
|
||
'availableBalance': balance.get('available', 0),
|
||
'totalPnL': total_pnl,
|
||
'positions': position_list
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"获取持仓摘要失败: {e}")
|
||
return {}
|
||
|
||
async def _reconcile_pending_with_binance(self) -> int:
|
||
"""正向流程加固:对 status=pending 记录查币安订单,若已 FILLED 则更新为 open。"""
|
||
try:
|
||
from .pending_reconcile import reconcile_pending_with_binance
|
||
return await reconcile_pending_with_binance(self.client, self.account_id)
|
||
except ImportError:
|
||
return 0
|
||
|
||
async def sync_positions_with_binance(self):
|
||
"""
|
||
同步币安实际持仓状态与数据库状态
|
||
检查哪些持仓在数据库中还是open状态,但在币安已经不存在了
|
||
"""
|
||
if not DB_AVAILABLE or not Trade:
|
||
logger.debug("数据库不可用,跳过持仓状态同步")
|
||
return
|
||
|
||
try:
|
||
logger.info("开始同步币安持仓状态与数据库...")
|
||
|
||
# 0. 正向流程加固:pending 对账(补齐因 WS 断线/进程重启漏掉的 pending→open)
|
||
n = await self._reconcile_pending_with_binance()
|
||
if n > 0:
|
||
logger.info(f"[账号{self.account_id}] pending 对账完成,{n} 条已更新为 open")
|
||
|
||
# 1. 获取币安实际持仓
|
||
binance_positions = await self._get_open_positions()
|
||
binance_symbols = {p['symbol'] for p in binance_positions}
|
||
logger.debug(f"币安实际持仓: {len(binance_symbols)} 个 ({', '.join(binance_symbols) if binance_symbols else '无'})")
|
||
|
||
# 2. 获取数据库中状态为open的交易记录(仅当前账号,限制条数防内存暴增)
|
||
db_open_trades = Trade.get_all(status='open', account_id=self.account_id, limit=500)
|
||
db_open_symbols = {t['symbol'] for t in db_open_trades}
|
||
logger.debug(f"数据库open状态: {len(db_open_symbols)} 个 ({', '.join(db_open_symbols) if db_open_symbols else '无'})")
|
||
|
||
# 2.5 用「DB 有 open 且币安也有」的持仓填充 active_positions,确保这些持仓会被后续启动的监控覆盖
|
||
in_both = db_open_symbols & binance_symbols
|
||
for symbol in in_both:
|
||
if symbol in self.active_positions:
|
||
continue
|
||
trade = next((t for t in db_open_trades if t.get('symbol') == symbol), None)
|
||
pos = next((p for p in binance_positions if p.get('symbol') == symbol), None)
|
||
if not trade or not pos or float(pos.get('positionAmt', 0)) == 0:
|
||
continue
|
||
try:
|
||
entry_price = float(trade.get('entry_price', 0) or pos.get('entryPrice', 0))
|
||
quantity = abs(float(pos.get('positionAmt', 0)))
|
||
side = 'BUY' if float(pos.get('positionAmt', 0)) > 0 else 'SELL'
|
||
leverage = float(pos.get('leverage', 10) or trade.get('leverage', 10) or 10)
|
||
stop_loss_price = trade.get('stop_loss_price')
|
||
take_profit_price = trade.get('take_profit_price') or trade.get('take_profit_1')
|
||
take_profit_1 = trade.get('take_profit_1')
|
||
take_profit_2 = trade.get('take_profit_2')
|
||
if stop_loss_price is None or take_profit_price is None:
|
||
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, leverage, stop_loss_pct=stop_loss_pct)
|
||
take_profit_price = take_profit_price or self.risk_manager.get_take_profit_price(entry_price, side, quantity, leverage, take_profit_pct=take_profit_pct)
|
||
if take_profit_2 is None and take_profit_price is not None:
|
||
take_profit_2 = take_profit_price
|
||
if take_profit_1 is not None and take_profit_2 is not None:
|
||
if side == 'BUY':
|
||
if take_profit_1 >= take_profit_2 and take_profit_2 > entry_price:
|
||
closer = min(take_profit_1, take_profit_2)
|
||
further = max(take_profit_1, take_profit_2)
|
||
if closer > entry_price:
|
||
take_profit_1 = closer
|
||
take_profit_2 = further
|
||
else:
|
||
if take_profit_1 <= take_profit_2 and take_profit_2 < entry_price:
|
||
closer = max(take_profit_1, take_profit_2)
|
||
further = min(take_profit_1, take_profit_2)
|
||
if closer < entry_price:
|
||
take_profit_1 = closer
|
||
take_profit_2 = further
|
||
position_info = {
|
||
'symbol': symbol,
|
||
'side': side,
|
||
'quantity': quantity,
|
||
'entryPrice': entry_price,
|
||
'changePercent': 0,
|
||
'orderId': trade.get('entry_order_id'),
|
||
'tradeId': trade.get('id'),
|
||
'stopLoss': stop_loss_price,
|
||
'takeProfit': take_profit_price,
|
||
'takeProfit1': take_profit_1,
|
||
'takeProfit2': take_profit_2,
|
||
'partialProfitTaken': False,
|
||
'remainingQuantity': quantity,
|
||
'initialStopLoss': stop_loss_price,
|
||
'leverage': leverage,
|
||
'entryReason': trade.get('entry_reason') or 'db_sync',
|
||
'entryTime': trade.get('entry_time'),
|
||
'atr': trade.get('atr'),
|
||
'maxProfit': 0.0,
|
||
'trailingStopActivated': False,
|
||
'breakevenStopSet': False,
|
||
}
|
||
self.active_positions[symbol] = position_info
|
||
logger.debug(f"{symbol} 已从 DB 载入到 active_positions,便于监控")
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 载入 active_positions 失败: {e}")
|
||
|
||
# 2.6 对「有仓但交易所无 SL/TP」的持仓自动补挂止损止盈(含仅在本系统 active 的仓)
|
||
for symbol in list(self.active_positions.keys()):
|
||
if symbol not in binance_symbols:
|
||
continue
|
||
position_info = self.active_positions[symbol]
|
||
if not position_info.get('stopLoss') and not position_info.get('takeProfit'):
|
||
continue
|
||
try:
|
||
sl_from_ex, tp_from_ex = await self._get_sltp_from_exchange(symbol, position_info['side'])
|
||
if sl_from_ex is not None and tp_from_ex is not None:
|
||
continue
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
current_price = float(ticker.get('price', 0) or position_info.get('entryPrice', 0))
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
logger.info(f"[账号{self.account_id}] {symbol} [同步] 检测到交易所无止损/止盈,已补挂 SL/TP")
|
||
except Exception as e:
|
||
logger.warning(f"[账号{self.account_id}] {symbol} [同步] 补挂 SL/TP 失败: {e}", exc_info=False)
|
||
|
||
# 3. 找出在数据库中open但在币安已不存在的持仓
|
||
missing_in_binance = db_open_symbols - binance_symbols
|
||
|
||
if missing_in_binance:
|
||
logger.warning(
|
||
f"发现 {len(missing_in_binance)} 个持仓在数据库中为open状态,但在币安已不存在: "
|
||
f"{', '.join(missing_in_binance)}"
|
||
)
|
||
|
||
# 4. 更新这些持仓的状态
|
||
for symbol in missing_in_binance:
|
||
try:
|
||
trades = Trade.get_by_symbol(symbol, status='open', account_id=self.account_id)
|
||
if not trades:
|
||
logger.warning(f"{symbol} [状态同步] ⚠️ 数据库中没有找到open状态的交易记录,跳过")
|
||
continue
|
||
|
||
# 过滤掉刚刚开仓的交易(给予60秒的同步缓冲期)
|
||
# 避免因API延迟导致刚开的仓位被误判为"丢失"从而被错误关闭
|
||
import time
|
||
now_ts = int(time.time())
|
||
recent_trades = []
|
||
valid_trades = []
|
||
|
||
for t in trades:
|
||
try:
|
||
entry_time = t.get('entry_time')
|
||
if entry_time:
|
||
# 处理 entry_time 可能是 datetime 对象或时间戳的情况
|
||
entry_ts = 0
|
||
if hasattr(entry_time, 'timestamp'):
|
||
entry_ts = int(entry_time.timestamp())
|
||
elif isinstance(entry_time, (int, float)):
|
||
entry_ts = int(entry_time)
|
||
# 如果是刚刚开仓(60秒内),跳过同步关闭
|
||
if now_ts - entry_ts < 60:
|
||
recent_trades.append(t)
|
||
continue
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} [状态同步] 检查开仓时间出错: {e}")
|
||
|
||
valid_trades.append(t)
|
||
|
||
if recent_trades:
|
||
logger.info(f"{symbol} [状态同步] 发现 {len(recent_trades)} 个刚刚开仓的交易(<60s),跳过同步关闭(防止误杀)")
|
||
|
||
if not valid_trades:
|
||
if not recent_trades:
|
||
logger.warning(f"{symbol} [状态同步] 没有符合条件的交易记录需要更新")
|
||
continue
|
||
|
||
logger.info(f"{symbol} [状态同步] 找到 {len(valid_trades)} 条open状态的交易记录,开始更新...")
|
||
except Exception as get_trades_error:
|
||
logger.error(
|
||
f"{symbol} [状态同步] ❌ 获取交易记录失败: "
|
||
f"错误类型={type(get_trades_error).__name__}, 错误消息={str(get_trades_error)}"
|
||
)
|
||
import traceback
|
||
logger.debug(f"{symbol} [状态同步] 错误详情:\n{traceback.format_exc()}")
|
||
continue
|
||
|
||
for trade in valid_trades:
|
||
trade_id = trade.get('id')
|
||
if not trade_id:
|
||
logger.warning(f"{symbol} [状态同步] ⚠️ 交易记录缺少ID字段,跳过: {trade}")
|
||
continue
|
||
|
||
try:
|
||
logger.info(
|
||
f"{symbol} [状态同步] 更新交易记录状态 (ID: {trade_id})... | "
|
||
f"入场价: {trade.get('entry_price', 'N/A')}, "
|
||
f"数量: {trade.get('quantity', 'N/A')}, "
|
||
f"方向: {trade.get('side', 'N/A')}"
|
||
)
|
||
|
||
# 尝试从币安历史订单获取实际平仓价格
|
||
exit_price = None
|
||
realized_pnl = None
|
||
commission = None
|
||
close_orders = []
|
||
try:
|
||
# 优先尝试从成交记录(Trade History)获取精确的 Realized PnL
|
||
# 这是最准确的方式,因为它包含了资金费率、手续费扣除后的实际盈亏
|
||
try:
|
||
recent_trades = await self.client.get_recent_trades(symbol, limit=50)
|
||
|
||
# 确定过滤时间戳
|
||
entry_ts_ms_filter = 0
|
||
et_obj = trade.get('entry_time')
|
||
if hasattr(et_obj, 'timestamp'):
|
||
entry_ts_ms_filter = int(et_obj.timestamp() * 1000)
|
||
elif isinstance(et_obj, (int, float)):
|
||
# 区分秒和毫秒
|
||
entry_ts_ms_filter = int(et_obj * 1000) if et_obj < 3000000000 else int(et_obj)
|
||
elif isinstance(et_obj, str):
|
||
try:
|
||
from datetime import datetime
|
||
dt_obj = datetime.strptime(et_obj, "%Y-%m-%d %H:%M:%S")
|
||
entry_ts_ms_filter = int(dt_obj.timestamp() * 1000)
|
||
except:
|
||
pass
|
||
|
||
if entry_ts_ms_filter > 0:
|
||
# 筛选出入场之后的成交记录
|
||
closing_trades = [
|
||
t for t in recent_trades
|
||
if t.get('time', 0) > entry_ts_ms_filter and float(t.get('realizedPnl', 0)) != 0
|
||
]
|
||
|
||
if closing_trades:
|
||
total_pnl = 0.0
|
||
total_comm = 0.0
|
||
total_qty = 0.0
|
||
total_val = 0.0
|
||
|
||
for t in closing_trades:
|
||
total_pnl += float(t.get('realizedPnl', 0))
|
||
total_comm += float(t.get('commission', 0))
|
||
qty = float(t.get('qty', 0))
|
||
price = float(t.get('price', 0))
|
||
total_qty += qty
|
||
total_val += qty * price
|
||
|
||
realized_pnl = total_pnl
|
||
commission = total_comm
|
||
if total_qty > 0:
|
||
exit_price = total_val / total_qty
|
||
|
||
logger.info(
|
||
f"{symbol} [状态同步] ✓ 从成交记录获取精确盈亏: "
|
||
f"PnL={realized_pnl} USDT, 均价={exit_price:.4f}"
|
||
)
|
||
except Exception as trade_hist_err:
|
||
err_msg = str(trade_hist_err).strip() or type(trade_hist_err).__name__
|
||
logger.warning(f"{symbol} [状态同步] 获取成交记录失败: {err_msg}")
|
||
|
||
# 获取最近的平仓订单(reduceOnly=True的订单)
|
||
import time
|
||
end_time = int(time.time() * 1000) # 当前时间(毫秒)
|
||
start_time = end_time - (7 * 24 * 60 * 60 * 1000) # 最近7天
|
||
|
||
# 获取入场时间(毫秒)用于过滤
|
||
entry_ts_ms = 0
|
||
try:
|
||
et = trade.get('entry_time')
|
||
if et:
|
||
if isinstance(et, (int, float)):
|
||
# 如果小于 3000000000,认为是秒,转毫秒
|
||
entry_ts_ms = int(et * 1000) if et < 3000000000 else int(et)
|
||
elif hasattr(et, 'timestamp'): # datetime object
|
||
entry_ts_ms = int(et.timestamp() * 1000)
|
||
elif isinstance(et, str):
|
||
# 尝试解析字符串
|
||
try:
|
||
# 先尝试作为数字解析
|
||
et_float = float(et)
|
||
entry_ts_ms = int(et_float * 1000) if et_float < 3000000000 else int(et_float)
|
||
except ValueError:
|
||
# 尝试作为日期字符串解析
|
||
try:
|
||
from datetime import datetime
|
||
dt = datetime.strptime(et, "%Y-%m-%d %H:%M:%S")
|
||
entry_ts_ms = int(dt.timestamp() * 1000)
|
||
except:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
# 优化:如果已知入场时间,则仅查询入场之后的订单
|
||
if entry_ts_ms > 0:
|
||
start_time = max(start_time, entry_ts_ms)
|
||
else:
|
||
# 如果没有入场时间(如手动录入且未填时间),缩小搜索范围以防止匹配到很久以前的订单
|
||
# 限制为最近1小时,避免匹配到几天的订单导致"时间倒流"
|
||
start_time = end_time - (60 * 60 * 1000)
|
||
logger.warning(f"{symbol} [状态同步] ⚠️ 交易记录缺少入场时间,将搜索范围限制为最近1小时")
|
||
|
||
logger.debug(
|
||
f"{symbol} [状态同步] 获取历史订单: "
|
||
f"symbol={symbol}, startTime={start_time}, endTime={end_time}"
|
||
)
|
||
|
||
# 获取历史订单
|
||
orders = await self.client.client.futures_get_all_orders(
|
||
symbol=symbol,
|
||
startTime=start_time,
|
||
endTime=end_time,
|
||
recvWindow=20000
|
||
)
|
||
|
||
# 验证 orders 的类型
|
||
if not isinstance(orders, list):
|
||
logger.warning(
|
||
f"{symbol} [状态同步] ⚠️ futures_get_all_orders 返回的不是列表: "
|
||
f"类型={type(orders)}, 值={orders}"
|
||
)
|
||
orders = [] # 设置为空列表,避免后续错误
|
||
|
||
if not orders:
|
||
logger.debug(f"{symbol} [状态同步] 未找到历史订单")
|
||
else:
|
||
logger.debug(f"{symbol} [状态同步] 找到 {len(orders)} 个历史订单")
|
||
|
||
# 查找最近的平仓订单(reduceOnly=True且已成交)
|
||
close_orders = []
|
||
|
||
for o in orders:
|
||
try:
|
||
if isinstance(o, dict) and o.get('reduceOnly') == True and o.get('status') == 'FILLED':
|
||
# 再次过滤掉早于入场时间的订单(双重保险)
|
||
order_time = int(o.get('updateTime', 0))
|
||
if entry_ts_ms > 0 and order_time < entry_ts_ms:
|
||
continue
|
||
# 如果没有入场时间,且订单时间早于1小时前(虽然startTime已限制,但双重检查),则跳过
|
||
if entry_ts_ms == 0 and order_time < (end_time - 3600000):
|
||
continue
|
||
|
||
close_orders.append(o)
|
||
except (AttributeError, TypeError) as e:
|
||
logger.warning(
|
||
f"{symbol} [状态同步] ⚠️ 处理订单数据时出错: "
|
||
f"错误类型={type(e).__name__}, 错误消息={str(e)}, "
|
||
f"订单数据类型={type(o)}, 订单数据={o}"
|
||
)
|
||
continue
|
||
|
||
if close_orders:
|
||
# 按时间倒序排序,取最近的
|
||
close_orders.sort(key=lambda x: x.get('updateTime', 0), reverse=True)
|
||
latest_order = close_orders[0]
|
||
|
||
# 获取平均成交价格
|
||
exit_price = float(latest_order.get('avgPrice', 0))
|
||
if exit_price > 0:
|
||
logger.info(f"{symbol} [状态同步] 从币安历史订单获取平仓价格: {exit_price:.4f} USDT")
|
||
else:
|
||
logger.warning(
|
||
f"{symbol} [状态同步] 历史订单中没有有效的avgPrice: {latest_order}"
|
||
)
|
||
except KeyError as key_error:
|
||
# KeyError 可能是访问 orders[symbol] 或其他字典访问错误
|
||
logger.error(
|
||
f"{symbol} [状态同步] ❌ 获取历史订单时KeyError: "
|
||
f"错误key={key_error}, 错误类型={type(key_error).__name__}"
|
||
)
|
||
import traceback
|
||
logger.debug(f"{symbol} [状态同步] KeyError详情:\n{traceback.format_exc()}")
|
||
except Exception as order_error:
|
||
logger.warning(
|
||
f"{symbol} [状态同步] 获取历史订单失败: "
|
||
f"错误类型={type(order_error).__name__}, 错误消息={str(order_error)}"
|
||
)
|
||
import traceback
|
||
logger.debug(f"{symbol} [状态同步] 错误详情:\n{traceback.format_exc()}")
|
||
|
||
# 如果无法从订单获取,尝试使用当前价格;若当前价格也无法获取,则保留open状态等待下次同步
|
||
if not exit_price or exit_price <= 0:
|
||
try:
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
if ticker and ticker.get('price'):
|
||
exit_price = float(ticker['price'])
|
||
logger.warning(f"{symbol} [状态同步] 使用当前价格作为平仓价格: {exit_price:.4f} USDT")
|
||
else:
|
||
logger.warning(
|
||
f"{symbol} [状态同步] 无法获取当前价格(ticker={ticker}),"
|
||
f"保留open状态等待下次同步"
|
||
)
|
||
continue
|
||
except KeyError as key_error:
|
||
# KeyError 可能是访问 ticker['price'] 时出错
|
||
logger.error(
|
||
f"{symbol} [状态同步] ❌ 获取当前价格时KeyError: {key_error}, "
|
||
f"ticker数据: {ticker if 'ticker' in locals() else 'N/A'}"
|
||
)
|
||
continue
|
||
except Exception as ticker_error:
|
||
logger.warning(
|
||
f"{symbol} [状态同步] 获取当前价格失败: "
|
||
f"错误类型={type(ticker_error).__name__}, 错误消息={str(ticker_error)},"
|
||
f"将保留open状态等待下次同步"
|
||
)
|
||
continue
|
||
|
||
# 计算盈亏(确保所有值都是float类型,避免Decimal类型问题)
|
||
try:
|
||
entry_price = float(trade.get('entry_price', 0))
|
||
quantity = float(trade.get('quantity', 0))
|
||
|
||
if entry_price <= 0 or quantity <= 0:
|
||
logger.error(
|
||
f"{symbol} [状态同步] ❌ 交易记录数据无效: "
|
||
f"入场价={entry_price}, 数量={quantity}, 跳过更新"
|
||
)
|
||
continue
|
||
|
||
# 如果已经获取到精确的 Realized PnL,直接使用
|
||
if realized_pnl is not None:
|
||
pnl = realized_pnl
|
||
# 根据 Realized PnL 反推百分比 (PnL / 保证金) 或者 (PnL / 名义价值)?
|
||
# 这里保持与价格差计算一致的逻辑:(PriceDiff / EntryPrice) * 100
|
||
# 如果 exit_price 是计算出来的均价,这个公式依然成立
|
||
if trade.get('side') == 'BUY':
|
||
pnl_percent = ((exit_price - entry_price) / entry_price) * 100
|
||
else:
|
||
pnl_percent = ((entry_price - exit_price) / entry_price) * 100
|
||
else:
|
||
# 降级方案:使用价格差计算
|
||
if trade.get('side') == 'BUY':
|
||
pnl = (exit_price - entry_price) * quantity
|
||
pnl_percent = ((exit_price - entry_price) / entry_price) * 100
|
||
else: # SELL
|
||
pnl = (entry_price - exit_price) * quantity
|
||
pnl_percent = ((entry_price - exit_price) / entry_price) * 100
|
||
|
||
logger.debug(
|
||
f"{symbol} [状态同步] 盈亏计算: "
|
||
f"入场价={entry_price:.4f}, 平仓价={exit_price:.4f}, "
|
||
f"数量={quantity:.4f}, 方向={trade.get('side', 'N/A')}, "
|
||
f"盈亏={pnl:.2f} USDT ({pnl_percent:.2f}%)"
|
||
)
|
||
except (ValueError, TypeError, KeyError) as calc_error:
|
||
logger.error(
|
||
f"{symbol} [状态同步] ❌ 计算盈亏失败 (ID: {trade_id}): "
|
||
f"错误类型={type(calc_error).__name__}, 错误消息={str(calc_error)}, "
|
||
f"交易记录数据: entry_price={trade.get('entry_price', 'N/A')}, "
|
||
f"quantity={trade.get('quantity', 'N/A')}, side={trade.get('side', 'N/A')}"
|
||
)
|
||
continue
|
||
|
||
# 从历史订单中获取平仓订单号
|
||
exit_order_id = None
|
||
latest_close_order = None
|
||
if close_orders:
|
||
exit_order_id = close_orders[0].get('orderId')
|
||
latest_close_order = close_orders[0]
|
||
if exit_order_id:
|
||
logger.info(f"{symbol} [状态同步] 找到平仓订单号: {exit_order_id}")
|
||
|
||
# 使用 try-except 包裹,确保异常被正确处理
|
||
try:
|
||
# 计算持仓持续时间和策略类型
|
||
# exit_reason 细分:优先用价格匹配和特征判断,其次看币安订单类型
|
||
exit_reason = "sync"
|
||
exit_time_ts = None
|
||
is_reduce_only = False
|
||
|
||
try:
|
||
if latest_close_order and isinstance(latest_close_order, dict):
|
||
otype = str(
|
||
latest_close_order.get("type")
|
||
or latest_close_order.get("origType")
|
||
or ""
|
||
).upper()
|
||
is_reduce_only = latest_close_order.get("reduceOnly", False)
|
||
|
||
ms = latest_close_order.get("updateTime") or latest_close_order.get("time")
|
||
try:
|
||
if ms:
|
||
exit_time_ts = int(int(ms) / 1000)
|
||
except Exception:
|
||
exit_time_ts = None
|
||
except Exception:
|
||
pass
|
||
|
||
# ⚠️ 关键改进:优先使用价格匹配和特征判断(提高准确性)
|
||
# 这对于保护单触发的 MARKET 订单特别重要(币安的保护单触发后会生成 MARKET 订单,但 reduceOnly 可能不准确)
|
||
try:
|
||
def _close_to(a: float, b: float, max_pct: float = 0.10) -> bool: # 从5%放宽到10%,以应对极端滑点
|
||
if a <= 0 or b <= 0:
|
||
return False
|
||
return abs((a - b) / b) <= max_pct
|
||
|
||
ep = float(exit_price or 0)
|
||
entry_price_val = float(trade.get("entry_price", 0) or 0)
|
||
sl = trade.get("stop_loss_price")
|
||
tp = trade.get("take_profit_price")
|
||
tp1 = trade.get("take_profit_1")
|
||
tp2 = trade.get("take_profit_2")
|
||
|
||
# 计算持仓时间和亏损比例(用于特征判断)
|
||
entry_time = trade.get("entry_time")
|
||
duration_minutes = None
|
||
if entry_time and exit_time_ts:
|
||
try:
|
||
duration_minutes = (exit_time_ts - int(entry_time)) / 60.0
|
||
except Exception:
|
||
pass
|
||
|
||
# ⚠️ 关键修复:使用基于保证金的盈亏百分比判断盈利/亏损,而不是价格涨跌幅
|
||
# 因为价格涨跌幅(如2.35%)和基于保证金的盈亏百分比(如18.82%)差异很大
|
||
# 对于SELL单,价格从0.119跌到0.1162,价格涨跌幅只有2.35%,但基于保证金的盈亏百分比是18.82%
|
||
pnl_percent_price = float(trade.get("pnl_percent", 0) or 0) # 价格涨跌幅(数据库可能存储的是这个)
|
||
|
||
# 重新计算基于保证金的盈亏百分比(更准确)
|
||
leverage = float(trade.get("leverage", 8) or 8)
|
||
entry_value = entry_price * quantity
|
||
margin = entry_value / leverage if leverage > 0 else entry_value
|
||
pnl_percent_margin = (pnl / margin * 100) if margin > 0 else 0
|
||
|
||
# 使用基于保证金的盈亏百分比判断(与实时监控逻辑一致)
|
||
pnl_percent_for_judge = pnl_percent_margin
|
||
|
||
# ⚠️ 2026-01-27关键修复:优先检查盈亏情况,避免盈利单被错误标记为止损
|
||
# 1. 优先检查盈亏情况(使用基于保证金的盈亏百分比)
|
||
if pnl_percent_for_judge > 0:
|
||
# 盈利单:优先检查止盈价格匹配
|
||
if tp is not None and _close_to(ep, float(tp), max_pct=0.10):
|
||
exit_reason = "take_profit"
|
||
elif tp1 is not None and _close_to(ep, float(tp1), max_pct=0.10):
|
||
exit_reason = "take_profit"
|
||
elif tp2 is not None and _close_to(ep, float(tp2), max_pct=0.10):
|
||
exit_reason = "take_profit"
|
||
# 如果盈利但没有匹配止盈价,可能是移动止损或手动平仓
|
||
elif exit_reason == "sync":
|
||
# 检查是否有移动止损标记
|
||
if is_reduce_only:
|
||
exit_reason = "trailing_stop" # 可能是移动止损
|
||
else:
|
||
exit_reason = "manual" # 可能是手动平仓
|
||
else:
|
||
# 亏损单:检查止损价格匹配
|
||
if sl is not None and entry_price_val > 0 and ep > 0:
|
||
sl_val = float(sl)
|
||
# 价格匹配:平仓价接近止损价
|
||
if _close_to(ep, sl_val, max_pct=0.10):
|
||
exit_reason = "stop_loss"
|
||
# 方向匹配:BUY时平仓价 < 止损价,SELL时平仓价 > 止损价
|
||
elif (trade.get("side") == "BUY" and ep < sl_val) or (trade.get("side") == "SELL" and ep > sl_val):
|
||
# 如果价格在止损方向,且亏损比例较大,更可能是止损触发
|
||
# ⚠️ 关键修复:使用基于保证金的盈亏百分比判断
|
||
if pnl_percent_for_judge < -5.0: # 亏损超过5%(基于保证金)
|
||
exit_reason = "stop_loss"
|
||
logger.info(f"{trade.get('symbol')} [同步] 价格方向匹配止损,且亏损{pnl_percent_for_judge:.2f}% of margin,标记为止损")
|
||
|
||
# 2. 如果仍未确定,检查止盈价格匹配(作为备选);仅盈利单可标为止盈
|
||
if exit_reason == "sync" and ep > 0 and pnl_percent_for_judge > 0:
|
||
if tp is not None and _close_to(ep, float(tp), max_pct=0.10):
|
||
exit_reason = "take_profit"
|
||
elif tp1 is not None and _close_to(ep, float(tp1), max_pct=0.10):
|
||
exit_reason = "take_profit"
|
||
elif tp2 is not None and _close_to(ep, float(tp2), max_pct=0.10):
|
||
exit_reason = "take_profit"
|
||
|
||
# 3. 特征判断:如果价格不匹配,但满足止损特征,也标记为止损
|
||
if exit_reason == "sync" and entry_price_val > 0 and ep > 0:
|
||
# 特征1:持仓时间短(< 30分钟)且亏损
|
||
# ⚠️ 关键修复:使用基于保证金的盈亏百分比判断
|
||
if duration_minutes and duration_minutes < 30 and pnl_percent_for_judge < -5.0:
|
||
# 特征2:价格在止损方向
|
||
if sl is not None:
|
||
sl_val = float(sl)
|
||
if (trade.get("side") == "BUY" and ep < sl_val) or (trade.get("side") == "SELL" and ep > sl_val):
|
||
exit_reason = "stop_loss"
|
||
logger.info(f"{trade.get('symbol')} [同步] 特征判断:持仓{duration_minutes:.1f}分钟,亏损{pnl_percent_for_judge:.2f}% of margin,价格在止损方向,标记为止损")
|
||
|
||
# ⚠️ 2026-02-05 修复:防止亏损单被错误标记为止盈
|
||
if pnl_percent_for_judge < 0 and exit_reason == "take_profit":
|
||
logger.warning(f"{symbol} [状态同步] ⚠️ 亏损单({pnl_percent_for_judge:.2f}%)被标记为止盈,强制修正为manual/stop_loss")
|
||
# 如果亏损较大,归为止损
|
||
if pnl_percent_for_judge < -5.0:
|
||
exit_reason = "stop_loss"
|
||
else:
|
||
exit_reason = "manual"
|
||
|
||
# 4. 如果之前标记为 sync 且是 reduceOnly 订单,但价格不匹配止损/止盈,可能是其他自动平仓(如移动止损)
|
||
if exit_reason == "sync" and is_reduce_only:
|
||
# 检查是否是移动止损:如果价格接近入场价,可能是移动止损触发的
|
||
if entry_price_val > 0 and _close_to(ep, entry_price_val, max_pct=0.01):
|
||
exit_reason = "trailing_stop"
|
||
|
||
# 5. 最后才看币安订单类型(作为兜底);亏损单不标为止盈
|
||
if exit_reason == "sync" and latest_close_order and isinstance(latest_close_order, dict):
|
||
otype = str(
|
||
latest_close_order.get("type")
|
||
or latest_close_order.get("origType")
|
||
or ""
|
||
).upper()
|
||
|
||
if "TRAILING" in otype:
|
||
exit_reason = "trailing_stop"
|
||
elif "TAKE_PROFIT" in otype and pnl_percent_for_judge > 0:
|
||
exit_reason = "take_profit"
|
||
elif "TAKE_PROFIT" in otype and pnl_percent_for_judge < 0:
|
||
exit_reason = "stop_loss" if pnl_percent_for_judge < -5.0 else "manual"
|
||
elif "STOP" in otype:
|
||
exit_reason = "stop_loss"
|
||
elif otype in ("MARKET", "LIMIT"):
|
||
# 只有在价格和特征都不匹配,且不是 reduceOnly 时,才标记为手动平仓
|
||
if not is_reduce_only:
|
||
# 再次检查:如果亏损很大,更可能是止损触发(币安API可能不准确)
|
||
# ⚠️ 关键修复:使用基于保证金的盈亏百分比判断
|
||
if pnl_percent_for_judge < -10.0:
|
||
exit_reason = "stop_loss" # 大额亏损,更可能是止损
|
||
logger.warning(f"{trade.get('symbol')} [同步] 大额亏损{pnl_percent_for_judge:.2f}% of margin,即使reduceOnly=false也标记为止损")
|
||
else:
|
||
exit_reason = "manual"
|
||
except Exception as e:
|
||
logger.warning(f"判断平仓原因失败: {e}")
|
||
pass
|
||
|
||
# 持仓持续时间(分钟):优先用币安订单时间,否则用当前时间
|
||
entry_time = trade.get("entry_time")
|
||
duration_minutes = None
|
||
try:
|
||
et = None
|
||
if isinstance(entry_time, (int, float)):
|
||
et = int(entry_time)
|
||
elif isinstance(entry_time, str) and entry_time.strip():
|
||
# 兼容旧格式:字符串时间戳/日期字符串
|
||
s = entry_time.strip()
|
||
if s.isdigit():
|
||
et = int(s)
|
||
else:
|
||
from datetime import datetime
|
||
et = int(datetime.fromisoformat(s).timestamp())
|
||
|
||
xt = int(exit_time_ts) if exit_time_ts is not None else int(get_beijing_time().timestamp())
|
||
if et is not None and xt >= et:
|
||
duration_minutes = int((xt - et) / 60)
|
||
except Exception as e:
|
||
logger.debug(f"计算持仓持续时间失败: {e}")
|
||
|
||
strategy_type = 'trend_following' # 默认策略类型
|
||
|
||
# ⚠️ 关键修复:使用基于保证金的盈亏百分比更新数据库(与实时监控逻辑一致)
|
||
Trade.update_exit(
|
||
trade_id=trade_id,
|
||
exit_price=exit_price,
|
||
exit_reason=exit_reason,
|
||
pnl=pnl,
|
||
pnl_percent=pnl_percent_margin, # 使用基于保证金的盈亏百分比
|
||
exit_order_id=exit_order_id, # 保存币安平仓订单号
|
||
strategy_type=strategy_type,
|
||
duration_minutes=duration_minutes,
|
||
exit_time_ts=exit_time_ts,
|
||
)
|
||
except Exception as update_error:
|
||
# update_exit 内部已经有异常处理,但如果仍然失败,记录错误但不中断同步流程
|
||
error_str = str(update_error)
|
||
if "Duplicate entry" in error_str and "exit_order_id" in error_str:
|
||
logger.warning(
|
||
f"{symbol} [状态同步] ⚠️ exit_order_id {exit_order_id} 唯一约束冲突,"
|
||
f"update_exit 内部处理失败,尝试不更新 exit_order_id"
|
||
)
|
||
# 再次尝试,不更新 exit_order_id
|
||
try:
|
||
from database.connection import db
|
||
# from database.models import get_beijing_time # 移除本地导入,避免 UnboundLocalError
|
||
exit_time = int(exit_time_ts) if exit_time_ts is not None else get_beijing_time()
|
||
db.execute_update(
|
||
"""UPDATE trades
|
||
SET exit_price = %s, exit_time = %s,
|
||
exit_reason = %s, pnl = %s, pnl_percent = %s, status = 'closed'
|
||
WHERE id = %s""",
|
||
(exit_price, exit_time, exit_reason, pnl, pnl_percent, trade_id)
|
||
)
|
||
logger.info(f"{symbol} [状态同步] ✓ 已更新(跳过 exit_order_id)")
|
||
except Exception as retry_error:
|
||
logger.error(f"{symbol} [状态同步] ❌ 重试更新也失败: {retry_error}")
|
||
raise
|
||
else:
|
||
# 其他错误,重新抛出
|
||
raise
|
||
|
||
logger.info(
|
||
f"{symbol} [状态同步] ✓ 已更新 (ID: {trade_id}, "
|
||
f"盈亏: {pnl:.2f} USDT, {pnl_percent:.2f}%)"
|
||
)
|
||
|
||
# 清理本地记录
|
||
if symbol in self.active_positions:
|
||
await self._stop_position_monitoring(symbol)
|
||
del self.active_positions[symbol]
|
||
logger.debug(f"{symbol} [状态同步] 已清理本地持仓记录")
|
||
|
||
except Exception as e:
|
||
# 详细记录错误信息,包括异常类型、错误消息和堆栈跟踪
|
||
import traceback
|
||
error_type = type(e).__name__
|
||
error_msg = str(e)
|
||
error_traceback = traceback.format_exc()
|
||
|
||
logger.error(
|
||
f"{symbol} [状态同步] ❌ 更新失败 (ID: {trade_id}): "
|
||
f"错误类型={error_type}, 错误消息={error_msg}"
|
||
)
|
||
logger.debug(
|
||
f"{symbol} [状态同步] 错误详情:\n{error_traceback}"
|
||
)
|
||
|
||
# 如果是数据库相关错误,记录更多信息
|
||
if "Duplicate entry" in error_msg or "1062" in error_msg:
|
||
logger.error(
|
||
f"{symbol} [状态同步] 数据库唯一约束冲突,"
|
||
f"可能原因: exit_order_id={exit_order_id} 已被其他交易记录使用"
|
||
)
|
||
elif "database" in error_msg.lower() or "connection" in error_msg.lower():
|
||
logger.error(
|
||
f"{symbol} [状态同步] 数据库连接或执行错误,"
|
||
f"请检查数据库连接状态"
|
||
)
|
||
else:
|
||
logger.info("✓ 持仓状态同步完成,数据库与币安状态一致")
|
||
|
||
# 5. 检查币安有但数据库没有记录的持仓(可能是本策略开仓后未正确落库、或其它来源)
|
||
missing_in_db = binance_symbols - db_open_symbols
|
||
if missing_in_db:
|
||
logger.info(
|
||
f"[账号{self.account_id}] 发现 {len(missing_in_db)} 个持仓在币安存在但数据库中没有 open 记录: "
|
||
f"{', '.join(missing_in_db)}"
|
||
)
|
||
sync_create_manual = config.TRADING_CONFIG.get("SYNC_CREATE_MANUAL_ENTRY_RECORD", False)
|
||
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)
|
||
# 订单统一由自动下单入 DB:同步/持仓 sync 不创建新记录,仅 WS 与自动开仓写库
|
||
only_auto_creates = config.TRADING_CONFIG.get("ONLY_AUTO_TRADE_CREATES_RECORDS", True)
|
||
if only_auto_creates:
|
||
sync_recover = False
|
||
sync_create_manual = False
|
||
if missing_in_db:
|
||
logger.info(
|
||
f"[账号{self.account_id}] 因 ONLY_AUTO_TRADE_CREATES_RECORDS=True 已关闭补建,"
|
||
f"共 {len(missing_in_db)} 个仅币安持仓未写入 DB。"
|
||
" 若要让币安与 DB 一致,请设 ONLY_AUTO_TRADE_CREATES_RECORDS=False 且 SYNC_RECOVER_MISSING_POSITIONS=True,然后重启或等待下次同步。"
|
||
)
|
||
|
||
if sync_recover:
|
||
system_order_prefix = (config.TRADING_CONFIG.get("SYSTEM_ORDER_ID_PREFIX") or "").strip()
|
||
if system_order_prefix:
|
||
logger.info(
|
||
f" → 补建缺失持仓:一律写入 DB、自动挂止损止盈并纳入监控(含 clientOrderId 非 {system_order_prefix!r} 前缀的来历不明仓)"
|
||
)
|
||
else:
|
||
logger.info(" → 补建缺失持仓:一律写入 DB、自动挂止损止盈并纳入监控(含无 SL/TP 的仓位)")
|
||
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:优先用 WS 缓存的 FILLED 消息(基于消息补建),否则按时间范围查开仓订单
|
||
entry_order_id = None
|
||
client_order_id_sync = None
|
||
try:
|
||
rc = getattr(self.client, "redis_cache", None)
|
||
if rc:
|
||
cache_key = f"ats:filled_open_orders:{self.account_id}"
|
||
cached = await rc.hget(cache_key, symbol)
|
||
if cached and isinstance(cached, dict):
|
||
cs = (cached.get("side") or "").strip().upper()
|
||
ap = float(cached.get("avgPrice") or 0)
|
||
eq = float(cached.get("executedQty") or 0)
|
||
if (cs == side and ap > 0
|
||
and abs(ap - entry_price) / max(entry_price, 1e-9) < 0.01
|
||
and abs(eq - quantity) < 1e-6):
|
||
entry_order_id = cached.get("orderId")
|
||
client_order_id_sync = (cached.get("clientOrderId") or "").strip() or None
|
||
if entry_order_id:
|
||
logger.debug(f" {symbol} 补建从 WS 缓存 FILLED 取得 orderId={entry_order_id}, clientOrderId={client_order_id_sync!r}")
|
||
except Exception as e:
|
||
logger.debug(f" {symbol} 补建读 WS 缓存失败: {e}")
|
||
if system_order_prefix and entry_order_id is None:
|
||
try:
|
||
end_ms = int(time.time() * 1000)
|
||
start_ms = end_ms - (24 * 3600 * 1000)
|
||
orders = await self.client.client.futures_get_all_orders(
|
||
symbol=symbol, startTime=start_ms, endTime=end_ms, recvWindow=20000
|
||
)
|
||
if isinstance(orders, list):
|
||
open_orders = [
|
||
o for o in orders
|
||
if isinstance(o, dict) and o.get("reduceOnly") is False
|
||
and str(o.get("side", "")).upper() == side and o.get("status") == "FILLED"
|
||
]
|
||
our_orders = [o for o in open_orders if (o.get("clientOrderId") or "").startswith(system_order_prefix)]
|
||
if our_orders:
|
||
our_orders.sort(key=lambda x: int(x.get("updateTime", 0)), reverse=True)
|
||
best = None
|
||
for o in our_orders:
|
||
ap = float(o.get("avgPrice") or 0)
|
||
eq = float(o.get("executedQty") or o.get("origQty") or 0)
|
||
if ap > 0 and abs(ap - entry_price) / max(entry_price, 1e-9) < 0.01 and abs(eq - quantity) < 1e-6:
|
||
best = o
|
||
break
|
||
if best is None:
|
||
best = our_orders[0]
|
||
entry_order_id = best.get("orderId")
|
||
client_order_id_sync = (best.get("clientOrderId") or "").strip() or None
|
||
logger.debug(f" {symbol} 补建从 get_all_orders 取得 orderId={entry_order_id}, clientOrderId={client_order_id_sync!r}")
|
||
except Exception as e:
|
||
logger.debug(f" {symbol} 补建 get_all_orders 取开仓订单号失败: {e}")
|
||
if entry_order_id is None:
|
||
try:
|
||
recent = await self.client.get_recent_trades(symbol, limit=100)
|
||
if not recent:
|
||
await asyncio.sleep(2)
|
||
recent = await self.client.get_recent_trades(symbol, limit=100)
|
||
if recent:
|
||
same_side = [t for t in recent if str(t.get("side", "")).upper() == side]
|
||
same_side.sort(key=lambda x: int(x.get("time", 0)), reverse=True)
|
||
if system_order_prefix and same_side:
|
||
for t in same_side[:5]:
|
||
oid = t.get("orderId")
|
||
if not oid:
|
||
continue
|
||
try:
|
||
info = await self.client.client.futures_get_order(symbol=symbol, orderId=int(oid), recvWindow=20000)
|
||
cid = (info or {}).get("clientOrderId") or ""
|
||
if cid.startswith(system_order_prefix):
|
||
entry_order_id = oid
|
||
client_order_id_sync = cid.strip() or None
|
||
break
|
||
except Exception:
|
||
continue
|
||
if entry_order_id is None and same_side:
|
||
entry_order_id = same_side[0].get("orderId")
|
||
except Exception:
|
||
pass
|
||
if entry_order_id and client_order_id_sync is None:
|
||
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 ""
|
||
client_order_id_sync = cid.strip() or None
|
||
except Exception:
|
||
pass
|
||
# 标记是否「来历不明」:用于 DB entry_reason 与后续统计分析(sync_recovered_unknown_origin)
|
||
sync_unknown_origin = False
|
||
is_clearly_manual = False
|
||
if system_order_prefix and entry_order_id and client_order_id_sync and not client_order_id_sync.startswith(system_order_prefix):
|
||
is_clearly_manual = True
|
||
logger.debug(f" {symbol} 开仓订单 clientOrderId={client_order_id_sync!r} 非系统前缀,将按来历不明仓补建并挂 SL/TP")
|
||
elif system_order_prefix and entry_order_id and not client_order_id_sync:
|
||
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 cid and not cid.startswith(system_order_prefix):
|
||
is_clearly_manual = True
|
||
except Exception:
|
||
pass
|
||
if system_order_prefix:
|
||
if is_clearly_manual and config.TRADING_CONFIG.get("SYNC_MONITOR_BINANCE_POSITIONS_WITH_SLTP", True):
|
||
if await self._symbol_has_sltp_orders(symbol):
|
||
is_clearly_manual = False
|
||
logger.info(f" → {symbol} 开仓订单 clientOrderId 非系统前缀,但存在止损/止盈单,按系统单补建并监控")
|
||
if is_clearly_manual:
|
||
sync_unknown_origin = True
|
||
logger.info(f" → {symbol} 来历不明(开仓订单非系统前缀),将补建记录、自动挂止损止盈并纳入监控")
|
||
else:
|
||
if sync_recover_only_has_sltp and not (await self._symbol_has_sltp_orders(symbol)):
|
||
sync_unknown_origin = True
|
||
logger.info(f" → {symbol} 无止损/止盈单,将补建记录、自动挂止损止盈并纳入监控")
|
||
# 不再因 is_clearly_manual 或 无 SL/TP 跳过,一律补建 + 挂 SL/TP + 监控
|
||
entry_reason_sync = "sync_recovered_unknown_origin" if sync_unknown_origin else "sync_recovered"
|
||
# 同一开仓订单在 DB 中已有记录(无论 open 或 closed)则跳过,避免重复建单(如 PYTHUSDT 同单多笔)
|
||
if entry_order_id and hasattr(Trade, "get_by_entry_order_id"):
|
||
try:
|
||
existing = Trade.get_by_entry_order_id(entry_order_id)
|
||
if existing and existing.get("symbol") == symbol:
|
||
logger.debug(f" {symbol} 开仓订单 {entry_order_id} 已有记录 (id={existing.get('id')}, status={existing.get('status')}),跳过补建")
|
||
continue
|
||
except Exception:
|
||
pass
|
||
# 从币安取真实开仓时间,避免补建后开仓时间全是“当前时间”
|
||
entry_time_ts = None
|
||
if entry_order_id:
|
||
try:
|
||
oi = await self.client.client.futures_get_order(symbol=symbol, orderId=int(entry_order_id), recvWindow=20000)
|
||
if oi and oi.get("time"):
|
||
entry_time_ts = int(oi["time"]) // 1000
|
||
except Exception:
|
||
pass
|
||
if entry_time_ts is None:
|
||
try:
|
||
recent = await self.client.get_recent_trades(symbol, limit=100)
|
||
same_side = [t for t in (recent or []) if str(t.get("side", "")).upper() == side]
|
||
if same_side:
|
||
same_side.sort(key=lambda x: int(x.get("time", 0)))
|
||
entry_time_ts = int(same_side[0].get("time", 0)) // 1000
|
||
except Exception:
|
||
pass
|
||
try:
|
||
trade_id = Trade.create(
|
||
symbol=symbol,
|
||
side=side,
|
||
quantity=quantity,
|
||
entry_price=entry_price,
|
||
leverage=binance_position.get("leverage", 10),
|
||
entry_reason=entry_reason_sync,
|
||
entry_order_id=entry_order_id,
|
||
client_order_id=client_order_id_sync,
|
||
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,
|
||
entry_time=entry_time_ts,
|
||
)
|
||
logger.info(f" ✓ [DB] {symbol} [状态同步] 补建记录已落库 ID={trade_id} orderId={entry_order_id or '-'} entry_reason={entry_reason_sync}")
|
||
except Exception as create_ex:
|
||
logger.error(
|
||
f" [DB] {symbol} [状态同步] 补建写入 DB 失败,该持仓将无法纳入监控: "
|
||
f"orderId={entry_order_id} error_type={type(create_ex).__name__} error={create_ex}"
|
||
)
|
||
_log_trade_db_failure(
|
||
symbol=symbol, entry_order_id=entry_order_id, side=side, quantity=quantity,
|
||
entry_price=entry_price, account_id=self.account_id,
|
||
reason="sync_recover_create_failed",
|
||
error_type=type(create_ex).__name__, error_message=str(create_ex)
|
||
)
|
||
raise
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
current_price = ticker["price"] if ticker else entry_price
|
||
lev = float(binance_position.get("leverage", 10))
|
||
# ---------- 补建 SL/TP 执行顺序(必须严格保证)----------
|
||
# 1) 先读交易所当前条件单的止损/止盈,再决定用哪套价格,最后才调用 _ensure_exchange_sltp_orders。
|
||
# 否则会先用 risk_manager 算出初始止损,挂到交易所,把已有保本/移动止损覆盖掉。
|
||
# 2) 若交易所止损已达保本或更优 → position_info 采用交易所止损并设 breakevenStopSet=True;
|
||
# 否则采用 risk_manager 初始止损。止盈:交易所有则用交易所,否则用 risk_manager。
|
||
# 3) _ensure_exchange_sltp_orders 用当前 position_info 的 SL/TP 挂单,因此传入的必须是上面决定好的价格。
|
||
sl_from_ex, tp_from_ex = await self._get_sltp_from_exchange(symbol, side)
|
||
breakeven = self._breakeven_stop_price(entry_price, side, None)
|
||
use_exchange_sl = False
|
||
if sl_from_ex is not None:
|
||
if side == "BUY" and sl_from_ex >= breakeven:
|
||
use_exchange_sl = True
|
||
elif side == "SELL" and sl_from_ex <= breakeven:
|
||
use_exchange_sl = True
|
||
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
|
||
if use_exchange_sl:
|
||
stop_loss_price = sl_from_ex
|
||
initial_stop_loss = self.risk_manager.get_stop_loss_price(entry_price, side, quantity, lev, stop_loss_pct=stop_loss_pct)
|
||
logger.info(f" {symbol} [补建] 使用交易所已有止损(保本/移动)sl={stop_loss_price},不覆盖为初始止损 {initial_stop_loss}")
|
||
else:
|
||
stop_loss_price = self.risk_manager.get_stop_loss_price(entry_price, side, quantity, lev, stop_loss_pct=stop_loss_pct)
|
||
initial_stop_loss = stop_loss_price
|
||
if tp_from_ex is not None:
|
||
take_profit_price = tp_from_ex
|
||
else:
|
||
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": initial_stop_loss,
|
||
"leverage": lev, "entryReason": entry_reason_sync, "atr": None, "maxProfit": 0.0, "trailingStopActivated": False,
|
||
"breakevenStopSet": use_exchange_sl,
|
||
"entryTime": entry_time_ts if entry_time_ts is not None else get_beijing_time(),
|
||
}
|
||
self.active_positions[symbol] = position_info
|
||
# 4) 最后再同步到交易所:用已决定好的 position_info(可能是交易所保本或 risk_manager 初始)挂单
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price)
|
||
except Exception as sltp_e:
|
||
logger.warning(f" {symbol} [补建] 补挂交易所止损止盈失败(不影响监控): {sltp_e}")
|
||
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(
|
||
f"[账号{self.account_id}] → 已跳过自动创建交易记录(SYNC_RECOVER_MISSING_POSITIONS 未开启或 ONLY_AUTO_TRADE_CREATES_RECORDS=True)。"
|
||
" 若要让币安持仓与 DB 一致:设 ONLY_AUTO_TRADE_CREATES_RECORDS=False、SYNC_RECOVER_MISSING_POSITIONS=True,重启或等待下次同步。"
|
||
)
|
||
elif sync_create_manual:
|
||
# 为手动开仓的持仓创建数据库记录并启动监控(仅当显式开启且未走上面的「补建系统单」时)
|
||
for symbol in missing_in_db:
|
||
try:
|
||
# 获取币安持仓详情
|
||
binance_position = next(
|
||
(p for p in binance_positions if p['symbol'] == symbol),
|
||
None
|
||
)
|
||
if not binance_position:
|
||
continue
|
||
|
||
position_amt = binance_position['positionAmt']
|
||
entry_price = binance_position['entryPrice']
|
||
quantity = abs(position_amt)
|
||
side = 'BUY' if position_amt > 0 else 'SELL'
|
||
|
||
notional = (float(entry_price) * float(quantity)) if entry_price and quantity else 0
|
||
if notional < 1.0:
|
||
logger.debug(f"{symbol} [状态同步] 跳过灰尘持仓 (名义 {notional:.4f} USDT < 1),不创建记录")
|
||
continue
|
||
logger.info(
|
||
f"{symbol} [状态同步] 检测到手动开仓,创建数据库记录... "
|
||
f"({side} {quantity:.4f} @ {entry_price:.4f})"
|
||
)
|
||
# 尽量从币安成交取 entry_order_id 与真实开仓时间(limit=100、空时重试一次)
|
||
entry_order_id = None
|
||
entry_time_ts = None
|
||
try:
|
||
recent = await self.client.get_recent_trades(symbol, limit=100)
|
||
if not recent:
|
||
await asyncio.sleep(2)
|
||
recent = await self.client.get_recent_trades(symbol, limit=100)
|
||
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')
|
||
# 开仓时间取同方向最早一笔成交(oldest),避免显示为“当前时间”
|
||
same_side_asc = sorted(same_side, key=lambda x: int(x.get('time', 0)))
|
||
entry_time_ts = int(same_side_asc[0].get('time', 0)) // 1000
|
||
except Exception:
|
||
pass
|
||
if entry_time_ts is None and entry_order_id:
|
||
try:
|
||
oi = await self.client.client.futures_get_order(symbol=symbol, orderId=int(entry_order_id), recvWindow=20000)
|
||
if oi and oi.get('time'):
|
||
entry_time_ts = int(oi['time']) // 1000
|
||
except Exception:
|
||
pass
|
||
# 创建数据库记录(显式传入 account_id、真实开仓时间)
|
||
try:
|
||
trade_id = Trade.create(
|
||
symbol=symbol,
|
||
side=side,
|
||
quantity=quantity,
|
||
entry_price=entry_price,
|
||
leverage=binance_position.get('leverage', 10),
|
||
entry_reason='manual_entry', # 标记为手动开仓
|
||
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,
|
||
entry_time=entry_time_ts,
|
||
)
|
||
logger.info(f"[DB] {symbol} [状态同步] 手动开仓记录已落库 ID={trade_id} orderId={entry_order_id or '-'}")
|
||
except Exception as create_ex:
|
||
logger.error(
|
||
f"[DB] {symbol} [状态同步] 手动开仓写入 DB 失败: "
|
||
f"orderId={entry_order_id} error_type={type(create_ex).__name__} error={create_ex}"
|
||
)
|
||
_log_trade_db_failure(
|
||
symbol=symbol, entry_order_id=entry_order_id, side=side, quantity=quantity,
|
||
entry_price=entry_price, account_id=self.account_id,
|
||
reason="manual_entry_create_failed",
|
||
error_type=type(create_ex).__name__, error_message=str(create_ex)
|
||
)
|
||
continue
|
||
|
||
# 创建本地持仓记录(用于监控)
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
current_price = ticker['price'] if ticker else entry_price
|
||
# ---------- 手动开仓补建 SL/TP 顺序:先读交易所 → 决定 SL/TP → 再写 position_info ----------
|
||
sl_from_ex, tp_from_ex = await self._get_sltp_from_exchange(symbol, side)
|
||
breakeven = self._breakeven_stop_price(entry_price, side, None)
|
||
use_exchange_sl = False
|
||
if sl_from_ex is not None:
|
||
if side == 'BUY' and sl_from_ex >= breakeven:
|
||
use_exchange_sl = True
|
||
elif side == 'SELL' and sl_from_ex <= breakeven:
|
||
use_exchange_sl = True
|
||
|
||
# 计算止损止盈(缺失时用 risk_manager 基于保证金)
|
||
leverage = binance_position.get('leverage', 10)
|
||
stop_loss_pct_margin = config.TRADING_CONFIG.get('STOP_LOSS_PERCENT', 0.08)
|
||
if stop_loss_pct_margin is not None and stop_loss_pct_margin > 1:
|
||
stop_loss_pct_margin = stop_loss_pct_margin / 100.0
|
||
take_profit_pct_margin = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.15)
|
||
if take_profit_pct_margin is not None and take_profit_pct_margin > 1:
|
||
take_profit_pct_margin = take_profit_pct_margin / 100.0
|
||
if take_profit_pct_margin is None or take_profit_pct_margin == 0:
|
||
take_profit_pct_margin = stop_loss_pct_margin * 2.0
|
||
|
||
if use_exchange_sl:
|
||
stop_loss_price = sl_from_ex
|
||
initial_stop_loss = self.risk_manager.get_stop_loss_price(
|
||
entry_price, side, quantity, leverage,
|
||
stop_loss_pct=stop_loss_pct_margin
|
||
)
|
||
logger.info(f" {symbol} [补建-手动] 使用交易所已有止损(保本/移动)sl={stop_loss_price},不覆盖为初始止损 {initial_stop_loss}")
|
||
else:
|
||
stop_loss_price = self.risk_manager.get_stop_loss_price(
|
||
entry_price, side, quantity, leverage,
|
||
stop_loss_pct=stop_loss_pct_margin
|
||
)
|
||
initial_stop_loss = stop_loss_price
|
||
if tp_from_ex is not None:
|
||
take_profit_price = tp_from_ex
|
||
else:
|
||
take_profit_price = self.risk_manager.get_take_profit_price(
|
||
entry_price, side, quantity, leverage,
|
||
take_profit_pct=take_profit_pct_margin
|
||
)
|
||
|
||
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': initial_stop_loss,
|
||
'leverage': leverage,
|
||
'entryReason': 'manual_entry',
|
||
'entryTime': entry_time_ts if entry_time_ts is not None else get_beijing_time(),
|
||
'atr': None,
|
||
'maxProfit': 0.0,
|
||
'trailingStopActivated': False,
|
||
'breakevenStopSet': use_exchange_sl
|
||
}
|
||
|
||
self.active_positions[symbol] = position_info
|
||
|
||
# 启动WebSocket监控
|
||
if self._monitoring_enabled:
|
||
await self._start_position_monitoring(symbol)
|
||
logger.info(f"[账号{self.account_id}] {symbol} [状态同步] ✓ 已启动实时监控")
|
||
|
||
logger.info(f"{symbol} [状态同步] ✓ 手动开仓同步完成")
|
||
|
||
except Exception as e:
|
||
logger.error(f"{symbol} [状态同步] ❌ 处理手动开仓失败: {e}")
|
||
import traceback
|
||
logger.error(f" 错误详情:\n{traceback.format_exc()}")
|
||
|
||
# 6. 同步挂单信息 (STOP_MARKET / TAKE_PROFIT_MARKET)
|
||
if self.active_positions:
|
||
logger.debug("正在同步持仓挂单信息...")
|
||
try:
|
||
tasks = []
|
||
symbols = list(self.active_positions.keys())
|
||
for symbol in symbols:
|
||
tasks.append(self.client.get_open_orders(symbol))
|
||
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
for symbol, orders in zip(symbols, results):
|
||
if isinstance(orders, list):
|
||
# Filter for relevant orders (SL/TP)
|
||
conditional_orders = []
|
||
for o in orders:
|
||
o_type = o.get('type')
|
||
# 关注止盈止损单
|
||
if o_type in ['STOP_MARKET', 'TAKE_PROFIT_MARKET', 'STOP', 'TAKE_PROFIT']:
|
||
conditional_orders.append({
|
||
'orderId': o.get('orderId'),
|
||
'type': o_type,
|
||
'side': o.get('side'),
|
||
'stopPrice': float(o.get('stopPrice', 0)),
|
||
'price': float(o.get('price', 0)),
|
||
'origType': o.get('origType'),
|
||
'reduceOnly': o.get('reduceOnly'),
|
||
'status': o.get('status')
|
||
})
|
||
|
||
if symbol in self.active_positions:
|
||
self.active_positions[symbol]['openOrders'] = conditional_orders
|
||
logger.debug(f"{symbol} 同步挂单: {len(conditional_orders)} 个")
|
||
else:
|
||
logger.warning(f"{symbol} 获取挂单失败: {orders}")
|
||
except Exception as e:
|
||
logger.error(f"同步挂单信息失败: {e}")
|
||
|
||
logger.info("持仓状态同步完成")
|
||
|
||
except Exception as e:
|
||
logger.error(f"同步持仓状态失败: {e}")
|
||
import traceback
|
||
logger.error(f"错误详情:\n{traceback.format_exc()}")
|
||
|
||
async def start_all_position_monitoring(self):
|
||
"""
|
||
启动所有持仓的WebSocket实时监控
|
||
"""
|
||
if not self._monitoring_enabled:
|
||
logger.info("实时监控已禁用,跳过启动")
|
||
return
|
||
|
||
# WebSocket 现在直接使用 aiohttp,不需要检查 socket_manager
|
||
if not self.client:
|
||
logger.warning("客户端未初始化,无法启动实时监控")
|
||
return
|
||
|
||
# 获取当前所有持仓(与 sync 一致:仅本系统关心的持仓会进 active_positions)
|
||
# 多账号时必须用 account_id 读缓存;若缓存返回数量少于本地记录则强制 REST 一次,避免误判「持仓已不存在」
|
||
positions = await self._get_open_positions()
|
||
binance_symbols = {p['symbol'] for p in positions}
|
||
active_symbols = set(self.active_positions.keys())
|
||
if active_symbols and len(binance_symbols) < len(active_symbols):
|
||
try:
|
||
rest_positions = await self.client.get_open_positions()
|
||
if rest_positions and len(rest_positions) > len(positions):
|
||
positions = rest_positions
|
||
binance_symbols = {p['symbol'] for p in positions}
|
||
logger.info(f"[账号{self.account_id}] 缓存持仓数少于本地记录,已用 REST 拉取完整持仓: {len(binance_symbols)} 个")
|
||
except Exception as e:
|
||
logger.debug(f"[账号{self.account_id}] REST 拉取完整持仓失败: {e}")
|
||
sync_create_manual = config.TRADING_CONFIG.get("SYNC_CREATE_MANUAL_ENTRY_RECORD", False)
|
||
|
||
logger.info(f"[账号{self.account_id}] 币安持仓: {len(binance_symbols)} 个 ({', '.join(binance_symbols) if binance_symbols else '无'})")
|
||
logger.info(f"[账号{self.account_id}] 本地持仓记录: {len(active_symbols)} 个 ({', '.join(active_symbols) if active_symbols else '无'})")
|
||
|
||
# 仅为本系统已有记录的持仓启动监控;若未开启「同步创建手动开仓记录」,则不为「仅币安有仓」创建临时记录或监控
|
||
# 例外:SYNC_MONITOR_BINANCE_POSITIONS_WITH_SLTP=True 时,对「仅币安有仓且存在止损/止盈单」的视为可监管(多为系统单),补建并监控
|
||
only_binance = binance_symbols - active_symbols
|
||
monitor_binance_with_sltp = config.TRADING_CONFIG.get("SYNC_MONITOR_BINANCE_POSITIONS_WITH_SLTP", True)
|
||
if only_binance and not sync_create_manual and not monitor_binance_with_sltp:
|
||
logger.info(f"[账号{self.account_id}] 跳过 {len(only_binance)} 个仅币安持仓的监控(SYNC_CREATE_MANUAL_ENTRY_RECORD=False 且 SYNC_MONITOR_BINANCE_POSITIONS_WITH_SLTP=False): {', '.join(only_binance)}")
|
||
|
||
for position in positions:
|
||
symbol = position['symbol']
|
||
if symbol not in self._monitor_tasks:
|
||
# 若不在 active_positions:开启「同步创建手动开仓」则全部接入;或开启「监控仅币安有仓」时也接入(有 SL/TP 的视为系统单,无 SL/TP 的补挂并监控,避免裸仓)
|
||
if symbol not in self.active_positions:
|
||
has_sltp = await self._symbol_has_sltp_orders(symbol) if monitor_binance_with_sltp else False
|
||
should_create = sync_create_manual or monitor_binance_with_sltp
|
||
if not should_create:
|
||
continue
|
||
if sync_create_manual:
|
||
logger.warning(f"[账号{self.account_id}] {symbol} 在币安有持仓但不在本地记录中,可能是手动开仓,尝试创建记录...")
|
||
elif has_sltp:
|
||
logger.info(f"[账号{self.account_id}] {symbol} 仅币安有仓且存在止损/止盈单,按系统单接入监控并补建记录")
|
||
else:
|
||
logger.info(f"[账号{self.account_id}] {symbol} 仅币安有仓且无止损/止盈单,接入监控并补挂 SL/TP(避免裸仓)")
|
||
try:
|
||
entry_price = position.get('entryPrice', 0)
|
||
position_amt = position['positionAmt']
|
||
quantity = abs(position_amt)
|
||
side = 'BUY' if position_amt > 0 else 'SELL'
|
||
|
||
# 创建临时记录用于监控
|
||
ticker = await self.client.get_ticker_24h(symbol)
|
||
current_price = ticker['price'] if ticker else entry_price
|
||
|
||
# 计算止损止盈(基于保证金)
|
||
try:
|
||
leverage = float(position.get('leverage', 10))
|
||
except (ValueError, TypeError):
|
||
leverage = 10.0
|
||
|
||
# 优先从币安读取当前止损/止盈,避免用“初始止损”覆盖已有保本/移动止损
|
||
sl_from_ex, tp_from_ex = await self._get_sltp_from_exchange(symbol, side)
|
||
if sl_from_ex is not None or tp_from_ex is not None:
|
||
stop_loss_price = float(sl_from_ex) if sl_from_ex is not None else None
|
||
take_profit_price = float(tp_from_ex) if tp_from_ex is not None else None
|
||
else:
|
||
stop_loss_price = None
|
||
take_profit_price = None
|
||
# 若当前已明显盈利,不用交易所初始止损,直接算锁利止损(避免重启后把 226.97 这类初始止损再挂回去)
|
||
entry_price_f = float(entry_price)
|
||
current_price_f = float(current_price or entry_price_f)
|
||
margin = (entry_price_f * quantity) / leverage if leverage else (entry_price_f * quantity)
|
||
if side == 'BUY':
|
||
pnl_amount = (current_price_f - entry_price_f) * quantity
|
||
else:
|
||
pnl_amount = (entry_price_f - current_price_f) * quantity
|
||
pnl_percent_margin = (pnl_amount / margin * 100) if margin > 0 else 0
|
||
trailing_activation = config.TRADING_CONFIG.get('TRAILING_STOP_ACTIVATION', 0.10)
|
||
if trailing_activation and trailing_activation <= 1:
|
||
trailing_activation = trailing_activation * 100
|
||
if pnl_percent_margin >= (trailing_activation or 10):
|
||
protect_amount = max(margin * float(config.TRADING_CONFIG.get('TRAILING_STOP_PROTECT', 0.02) or 0.02), self._min_protect_amount_for_fees(margin, leverage))
|
||
if side == 'BUY':
|
||
stop_loss_price = entry_price_f + (pnl_amount - protect_amount) / quantity
|
||
stop_loss_price = max(stop_loss_price, self._breakeven_stop_price(entry_price_f, 'BUY'))
|
||
else:
|
||
stop_loss_price = entry_price_f - protect_amount / quantity
|
||
stop_loss_price = max(stop_loss_price, self._breakeven_stop_price(entry_price_f, 'SELL'))
|
||
logger.info(f"[账号{self.account_id}] {symbol} 接入监控时已盈利{pnl_percent_margin:.1f}%,使用锁利止损 {stop_loss_price:.4f} 替代交易所原止损")
|
||
if take_profit_price is None:
|
||
tp_pct = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.15) or 0.15
|
||
if tp_pct > 1:
|
||
tp_pct = tp_pct / 100.0
|
||
take_profit_price = self.risk_manager.get_take_profit_price(entry_price, side, quantity, leverage, take_profit_pct=tp_pct)
|
||
if stop_loss_price is None or take_profit_price is None:
|
||
stop_loss_pct_margin = config.TRADING_CONFIG.get('STOP_LOSS_PERCENT', 0.08)
|
||
if stop_loss_pct_margin is not None and stop_loss_pct_margin > 1:
|
||
stop_loss_pct_margin = stop_loss_pct_margin / 100.0
|
||
take_profit_pct_margin = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.15)
|
||
if take_profit_pct_margin is not None and take_profit_pct_margin > 1:
|
||
take_profit_pct_margin = take_profit_pct_margin / 100.0
|
||
if take_profit_pct_margin is None or take_profit_pct_margin == 0:
|
||
take_profit_pct_margin = (stop_loss_pct_margin or 0.05) * 2.0
|
||
if stop_loss_price is None:
|
||
stop_loss_price = self.risk_manager.get_stop_loss_price(
|
||
entry_price, side, quantity, leverage,
|
||
stop_loss_pct=stop_loss_pct_margin or 0.05
|
||
)
|
||
if take_profit_price is None:
|
||
take_profit_price = self.risk_manager.get_take_profit_price(
|
||
entry_price, side, quantity, leverage,
|
||
take_profit_pct=take_profit_pct_margin
|
||
)
|
||
|
||
entry_reason = 'manual_entry_temp' if sync_create_manual else 'sync_recovered'
|
||
position_info = {
|
||
'symbol': symbol,
|
||
'side': side,
|
||
'quantity': quantity,
|
||
'entryPrice': entry_price,
|
||
'changePercent': 0,
|
||
'orderId': None,
|
||
'tradeId': None,
|
||
'stopLoss': stop_loss_price,
|
||
'takeProfit': take_profit_price,
|
||
'initialStopLoss': stop_loss_price,
|
||
'leverage': leverage,
|
||
'entryReason': entry_reason,
|
||
'atr': None,
|
||
'maxProfit': 0.0,
|
||
'trailingStopActivated': False,
|
||
'breakevenStopSet': False
|
||
}
|
||
# 订单统一由自动下单入 DB,此处仅做内存监控不创建 DB 记录
|
||
if not sync_create_manual and DB_AVAILABLE and Trade and not config.TRADING_CONFIG.get("ONLY_AUTO_TRADE_CREATES_RECORDS", True):
|
||
try:
|
||
notional = float(entry_price) * quantity
|
||
trade_id = Trade.create(
|
||
symbol=symbol,
|
||
side=side,
|
||
quantity=quantity,
|
||
entry_price=entry_price,
|
||
leverage=leverage,
|
||
entry_reason="sync_recovered",
|
||
entry_order_id=None,
|
||
notional_usdt=notional,
|
||
margin_usdt=(notional / leverage) if leverage else None,
|
||
account_id=self.account_id,
|
||
stop_loss_price=stop_loss_price,
|
||
take_profit_price=take_profit_price,
|
||
)
|
||
position_info['tradeId'] = trade_id
|
||
except Exception as db_e:
|
||
logger.debug(f"{symbol} 补建 DB 记录失败(不影响监控): {db_e}")
|
||
self.active_positions[symbol] = position_info
|
||
logger.info(f"[账号{self.account_id}] {symbol} 已创建持仓记录用于监控" + (" (已写入 DB)" if position_info.get("tradeId") else ""))
|
||
# 也为“现有持仓”补挂交易所保护单(重启/掉线更安全)
|
||
try:
|
||
mp = None
|
||
try:
|
||
mp = float(position.get("markPrice", 0) or 0) or None
|
||
except Exception:
|
||
mp = None
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=mp or current_price)
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} 补挂币安止盈止损失败(不影响监控): {e}")
|
||
# 重启后立即按当前价做一次保本/移动止损检查并同步,不依赖首条 WS 推送
|
||
try:
|
||
_px = float(position.get("markPrice", 0) or 0) or float(current_price or entry_price)
|
||
await self._check_single_position(symbol, _px)
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 启动时保本/移动止损检查失败: {e}")
|
||
except Exception as e:
|
||
err_msg = str(e) or repr(e) or type(e).__name__
|
||
logger.error(
|
||
f"[账号{self.account_id}] {symbol} 创建临时持仓记录失败: {err_msg}",
|
||
exc_info=True,
|
||
)
|
||
else:
|
||
# 已在 active_positions 的持仓:启动前统一补挂/修正交易所 SL/TP(识别缺止盈、止损过远等异常并替换)
|
||
position_info = self.active_positions.get(symbol)
|
||
if position_info and position_info.get("stopLoss") and position_info.get("takeProfit"):
|
||
try:
|
||
mp = None
|
||
try:
|
||
mp = float(position.get("markPrice", 0) or 0) or None
|
||
except Exception:
|
||
mp = None
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=mp)
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} 补挂/修正交易所止损止盈失败(不影响监控): {e}")
|
||
|
||
await self._start_position_monitoring(symbol)
|
||
|
||
logger.info(f"[账号{self.account_id}] 已启动 {len(self._monitor_tasks)} 个持仓的实时监控")
|
||
|
||
async def stop_all_position_monitoring(self):
|
||
"""
|
||
停止所有持仓的WebSocket监控
|
||
"""
|
||
symbols = list(self._monitor_tasks.keys())
|
||
for symbol in symbols:
|
||
await self._stop_position_monitoring(symbol)
|
||
logger.info(f"已停止所有持仓监控 ({len(symbols)} 个)")
|
||
|
||
async def _start_position_monitoring(self, symbol: str):
|
||
"""
|
||
启动单个持仓的WebSocket价格监控
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
"""
|
||
if symbol in self._monitor_tasks:
|
||
logger.debug(f"{symbol} 监控任务已存在,跳过")
|
||
return
|
||
|
||
# WebSocket 现在直接使用 aiohttp,不需要检查 socket_manager
|
||
if not self.client:
|
||
logger.warning(f"{symbol} 客户端未初始化,无法启动监控")
|
||
return
|
||
|
||
try:
|
||
task = asyncio.create_task(self._monitor_position_price(symbol))
|
||
self._monitor_tasks[symbol] = task
|
||
logger.info(f"[账号{self.account_id}] ✓ 启动 {symbol} WebSocket实时价格监控")
|
||
except Exception as e:
|
||
logger.error(f"启动 {symbol} 监控失败: {e}")
|
||
|
||
async def _stop_position_monitoring(self, symbol: str):
|
||
"""
|
||
停止单个持仓的WebSocket监控
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
"""
|
||
# 幂等:可能会被多处/并发调用,先 pop 再处理,避免 KeyError
|
||
task = self._monitor_tasks.pop(symbol, None)
|
||
if task is None:
|
||
return
|
||
|
||
if not task.done():
|
||
task.cancel()
|
||
try:
|
||
await task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
logger.debug(f"已停止 {symbol} 的WebSocket监控")
|
||
|
||
async def _monitor_position_price(self, symbol: str):
|
||
"""
|
||
监控单个持仓的价格(WebSocket实时推送)
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
"""
|
||
retry_count = 0
|
||
max_retries = 5
|
||
|
||
while retry_count < max_retries:
|
||
try:
|
||
if symbol not in self.active_positions:
|
||
logger.info(f"[账号{self.account_id}] {symbol} 持仓已不存在,停止监控")
|
||
break
|
||
|
||
# 使用WebSocket订阅价格流
|
||
# 直接使用 aiohttp 连接 Binance 期货 WebSocket API
|
||
# 根据文档:https://developers.binance.com/docs/derivatives/usds-margined-futures/websocket-market-streams
|
||
# 端点:wss://fstream.binance.com/ws/<symbol>@ticker
|
||
ws_url = f"wss://fstream.binance.com/ws/{symbol.lower()}@ticker"
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.ws_connect(ws_url) as ws:
|
||
logger.debug(f"{symbol} WebSocket连接已建立,开始接收价格更新")
|
||
retry_count = 0 # 连接成功,重置重试计数
|
||
|
||
async for msg in ws:
|
||
if symbol not in self.active_positions:
|
||
logger.info(f"[账号{self.account_id}] {symbol} 持仓已不存在,停止监控")
|
||
break
|
||
|
||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||
try:
|
||
# 解析 JSON 消息
|
||
data = json.loads(msg.data)
|
||
|
||
# WebSocket 返回的数据格式:{'e': '24hrTicker', 's': 'BTCUSDT', 'c': '50000.00', ...}
|
||
# 根据文档,ticker 流包含 'c' 字段(最后价格)
|
||
if isinstance(data, dict):
|
||
if 'c' in data: # 'c' 是当前价格
|
||
current_price = float(data['c'])
|
||
# 立即检查止损止盈
|
||
await self._check_single_position(symbol, current_price)
|
||
elif 'data' in data:
|
||
# 兼容组合流格式(如果使用 /stream 端点)
|
||
if isinstance(data['data'], dict) and 'c' in data['data']:
|
||
current_price = float(data['data']['c'])
|
||
await self._check_single_position(symbol, current_price)
|
||
except (KeyError, ValueError, TypeError, json.JSONDecodeError) as e:
|
||
logger.debug(f"{symbol} 解析价格数据失败: {e}, 消息: {msg.data[:100] if hasattr(msg, 'data') else 'N/A'}")
|
||
continue
|
||
elif msg.type == aiohttp.WSMsgType.ERROR:
|
||
logger.warning(f"{symbol} WebSocket错误: {ws.exception()}")
|
||
break
|
||
elif msg.type == aiohttp.WSMsgType.CLOSE:
|
||
logger.info(f"{symbol} WebSocket连接关闭")
|
||
break
|
||
|
||
except asyncio.CancelledError:
|
||
logger.info(f"{symbol} 监控任务已取消")
|
||
break
|
||
except Exception as e:
|
||
retry_count += 1
|
||
logger.warning(f"{symbol} WebSocket监控出错 (重试 {retry_count}/{max_retries}): {e}")
|
||
|
||
if retry_count < max_retries:
|
||
# 指数退避重试
|
||
wait_time = min(2 ** retry_count, 30)
|
||
logger.info(f"{symbol} {wait_time}秒后重试连接...")
|
||
await asyncio.sleep(wait_time)
|
||
else:
|
||
logger.error(f"{symbol} WebSocket监控失败,已达到最大重试次数")
|
||
# 回退到定时检查模式
|
||
logger.info(f"{symbol} 将使用定时检查模式(非实时)")
|
||
break
|
||
|
||
# 清理任务
|
||
if symbol in self._monitor_tasks:
|
||
del self._monitor_tasks[symbol]
|
||
|
||
async def _check_single_position(self, symbol: str, current_price: float):
|
||
"""
|
||
检查单个持仓的止损止盈(实时检查)
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
current_price: 当前价格
|
||
"""
|
||
position_info = self.active_positions.get(symbol)
|
||
if not position_info:
|
||
return
|
||
|
||
# 确保所有值都是float类型
|
||
entry_price = float(position_info['entryPrice'])
|
||
quantity = float(position_info['quantity'])
|
||
current_price_float = float(current_price)
|
||
|
||
# 获取杠杆(确保为float)
|
||
try:
|
||
leverage = float(position_info.get('leverage', 10))
|
||
except (ValueError, TypeError):
|
||
leverage = 10.0
|
||
|
||
# 计算持仓价值和保证金
|
||
position_value = entry_price * quantity
|
||
margin = position_value / leverage if leverage > 0 else position_value
|
||
|
||
# 计算盈亏金额
|
||
if position_info['side'] == 'BUY':
|
||
pnl_amount = (current_price_float - entry_price) * quantity
|
||
else: # SELL
|
||
pnl_amount = (entry_price - current_price_float) * quantity
|
||
|
||
# 计算盈亏百分比(ROE - Return on Equity,即相对于保证金的盈亏)
|
||
# 这是用户最关心的“盈亏比”
|
||
pnl_percent_margin = (pnl_amount / margin * 100) if margin > 0 else 0
|
||
|
||
# 也计算价格百分比(不带杠杆的原始涨跌幅)
|
||
if position_info['side'] == 'BUY':
|
||
pnl_percent_price = ((current_price_float - entry_price) / entry_price) * 100
|
||
else: # SELL
|
||
pnl_percent_price = ((entry_price - current_price_float) / entry_price) * 100
|
||
|
||
# 更新最大盈利(基于保证金)及最后一次创新高时间(用于滞涨早止盈)
|
||
if pnl_percent_margin > position_info.get('maxProfit', 0):
|
||
position_info['maxProfit'] = pnl_percent_margin
|
||
position_info['lastNewHighTs'] = time.time()
|
||
|
||
# 滞涨早止盈(实时监控):曾涨到约 N% 后 X 小时内未创新高 → 分批减仓 + 抬止损
|
||
stagnation_enabled = bool(config.TRADING_CONFIG.get('STAGNATION_EARLY_EXIT_ENABLED', False))
|
||
if stagnation_enabled and not position_info.get('stagnationExitTriggered', False):
|
||
max_profit = float(position_info.get('maxProfit', 0) or 0)
|
||
min_runup = float(config.TRADING_CONFIG.get('STAGNATION_MIN_RUNUP_PCT', 10) or 10)
|
||
stall_hours = float(config.TRADING_CONFIG.get('STAGNATION_NO_NEW_HIGH_HOURS', 3) or 3)
|
||
partial_pct = float(config.TRADING_CONFIG.get('STAGNATION_PARTIAL_CLOSE_PCT', 0.5) or 0.5)
|
||
lock_pct = float(config.TRADING_CONFIG.get('STAGNATION_LOCK_PCT', 5) or 5)
|
||
last_high_ts = position_info.get('lastNewHighTs')
|
||
if last_high_ts is not None and max_profit >= min_runup and pnl_percent_margin < max_profit:
|
||
stall_sec = stall_hours * 3600
|
||
if (time.time() - last_high_ts) >= stall_sec:
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 曾达浮盈{max_profit:.2f}%≥{min_runup}%,"
|
||
f"已{stall_hours:.1f}h未创新高,执行分批减仓+抬止损"
|
||
)
|
||
try:
|
||
close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY'
|
||
close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT'
|
||
remaining_qty = float(position_info.get('remainingQuantity', quantity))
|
||
partial_quantity = remaining_qty * partial_pct
|
||
live_amt = await self._get_live_position_amt(symbol, position_side=close_position_side)
|
||
if live_amt is not None and abs(live_amt) > 0:
|
||
partial_quantity = min(partial_quantity, abs(live_amt))
|
||
partial_quantity = await self._adjust_close_quantity(symbol, partial_quantity)
|
||
if partial_quantity > 0:
|
||
partial_order = await self.client.place_order(
|
||
symbol=symbol, side=close_side, quantity=partial_quantity,
|
||
order_type='MARKET', reduce_only=True, position_side=close_position_side,
|
||
)
|
||
if partial_order:
|
||
position_info['partialProfitTaken'] = True
|
||
position_info['remainingQuantity'] = remaining_qty - partial_quantity
|
||
logger.info(f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 部分平仓{partial_quantity:.4f},剩余{position_info['remainingQuantity']:.4f}")
|
||
remaining_after = float(position_info.get('remainingQuantity', quantity))
|
||
lev = float(position_info.get('leverage', 10) or 10)
|
||
rem_margin = (entry_price * remaining_after) / lev if lev > 0 else (entry_price * remaining_after)
|
||
lock_pct_use = max(lock_pct, max_profit / 2.0)
|
||
new_sl = self._stop_price_to_lock_pct(entry_price, position_info['side'], rem_margin, remaining_after, lock_pct_use)
|
||
breakeven = self._breakeven_stop_price(entry_price, position_info['side'])
|
||
current_sl = position_info.get('stopLoss')
|
||
side_here = position_info['side']
|
||
set_sl = (side_here == 'BUY' and (current_sl is None or new_sl > current_sl) and new_sl >= breakeven) or (
|
||
side_here == 'SELL' and (current_sl is None or new_sl < current_sl) and new_sl <= breakeven)
|
||
if set_sl:
|
||
position_info['stopLoss'] = new_sl
|
||
logger.info(f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 剩余仓位止损上移至 {new_sl:.4f},锁定约{lock_pct_use:.1f}%利润")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float)
|
||
except Exception as sync_e:
|
||
logger.warning(f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 同步止损至交易所失败: {sync_e}")
|
||
position_info['stagnationExitTriggered'] = True
|
||
except Exception as e:
|
||
logger.warning(f"[账号{self.account_id}] {symbol} [滞涨早止盈-实时] 执行异常: {e}", exc_info=False)
|
||
|
||
# ⚠️ 2026-01-27修复:提前初始化partial_profit_taken,避免在止损检查时未定义
|
||
partial_profit_taken = position_info.get('partialProfitTaken', False)
|
||
remaining_quantity = position_info.get('remainingQuantity', quantity)
|
||
|
||
# 移动止损逻辑(盈利后保护利润,基于保证金)
|
||
# 每次检查时从Redis重新加载配置,确保配置修改能即时生效
|
||
try:
|
||
if config._config_manager:
|
||
config._config_manager.reload_from_redis()
|
||
config.TRADING_CONFIG = config._get_trading_config()
|
||
except Exception as e:
|
||
logger.debug(f"从Redis重新加载配置失败: {e}")
|
||
|
||
# 时间/无盈利止损:持仓超过 N 小时且盈利未达阈值则平仓(低波动期减少磨单)
|
||
time_stop_enabled = config.TRADING_CONFIG.get('TIME_STOP_ENABLED', False)
|
||
if time_stop_enabled:
|
||
max_hold_hours = float(config.get_effective_config('TIME_STOP_MAX_HOLD_HOURS', 8) or 8)
|
||
min_pnl_to_hold = float(config.TRADING_CONFIG.get('TIME_STOP_MIN_PNL_PCT_TO_HOLD', 0.0) or 0.0)
|
||
entry_time = position_info.get('entryTime')
|
||
hold_hours = 0.0
|
||
if entry_time:
|
||
try:
|
||
if isinstance(entry_time, datetime):
|
||
hold_hours = (get_beijing_time() - entry_time).total_seconds() / 3600.0
|
||
elif isinstance(entry_time, str):
|
||
entry_dt = datetime.strptime(entry_time, '%Y-%m-%d %H:%M:%S')
|
||
hold_hours = (get_beijing_time() - entry_dt).total_seconds() / 3600.0
|
||
else:
|
||
hold_hours = (time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0)) / 3600.0
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 解析 entryTime 失败: {e}")
|
||
if hold_hours >= max_hold_hours and pnl_percent_margin < min_pnl_to_hold:
|
||
logger.info(
|
||
f"{symbol} [时间止损] 持仓 {hold_hours:.1f}h >= {max_hold_hours}h 且盈亏 {pnl_percent_margin:.2f}% < {min_pnl_to_hold}%,平仓"
|
||
)
|
||
if await self.close_position(symbol, reason='time_stop'):
|
||
logger.info(f"{symbol} [时间止损] 平仓成功")
|
||
return
|
||
|
||
# ⚠️ 优化:已完全移除时间锁限制
|
||
# 理由:1) 止损和止盈都应该立即执行,不受时间限制
|
||
# 2) 交易所级别的止损/止盈单已提供保护
|
||
# 3) 分步止盈策略本身已提供利润保护
|
||
# 4) 及时执行止损/止盈可以保护资金和利润
|
||
# 注意:如果需要防止秒级平仓,可以通过提高入场信号质量(MIN_SIGNAL_STRENGTH)来实现
|
||
|
||
# 检查是否启用移动止损/保本(默认 True,与 config.py 一致;显式设为 False 才关闭)
|
||
profit_protection_enabled = bool(config.TRADING_CONFIG.get('PROFIT_PROTECTION_ENABLED', True))
|
||
use_trailing = profit_protection_enabled and bool(config.TRADING_CONFIG.get('USE_TRAILING_STOP', True))
|
||
if use_trailing:
|
||
logger.debug(f"[账号{self.account_id}] {symbol} [实时监控-移动止损] 已启用,将检查移动止损逻辑")
|
||
else:
|
||
if not profit_protection_enabled:
|
||
logger.debug(f"[账号{self.account_id}] {symbol} [实时监控-移动止损/保本] 已禁用(PROFIT_PROTECTION_ENABLED=False)")
|
||
else:
|
||
logger.debug(f"[账号{self.account_id}] {symbol} [实时监控-移动止损] 已禁用(USE_TRAILING_STOP=False),跳过移动止损检查")
|
||
if use_trailing:
|
||
trailing_activation = config.TRADING_CONFIG.get('TRAILING_STOP_ACTIVATION', 0.10) # 相对于保证金,默认 10%
|
||
trailing_protect = config.TRADING_CONFIG.get('TRAILING_STOP_PROTECT', 0.02) # 相对于保证金,默认 2%
|
||
lock_pct = config.TRADING_CONFIG.get('LOCK_PROFIT_AT_BREAKEVEN_AFTER_PCT')
|
||
if lock_pct is None:
|
||
lock_pct = 0.03 # 未配置时默认 3% 移至保本
|
||
if lock_pct and lock_pct > 1:
|
||
lock_pct = lock_pct / 100.0
|
||
|
||
if not position_info.get('trailingStopActivated', False):
|
||
# 盈利达一定比例时尽早将止损移至含手续费保本,避免先盈后亏
|
||
if lock_pct > 0 and not position_info.get('breakevenStopSet', False) and pnl_percent_margin >= lock_pct * 100:
|
||
breakeven = self._breakeven_stop_price(entry_price, position_info['side'])
|
||
current_sl = position_info.get('stopLoss')
|
||
side_here = position_info['side']
|
||
set_breakeven = False
|
||
if side_here == 'BUY' and (current_sl is None or current_sl < breakeven):
|
||
set_breakeven = True
|
||
elif side_here == 'SELL' and (current_sl is None or current_sl > breakeven):
|
||
set_breakeven = True
|
||
if set_breakeven:
|
||
position_info['stopLoss'] = breakeven
|
||
position_info['breakevenStopSet'] = True
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 盈利{pnl_percent_margin:.2f}%≥{lock_pct*100:.0f}%,止损已移至含手续费保本价 {breakeven:.4f}(留住盈利)"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "breakeven_set", breakeven=breakeven, pnl_pct=pnl_percent_margin, source="实时监控")
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 尝试将保本止损同步至交易所")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float)
|
||
_log_trailing_stop_event(self.account_id, symbol, "breakeven_sync_ok", breakeven=breakeven, source="实时监控")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "breakeven_sync_fail", breakeven=breakeven, error=str(sync_e), source="实时监控")
|
||
logger.warning(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 同步保本止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
# 盈利超过阈值后(相对于保证金),激活移动止损
|
||
if pnl_percent_margin > trailing_activation * 100:
|
||
position_info['trailingStopActivated'] = True
|
||
# 保护利润金额至少覆盖双向手续费,避免“保护”后仍为负
|
||
protect_amount = max(margin * trailing_protect, self._min_protect_amount_for_fees(margin, leverage))
|
||
if position_info['side'] == 'BUY':
|
||
new_stop_loss = entry_price + (pnl_amount - protect_amount) / quantity
|
||
breakeven = self._breakeven_stop_price(entry_price, 'BUY')
|
||
new_stop_loss = max(new_stop_loss, breakeven)
|
||
else: # SELL 做空锁利:止损下移,锁住 protect_amount。(entry - stop)*q = protect → stop = entry - protect/q
|
||
new_stop_loss = entry_price - protect_amount / quantity
|
||
breakeven = self._breakeven_stop_price(entry_price, 'SELL')
|
||
new_stop_loss = max(new_stop_loss, breakeven)
|
||
|
||
position_info['stopLoss'] = new_stop_loss
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 移动止损激活: 止损移至保护利润位 {new_stop_loss:.4f} "
|
||
f"(盈利: {pnl_percent_margin:.2f}% of margin, 保护: {trailing_protect*100:.1f}% of margin)"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_activated", new_stop_loss=new_stop_loss, pnl_pct=pnl_percent_margin, source="实时监控")
|
||
# 同步至交易所:取消原止损单并按新止损价重挂,使移动止损也有交易所保护
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 尝试将移动止损同步至交易所")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="实时监控")
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 已同步移动止损至交易所")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="实时监控")
|
||
logger.warning(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
else:
|
||
# ⚠️ 优化:如果分步止盈第一目标已触发,移动止损不再更新剩余仓位的止损价
|
||
# 原因:分步止盈第一目标触发后,剩余50%仓位止损已移至成本价(保本),等待第二目标
|
||
# 移动止损不应该覆盖分步止盈设置的止损价
|
||
if position_info.get('partialProfitTaken', False):
|
||
# 分步止盈第一目标已触发,移动止损不再更新
|
||
logger.debug(f"[账号{self.account_id}] {symbol} [实时监控-移动止损] 分步止盈第一目标已触发,移动止损不再更新剩余仓位止损价")
|
||
else:
|
||
# 盈利超过阈值后,止损移至保护利润位(基于保证金);保护金额至少覆盖手续费
|
||
protect_amount = max(margin * trailing_protect, self._min_protect_amount_for_fees(margin, leverage))
|
||
if position_info['side'] == 'BUY':
|
||
new_stop_loss = entry_price + (pnl_amount - protect_amount) / quantity
|
||
new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'BUY'))
|
||
current_sl_buy = position_info.get('stopLoss')
|
||
if current_sl_buy is None or new_stop_loss > current_sl_buy:
|
||
position_info['stopLoss'] = new_stop_loss
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 移动止损更新: {new_stop_loss:.4f} "
|
||
f"(保护{trailing_protect*100:.1f}% of margin = {protect_amount:.4f} USDT)"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="实时监控")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="实时监控")
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 已同步移动止损至交易所")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="实时监控")
|
||
logger.warning(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
else: # SELL 做空锁利:止损下移,锁住 protect_amount。(entry - stop)*q = protect → stop = entry - protect/q
|
||
new_stop_loss = entry_price - protect_amount / quantity
|
||
new_stop_loss = max(new_stop_loss, self._breakeven_stop_price(entry_price, 'SELL'))
|
||
current_sl = position_info.get('stopLoss')
|
||
# 仅当新止损低于当前止损(下移锁利)且高于市价时更新
|
||
if (current_sl is None or new_stop_loss < current_sl) and new_stop_loss > current_price_float and pnl_amount > 0:
|
||
position_info['stopLoss'] = new_stop_loss
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 移动止损更新(做空锁利): {new_stop_loss:.4f} "
|
||
f"(保护{trailing_protect*100:.1f}% of margin = {protect_amount:.4f} USDT)"
|
||
)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_update", new_stop_loss=new_stop_loss, source="实时监控")
|
||
try:
|
||
await self._ensure_exchange_sltp_orders(symbol, position_info, current_price=current_price_float)
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_ok", new_stop_loss=new_stop_loss, source="实时监控")
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 已同步移动止损至交易所")
|
||
except Exception as sync_e:
|
||
_log_trailing_stop_event(self.account_id, symbol, "trailing_sync_fail", new_stop_loss=new_stop_loss, error=str(sync_e), source="实时监控")
|
||
logger.warning(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 同步移动止损至交易所失败: {type(sync_e).__name__}: {sync_e}",
|
||
exc_info=False,
|
||
)
|
||
|
||
# 检查止损(基于保证金收益比)
|
||
# ⚠️ 重要:止损检查应该在时间锁之前,止损必须立即执行
|
||
stop_loss = position_info.get('stopLoss')
|
||
should_close_due_to_sl = False
|
||
exit_reason_sl = None
|
||
|
||
if stop_loss is not None:
|
||
# 计算止损对应的保证金百分比目标
|
||
# 止损金额 = (开仓价 - 止损价) × 数量 或 (止损价 - 开仓价) × 数量
|
||
if position_info['side'] == 'BUY':
|
||
stop_loss_amount = (entry_price - stop_loss) * quantity
|
||
else: # SELL
|
||
stop_loss_amount = (stop_loss - entry_price) * quantity
|
||
|
||
stop_loss_pct_margin = (stop_loss_amount / margin * 100) if margin > 0 else 0
|
||
|
||
# 每5%亏损记录一次诊断日志(帮助排查问题)
|
||
if pnl_percent_margin <= -5.0:
|
||
should_log = (int(abs(pnl_percent_margin)) % 5 == 0) or (pnl_percent_margin <= -10.0 and pnl_percent_margin > -10.5)
|
||
if should_log:
|
||
trigger_condition = pnl_percent_margin <= -stop_loss_pct_margin
|
||
# 计算当前价格相对于入场价的变动百分比(不带杠杆)
|
||
price_change_pct = 0.0
|
||
if entry_price > 0:
|
||
if position_info['side'] == 'BUY':
|
||
price_change_pct = (current_price_float - entry_price) / entry_price * 100
|
||
else:
|
||
price_change_pct = (entry_price - current_price_float) / entry_price * 100
|
||
|
||
logger.warning(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 诊断: \n"
|
||
f" • ROE(保证金盈亏): {pnl_percent_margin:.2f}% (用户关注)\n"
|
||
f" • 价格变动: {price_change_pct:.2f}% (实际币价涨跌)\n"
|
||
f" • 杠杆倍数: {leverage}x (放大倍数)\n"
|
||
f" • 当前价: {current_price_float:.4f} | 入场价: {entry_price:.4f}\n"
|
||
f" • 止损价: {stop_loss:.4f} (目标ROE: -{stop_loss_pct_margin:.2f}%)\n"
|
||
f" • 触发止损: {'YES' if trigger_condition else 'NO'}"
|
||
)
|
||
|
||
# ⚠️ 2026-01-27关键修复:止损检查前,先检查是否盈利
|
||
# 如果盈利,不应该触发止损(除非是移动止损或分步止盈后的剩余仓位)
|
||
# 直接比较当前盈亏百分比与止损目标(基于保证金)
|
||
if pnl_percent_margin <= -stop_loss_pct_margin:
|
||
# ⚠️ 额外检查:如果盈利,可能是移动止损触发,应该标记为trailing_stop
|
||
if pnl_percent_margin > 0:
|
||
# 盈利单触发止损,应该是移动止损
|
||
if position_info.get('trailingStopActivated'):
|
||
exit_reason_sl = 'trailing_stop'
|
||
logger.warning(f"[账号{self.account_id}] {symbol} [实时监控] ⚠️ 盈利单触发止损,标记为移动止损(盈利: {pnl_percent_margin:.2f}% of margin)")
|
||
else:
|
||
# 盈利单但未激活移动止损,可能是分步止盈后的剩余仓位止损
|
||
if partial_profit_taken:
|
||
exit_reason_sl = 'take_profit_partial_then_stop'
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 第一目标止盈后,剩余仓位触发止损(保本)")
|
||
else:
|
||
# 异常情况:盈利单触发止损但未激活移动止损
|
||
exit_reason_sl = 'trailing_stop' # 默认标记为移动止损
|
||
logger.warning(f"[账号{self.account_id}] {symbol} [实时监控] ⚠️ 异常:盈利单触发止损但未激活移动止损,标记为移动止损")
|
||
else:
|
||
# 正常止损逻辑
|
||
should_close_due_to_sl = True
|
||
# ⚠️ 2026-01-27优化:如果已部分止盈,细分状态
|
||
if partial_profit_taken:
|
||
if position_info.get('trailingStopActivated'):
|
||
exit_reason_sl = 'take_profit_partial_then_trailing_stop'
|
||
else:
|
||
exit_reason_sl = 'take_profit_partial_then_stop'
|
||
else:
|
||
exit_reason_sl = 'trailing_stop' if position_info.get('trailingStopActivated') else 'stop_loss'
|
||
|
||
# 计算持仓时间
|
||
entry_time = position_info.get('entryTime')
|
||
hold_time_minutes = 0
|
||
if entry_time:
|
||
try:
|
||
if isinstance(entry_time, datetime):
|
||
hold_time_sec = int((get_beijing_time() - entry_time).total_seconds())
|
||
else:
|
||
hold_time_sec = int(time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0))
|
||
hold_time_minutes = hold_time_sec / 60.0
|
||
except Exception:
|
||
hold_time_minutes = 0
|
||
|
||
# 详细诊断日志:记录平仓时的所有关键信息
|
||
logger.warning("=" * 80)
|
||
logger.warning(f"[账号{self.account_id}] {symbol} [实时监控-平仓诊断日志] ===== 触发止损平仓 =====")
|
||
logger.warning(f" 平仓原因: {exit_reason_sl}")
|
||
logger.warning(f" 入场价格: {entry_price:.6f} USDT")
|
||
logger.warning(f" 当前价格: {current_price_float:.6f} USDT")
|
||
logger.warning(f" 止损价格: {stop_loss:.4f} USDT")
|
||
logger.warning(f" 持仓数量: {quantity:.4f}")
|
||
logger.warning(f" 持仓时间: {hold_time_minutes:.1f} 分钟")
|
||
logger.warning(f" 入场时间: {entry_time}")
|
||
logger.warning(f" 当前盈亏: {pnl_percent_margin:.2f}% of margin")
|
||
logger.warning(f" 止损目标: -{stop_loss_pct_margin:.2f}% of margin")
|
||
logger.warning(f" 亏损金额: {abs(pnl_amount):.4f} USDT")
|
||
if position_info.get('trailingStopActivated'):
|
||
logger.warning(f" 移动止损: 已激活(从初始止损 {position_info.get('initialStopLoss', 'N/A')} 调整)")
|
||
logger.warning("=" * 80)
|
||
|
||
# ⚠️ 2026-01-27优化:如果已部分止盈,细分状态为"第一目标止盈后剩余仓位止损"
|
||
if partial_profit_taken:
|
||
exit_reason_sl = 'take_profit_partial_then_stop'
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 第一目标止盈后,剩余仓位触发止损(保本)")
|
||
|
||
# ⚠️ 关键修复:止损必须立即执行,不受时间锁限制
|
||
if await self.close_position(symbol, reason=exit_reason_sl):
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控] 止损平仓成功(不受时间锁限制)")
|
||
return # 止损已执行,跳过后续止盈检查
|
||
|
||
# 检查分步止盈(实时监控)
|
||
# ⚠️ 优化:已移除止盈时间锁,止盈可以立即执行(与止损一致)
|
||
# 理由:1) 止损已不受时间锁限制,止盈也应该一致
|
||
# 2) 分步止盈策略本身已提供利润保护(50%在1:1止盈,剩余保本)
|
||
# 3) 交易所级别止盈单已提供保护
|
||
# 4) 及时止盈可以保护利润,避免价格回落
|
||
should_close = False
|
||
take_profit_1 = position_info.get('takeProfit1') # 第一目标(盈亏比1:1)
|
||
take_profit_2 = position_info.get('takeProfit2', position_info.get('takeProfit')) # 第二目标(1.5:1)
|
||
# ⚠️ 注意:partial_profit_taken和remaining_quantity已在方法开头初始化,这里不需要重复定义
|
||
|
||
# 第一目标:TAKE_PROFIT_1_PERCENT 止盈(默认15%保证金),了结50%仓位
|
||
if not partial_profit_taken and take_profit_1 is not None:
|
||
# 直接使用配置的 TAKE_PROFIT_1_PERCENT,与开仓时计算的第一目标一致
|
||
take_profit_1_pct_margin_config = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20)
|
||
# 兼容百分比形式和比例形式
|
||
if take_profit_1_pct_margin_config > 1:
|
||
take_profit_1_pct_margin_config = take_profit_1_pct_margin_config / 100.0
|
||
take_profit_1_pct_margin = take_profit_1_pct_margin_config * 100 # 转换为百分比
|
||
|
||
# 直接比较当前盈亏百分比与第一目标(基于保证金,使用配置值)
|
||
# ⚠️ 2026-02-04 修复:增加最小盈利检查,防止因配置过低或滑点导致负盈利
|
||
# 手续费估算:0.05% * 2 = 0.1% 价格变动。10x杠杆下约1%保证金。保留2%作为安全边际。
|
||
min_profit_margin = 2.0
|
||
|
||
if pnl_percent_margin >= take_profit_1_pct_margin and pnl_percent_margin > min_profit_margin:
|
||
take_profit_pct_config = config.TRADING_CONFIG.get('TAKE_PROFIT_1_PERCENT', 0.20)
|
||
if take_profit_pct_config > 1:
|
||
take_profit_pct_config = take_profit_pct_config / 100.0
|
||
take_profit_pct_display = take_profit_pct_config * 100
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 触发第一目标止盈({take_profit_pct_display:.1f}%固定止盈,基于保证金): "
|
||
f"当前盈亏={pnl_percent_margin:.2f}% of margin >= 目标={take_profit_1_pct_margin:.2f}% of margin | "
|
||
f"当前价={current_price_float:.4f}, 目标价={take_profit_1:.4f} | "
|
||
f"将平掉50%仓位,锁定{take_profit_pct_display:.1f}%盈利,剩余50%追求更高收益"
|
||
)
|
||
# 部分平仓50%
|
||
partial_quantity = quantity * 0.5
|
||
try:
|
||
close_side = 'SELL' if position_info['side'] == 'BUY' else 'BUY'
|
||
close_position_side = 'LONG' if position_info['side'] == 'BUY' else 'SHORT'
|
||
live_amt = await self._get_live_position_amt(symbol, position_side=close_position_side)
|
||
if live_amt is None or abs(live_amt) <= 0:
|
||
logger.warning(f"[账号{self.account_id}] {symbol} [实时监控] 部分止盈:实时持仓已为0,跳过部分平仓")
|
||
else:
|
||
partial_quantity = min(partial_quantity, abs(live_amt))
|
||
partial_quantity = await self._adjust_close_quantity(symbol, partial_quantity)
|
||
if partial_quantity > 0:
|
||
partial_order = await self.client.place_order(
|
||
symbol=symbol,
|
||
side=close_side,
|
||
quantity=partial_quantity,
|
||
order_type='MARKET',
|
||
reduce_only=True,
|
||
position_side=close_position_side,
|
||
)
|
||
if partial_order:
|
||
position_info['partialProfitTaken'] = True
|
||
position_info['remainingQuantity'] = remaining_quantity - partial_quantity
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 部分止盈成功: 平仓{partial_quantity:.4f},剩余{position_info['remainingQuantity']:.4f}"
|
||
)
|
||
# 分步止盈后的"保本"处理:仅在盈利保护总开关开启时移至含手续费保本价
|
||
if profit_protection_enabled:
|
||
breakeven = self._breakeven_stop_price(entry_price, position_info['side'])
|
||
position_info['stopLoss'] = breakeven
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 部分止盈后:剩余仓位止损移至含手续费保本价 {breakeven:.4f}(入场: {entry_price:.4f}),"
|
||
f"剩余50%仓位追求更高收益(第二目标:4.0:1盈亏比或更高)"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"[账号{self.account_id}] {symbol} [实时监控] 部分止盈失败: {e}")
|
||
|
||
# 第二目标:4.0:1止盈,平掉剩余仓位(山寨币策略)
|
||
if partial_profit_taken and take_profit_2 is not None and not should_close:
|
||
# 计算第二目标对应的保证金百分比(基于剩余仓位)
|
||
if position_info['side'] == 'BUY':
|
||
take_profit_2_amount = (take_profit_2 - entry_price) * remaining_quantity
|
||
else: # SELL
|
||
take_profit_2_amount = (entry_price - take_profit_2) * remaining_quantity
|
||
remaining_margin = (entry_price * remaining_quantity) / leverage if leverage > 0 else (entry_price * remaining_quantity)
|
||
take_profit_2_pct_margin = (take_profit_2_amount / remaining_margin * 100) if remaining_margin > 0 else 0
|
||
# 计算剩余仓位的当前盈亏
|
||
if position_info['side'] == 'BUY':
|
||
remaining_pnl_amount = (current_price_float - entry_price) * remaining_quantity
|
||
else:
|
||
remaining_pnl_amount = (entry_price - current_price_float) * remaining_quantity
|
||
remaining_pnl_pct_margin = (remaining_pnl_amount / remaining_margin * 100) if remaining_margin > 0 else 0
|
||
|
||
# 直接比较剩余仓位盈亏百分比与第二目标(基于保证金)
|
||
# ⚠️ 2026-02-04 修复:增加最小盈利检查
|
||
if remaining_pnl_pct_margin >= take_profit_2_pct_margin and remaining_pnl_pct_margin > 2.0:
|
||
should_close = True
|
||
# ⚠️ 2026-01-27优化:细分状态,区分"第一目标止盈后第二目标止盈"
|
||
exit_reason = 'take_profit_partial_then_take_profit'
|
||
logger.info(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 触发第二目标止盈(4.0:1,山寨币策略): "
|
||
f"剩余仓位盈亏={remaining_pnl_pct_margin:.2f}% of margin >= 目标={take_profit_2_pct_margin:.2f}% of margin | "
|
||
f"当前价={current_price_float:.4f}, 目标价={take_profit_2:.4f}, "
|
||
f"剩余数量={remaining_quantity:.4f}"
|
||
)
|
||
|
||
# 检查止盈(基于保证金收益比)- 用于未启用分步止盈的情况
|
||
if not should_close:
|
||
take_profit = position_info.get('takeProfit')
|
||
if take_profit is not None:
|
||
# 计算止盈对应的保证金百分比目标
|
||
# ⚠️ 关键修复:直接使用配置的 TAKE_PROFIT_PERCENT,而不是从止盈价格反推
|
||
# 因为止盈价格可能使用了ATR(更远),反推会导致阈值过大,难以触发
|
||
take_profit_pct_margin_config = config.TRADING_CONFIG.get('TAKE_PROFIT_PERCENT', 0.10)
|
||
# 兼容百分比形式和比例形式
|
||
if take_profit_pct_margin_config > 1:
|
||
take_profit_pct_margin_config = take_profit_pct_margin_config / 100.0
|
||
take_profit_pct_margin = take_profit_pct_margin_config * 100 # 转换为百分比
|
||
|
||
# 计算止盈金额(用于日志显示,但不用于触发判断)
|
||
if position_info['side'] == 'BUY':
|
||
take_profit_amount = (take_profit - entry_price) * quantity
|
||
else: # SELL
|
||
take_profit_amount = (entry_price - take_profit) * quantity
|
||
take_profit_pct_margin_from_price = (take_profit_amount / margin * 100) if margin > 0 else 0
|
||
|
||
# 每5%盈利记录一次诊断日志(帮助排查问题)
|
||
if pnl_percent_margin >= 5.0:
|
||
should_log = (int(pnl_percent_margin) % 5 == 0) or (pnl_percent_margin >= 10.0 and pnl_percent_margin < 10.5)
|
||
if should_log:
|
||
trigger_condition = pnl_percent_margin >= take_profit_pct_margin
|
||
logger.warning(
|
||
f"[账号{self.account_id}] {symbol} [实时监控] 诊断: 盈利{pnl_percent_margin:.2f}% of margin | "
|
||
f"当前价: {current_price_float:.4f} | "
|
||
f"入场价: {entry_price:.4f} | "
|
||
f"止盈价: {take_profit:.4f} (配置目标: {take_profit_pct_margin:.2f}% of margin, 价格对应: {take_profit_pct_margin_from_price:.2f}%) | "
|
||
f"方向: {position_info['side']} | "
|
||
f"是否触发: {trigger_condition} | "
|
||
f"监控状态: {'运行中' if symbol in self._monitor_tasks else '未启动'}"
|
||
)
|
||
|
||
# 直接比较当前盈亏百分比与止盈目标(基于保证金,使用配置值)
|
||
# ⚠️ 2026-02-04 修复:增加最小盈利检查
|
||
if pnl_percent_margin >= take_profit_pct_margin and pnl_percent_margin > 2.0:
|
||
should_close = True
|
||
exit_reason = 'take_profit'
|
||
|
||
# 计算持仓时间(用于日志)
|
||
entry_time = position_info.get('entryTime')
|
||
hold_time_minutes = 0
|
||
if entry_time:
|
||
try:
|
||
if isinstance(entry_time, datetime):
|
||
hold_time_sec = int((get_beijing_time() - entry_time).total_seconds())
|
||
else:
|
||
hold_time_sec = int(time.time() - (float(entry_time) if isinstance(entry_time, (int, float)) else 0))
|
||
hold_time_minutes = hold_time_sec / 60.0
|
||
except Exception:
|
||
hold_time_minutes = 0
|
||
|
||
# 详细诊断日志:记录平仓时的所有关键信息
|
||
logger.info("=" * 80)
|
||
logger.info(f"[账号{self.account_id}] {symbol} [实时监控-平仓诊断日志] ===== 触发止盈平仓 =====")
|
||
logger.info(f" 平仓原因: {exit_reason}")
|
||
logger.info(f" 入场价格: {entry_price:.6f} USDT")
|
||
logger.info(f" 当前价格: {current_price_float:.6f} USDT")
|
||
logger.info(f" 止盈价格: {take_profit:.4f} USDT")
|
||
logger.info(f" 持仓数量: {quantity:.4f}")
|
||
logger.info(f" 持仓时间: {hold_time_minutes:.1f} 分钟")
|
||
logger.info(f" 入场时间: {entry_time}")
|
||
logger.info(f" 当前盈亏: {pnl_percent_margin:.2f}% of margin")
|
||
logger.info(f" 止盈目标: {take_profit_pct_margin:.2f}% of margin")
|
||
logger.info(f" 盈利金额: {pnl_amount:.4f} USDT")
|
||
logger.info("=" * 80)
|
||
|
||
# 如果触发止损止盈,执行平仓
|
||
if should_close:
|
||
# 自动平仓限流:避免同一 symbol 短时间内反复触发平仓请求(WebSocket 高频推送下很常见)
|
||
try:
|
||
now_ms = int(time.time() * 1000)
|
||
except Exception:
|
||
now_ms = None
|
||
if now_ms is not None:
|
||
cooldown_sec = int(config.TRADING_CONFIG.get("AUTO_CLOSE_COOLDOWN_SEC", 20) or 0)
|
||
last_ms = self._last_auto_close_attempt_ms.get(symbol)
|
||
if last_ms and cooldown_sec > 0 and now_ms - last_ms < cooldown_sec * 1000:
|
||
# 不重复刷屏:仅 debug
|
||
logger.debug(f"{symbol} [自动平仓] 冷却中({cooldown_sec}s),跳过重复平仓尝试")
|
||
return
|
||
self._last_auto_close_attempt_ms[symbol] = now_ms
|
||
|
||
logger.info(
|
||
f"{symbol} [自动平仓] 开始执行平仓操作 | "
|
||
f"原因: {exit_reason} | "
|
||
f"入场价: {entry_price:.4f} | "
|
||
f"当前价: {current_price_float:.4f} | "
|
||
f"盈亏: {pnl_percent_margin:.2f}% of margin ({pnl_amount:.4f} USDT) | "
|
||
f"数量: {quantity:.4f}"
|
||
)
|
||
|
||
# 执行平仓(让 close_position 统一处理数据库更新,避免重复更新和状态不一致)
|
||
logger.info(f"{symbol} [自动平仓] 正在执行平仓订单...")
|
||
success = await self.close_position(symbol, reason=exit_reason)
|
||
if success:
|
||
logger.info(f"{symbol} [自动平仓] ✓ 平仓成功完成")
|
||
# 平仓成功后,立即触发一次状态同步,确保数据库状态与币安一致
|
||
try:
|
||
await asyncio.sleep(2) # 等待2秒让币安订单完全成交
|
||
await self.sync_positions_with_binance()
|
||
logger.debug(f"{symbol} [自动平仓] 已触发状态同步")
|
||
except Exception as sync_error:
|
||
logger.warning(f"{symbol} [自动平仓] 状态同步失败: {sync_error}")
|
||
else:
|
||
# 平仓失败:先二次核对币安是否已无仓位(常见于竞态/网络抖动/幂等场景)
|
||
live_amt = None
|
||
try:
|
||
live_amt = await self._get_live_position_amt(symbol, position_side=None)
|
||
except Exception:
|
||
live_amt = None
|
||
|
||
if live_amt is not None and abs(live_amt) <= 0:
|
||
logger.warning(f"{symbol} [自动平仓] 平仓返回失败,但币安持仓已为0,按已平仓处理(避免误报)")
|
||
# 尝试同步一次,让DB与界面尽快一致(失败也不刷屏)
|
||
try:
|
||
await self.sync_positions_with_binance()
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
# 仍有仓位:减少刷屏(按时间窗口合并/限流)
|
||
should_log = True
|
||
if now_ms is not None:
|
||
log_cd = int(config.TRADING_CONFIG.get("AUTO_CLOSE_FAIL_LOG_COOLDOWN_SEC", 600) or 0)
|
||
last_log = self._last_auto_close_fail_log_ms.get(symbol)
|
||
if last_log and log_cd > 0 and now_ms - last_log < log_cd * 1000:
|
||
should_log = False
|
||
else:
|
||
self._last_auto_close_fail_log_ms[symbol] = now_ms
|
||
|
||
if should_log:
|
||
logger.error(f"{symbol} [自动平仓] ❌ 平仓失败(币安持仓仍存在: {live_amt})")
|
||
else:
|
||
logger.warning(f"{symbol} [自动平仓] 平仓仍失败(已在短时间内记录,暂不重复输出)")
|
||
|
||
# 即使平仓失败,也尝试同步状态(可能币安已经平仓了)
|
||
try:
|
||
await self.sync_positions_with_binance()
|
||
except Exception as sync_error:
|
||
logger.warning(f"{symbol} [自动平仓] 状态同步失败: {sync_error}")
|
||
|
||
async def diagnose_position(self, symbol: str):
|
||
"""
|
||
诊断持仓状态(用于排查为什么没有自动平仓)
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
"""
|
||
try:
|
||
logger.info(f"{symbol} [诊断] 开始诊断持仓状态...")
|
||
|
||
# 1. 检查是否在active_positions中
|
||
if symbol not in self.active_positions:
|
||
logger.warning(f"{symbol} [诊断] ❌ 不在本地持仓记录中 (active_positions)")
|
||
logger.warning(f" 可能原因: 手动开仓或系统重启后未同步")
|
||
logger.warning(f" 解决方案: 等待下次状态同步或手动触发同步")
|
||
else:
|
||
position_info = self.active_positions[symbol]
|
||
logger.info(f"{symbol} [诊断] ✓ 在本地持仓记录中")
|
||
logger.info(f" 入场价: {position_info['entryPrice']:.4f}")
|
||
logger.info(f" 方向: {position_info['side']}")
|
||
logger.info(f" 数量: {position_info['quantity']:.4f}")
|
||
logger.info(f" 止损价: {position_info['stopLoss']:.4f}")
|
||
logger.info(f" 止盈价: {position_info['takeProfit']:.4f}")
|
||
|
||
# 2. 检查WebSocket监控状态
|
||
if symbol in self._monitor_tasks:
|
||
task = self._monitor_tasks[symbol]
|
||
if task.done():
|
||
logger.warning(f"{symbol} [诊断] ⚠ WebSocket监控任务已结束")
|
||
try:
|
||
await task # 获取异常信息
|
||
except Exception as e:
|
||
logger.warning(f" 任务异常: {e}")
|
||
else:
|
||
logger.info(f"{symbol} [诊断] ✓ WebSocket监控任务运行中")
|
||
else:
|
||
logger.warning(f"{symbol} [诊断] ❌ 没有WebSocket监控任务")
|
||
logger.warning(f" 可能原因: 监控未启动或已停止")
|
||
|
||
# 3. 获取币安实际持仓
|
||
positions = await self._get_open_positions()
|
||
binance_position = next((p for p in positions if p['symbol'] == symbol), None)
|
||
|
||
if not binance_position:
|
||
logger.warning(f"{symbol} [诊断] ❌ 币安账户中没有持仓")
|
||
return
|
||
|
||
logger.info(f"{symbol} [诊断] ✓ 币安账户中有持仓")
|
||
entry_price_binance = binance_position.get('entryPrice', 0)
|
||
mark_price = binance_position.get('markPrice', 0)
|
||
unrealized_pnl = binance_position.get('unRealizedProfit', 0)
|
||
|
||
logger.info(f" 币安入场价: {entry_price_binance:.4f}")
|
||
logger.info(f" 标记价格: {mark_price:.4f}")
|
||
logger.info(f" 未实现盈亏: {unrealized_pnl:.2f} USDT")
|
||
|
||
# 4. 计算实际盈亏
|
||
if symbol in self.active_positions:
|
||
position_info = self.active_positions[symbol]
|
||
entry_price = float(position_info['entryPrice'])
|
||
take_profit = float(position_info['takeProfit'])
|
||
|
||
if position_info['side'] == 'BUY':
|
||
pnl_percent = ((mark_price - entry_price) / entry_price) * 100
|
||
take_profit_pct = ((take_profit - entry_price) / entry_price) * 100
|
||
should_trigger = mark_price >= take_profit
|
||
else: # SELL
|
||
pnl_percent = ((entry_price - mark_price) / entry_price) * 100
|
||
take_profit_pct = ((entry_price - take_profit) / entry_price) * 100
|
||
should_trigger = mark_price <= take_profit
|
||
|
||
logger.info(f"{symbol} [诊断] 盈亏分析:")
|
||
logger.info(f" 当前盈亏: {pnl_percent:.2f}%")
|
||
logger.info(f" 止盈目标: {take_profit_pct:.2f}%")
|
||
logger.info(f" 当前价: {mark_price:.4f}")
|
||
logger.info(f" 止盈价: {take_profit:.4f}")
|
||
logger.info(f" 价格差: {abs(mark_price - take_profit):.4f}")
|
||
logger.info(f" 应该触发: {should_trigger}")
|
||
|
||
if pnl_percent > take_profit_pct and not should_trigger:
|
||
logger.error(f"{symbol} [诊断] ❌ 异常: 盈亏{pnl_percent:.2f}% > 止盈目标{take_profit_pct:.2f}%,但未触发平仓!")
|
||
logger.error(f" 可能原因: 浮点数精度问题或止盈价格计算错误")
|
||
|
||
logger.info(f"{symbol} [诊断] 诊断完成")
|
||
|
||
except Exception as e:
|
||
logger.error(f"{symbol} [诊断] 诊断失败: {e}")
|
||
import traceback
|
||
logger.error(f" 错误详情:\n{traceback.format_exc()}")
|