5663 lines
261 KiB
Python
5663 lines
261 KiB
Python
"""
|
||
加密货币交易智能体 - 主控制器(LLM 驱动版)
|
||
"""
|
||
import asyncio
|
||
import math
|
||
from collections import deque
|
||
from typing import Dict, Any, List, Optional
|
||
from datetime import datetime, timedelta
|
||
import pandas as pd
|
||
|
||
from app.utils.logger import logger
|
||
from app.config import get_settings
|
||
from app.services.bitget_service import bitget_service
|
||
from app.services.feishu_service import (
|
||
get_feishu_service,
|
||
get_feishu_paper_trading_service,
|
||
get_feishu_error_service,
|
||
)
|
||
from app.services.telegram_service import get_telegram_service
|
||
from app.services.dingtalk_service import get_dingtalk_service
|
||
from app.services.paper_trading_service import get_paper_trading_service
|
||
from app.services.signal_database_service import get_signal_db_service
|
||
from app.services.position_sizing import (
|
||
DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME,
|
||
DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS,
|
||
calculate_margin_and_position_value,
|
||
resolve_target_margin_pct,
|
||
)
|
||
from app.crypto_agent.market_signal_analyzer import MarketSignalAnalyzer
|
||
from app.utils.system_status import get_system_monitor, AgentStatus
|
||
|
||
|
||
class CryptoAgent:
|
||
"""加密货币交易信号智能体(LLM 驱动版)"""
|
||
|
||
_instance = None
|
||
_initialized = False
|
||
|
||
# 平台交易规则配置
|
||
PLATFORM_RULES = {
|
||
'Bitget': {
|
||
'min_margin': {
|
||
'BTC': 85, # 0.01 BTC/张 ≈ $850, 10x 杠杆 → $85
|
||
'ETH': 35, # 0.1 ETH/张 ≈ $350, 10x 杠杆 → $35
|
||
'SOL': 14, # 1 SOL/张 ≈ $140, 10x 杠杆 → $14
|
||
'BNB': 7, # 0.1 BNB/张 ≈ $70, 10x 杠杆 → $7
|
||
'XRP': 10, # 10 XRP/张 ≈ $100, 10x 杠杆 → $10
|
||
'DOGE': 8, # 100 DOGE/张 ≈ $80, 10x 杠杆 → $8
|
||
'ADA': 8, # 10 ADA/张 ≈ $80 (估计)
|
||
'AVAX': 10, # 1 AVAX/张 ≈ $100
|
||
'LINK': 8, # 1 LINK/张 ≈ $80
|
||
'DOT': 5, # 1 DOT/张 ≈ $50
|
||
'MATIC': 8, # 10 MATIC/张 ≈ $80
|
||
'POL': 8, # 10 POL/张 ≈ $80
|
||
'LTC': 85, # 0.1 LTC/张 ≈ $85
|
||
'BCH': 35, # 0.1 BCH/张 ≈ $350
|
||
'FIL': 5, # 1 FIL/张 ≈ $50
|
||
'ATOM': 5, # 1 ATOM/张 ≈ $50
|
||
'UNI': 5, # 1 UNI/张 ≈ $50
|
||
},
|
||
'max_margin_pct': 0.25, # 单笔最大25%(支持超激进配置)
|
||
},
|
||
'PaperTrading': {
|
||
'min_margin': {}, # 无最小限制
|
||
'max_margin_pct': 0.25, # 单笔最大25%(与实盘一致)
|
||
},
|
||
'Hyperliquid': {
|
||
'min_margin': {
|
||
'BTC': 50, # Hyperliquid 最小约 $50
|
||
'ETH': 20,
|
||
'SOL': 10,
|
||
},
|
||
'max_margin_pct': 0.25, # 单笔最大25%
|
||
}
|
||
}
|
||
|
||
PLATFORM_SIGNAL_PRIORITY = {
|
||
'PaperTrading': ['short_term', 'medium_term'],
|
||
'Hyperliquid': ['short_term', 'medium_term'],
|
||
'Bitget': ['medium_term', 'short_term'],
|
||
}
|
||
|
||
SIGNAL_POSITION_SIZE_DEFAULTS = {
|
||
**DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME,
|
||
}
|
||
|
||
SIGNAL_MARGIN_MULTIPLIERS = {
|
||
**DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS,
|
||
}
|
||
|
||
SIGNAL_MIN_STOP_LOSS_PCT = {
|
||
'short_term': 0.7,
|
||
'medium_term': 1.5,
|
||
'long_term': 1.2,
|
||
}
|
||
|
||
SIGNAL_MIN_TAKE_PROFIT_PCT = {
|
||
'short_term': 1.2,
|
||
'medium_term': 3.0,
|
||
'long_term': 2.5,
|
||
}
|
||
|
||
SIGNAL_MIN_EFFECTIVE_LEVERAGE = {
|
||
'short_term': 4.0,
|
||
'medium_term': 2.0,
|
||
'long_term': 2.0,
|
||
}
|
||
|
||
SIGNAL_EXECUTION_RULES = {
|
||
'short_term': {
|
||
'min_add_price_gap_pct': 1.0,
|
||
'min_add_profit_pct': 1.0,
|
||
'roll_loss_threshold_pct': -1.0,
|
||
'flip_confidence': 92,
|
||
'protect_profit_pct': 1.2,
|
||
'min_remaining_tp_pct': 0.8,
|
||
},
|
||
'medium_term': {
|
||
'min_add_price_gap_pct': 2.0,
|
||
'min_add_profit_pct': 1.5,
|
||
'roll_loss_threshold_pct': -1.2,
|
||
'flip_confidence': 85,
|
||
'protect_profit_pct': 2.0,
|
||
'min_remaining_tp_pct': 1.2,
|
||
},
|
||
'long_term': {
|
||
'min_add_price_gap_pct': 2.5,
|
||
'min_add_profit_pct': 2.0,
|
||
'roll_loss_threshold_pct': -1.5,
|
||
'flip_confidence': 82,
|
||
'protect_profit_pct': 2.5,
|
||
'min_remaining_tp_pct': 1.5,
|
||
},
|
||
}
|
||
|
||
TP_SL_RETRY_ALERT_THRESHOLD = 3
|
||
TP_SL_MAX_RETRY_BEFORE_ERROR = 6
|
||
TP_SL_ALERT_COOLDOWN_MINUTES = 15
|
||
ANALYSIS_HEARTBEAT_INTERVAL_MINUTES = 60
|
||
|
||
def __new__(cls, *args, **kwargs):
|
||
"""单例模式 - 确保只有一个实例"""
|
||
if cls._instance is None:
|
||
cls._instance = super().__new__(cls)
|
||
return cls._instance
|
||
|
||
def __init__(self):
|
||
"""初始化智能体"""
|
||
# 防止重复初始化
|
||
if CryptoAgent._initialized:
|
||
return
|
||
|
||
CryptoAgent._initialized = True
|
||
self.settings = get_settings()
|
||
self.exchange = bitget_service # 交易所服务
|
||
self.feishu = get_feishu_service() # 通用飞书服务(crypto等)
|
||
self.feishu_paper = get_feishu_paper_trading_service() # 模拟交易专用飞书服务
|
||
self.feishu_error = get_feishu_error_service() # 异常/风控专用飞书服务
|
||
self.telegram = get_telegram_service()
|
||
self.dingtalk = get_dingtalk_service() # 添加钉钉服务
|
||
|
||
# 信号层:只负责市场分析
|
||
self.market_analyzer = MarketSignalAnalyzer()
|
||
|
||
self.signal_db = get_signal_db_service() # 信号数据库服务
|
||
|
||
# 模拟交易服务(始终启用)
|
||
self.paper_trading = get_paper_trading_service()
|
||
|
||
# Hyperliquid 实盘服务(可选)
|
||
from app.services.hyperliquid_trading_service import get_hyperliquid_service
|
||
self.hyperliquid = get_hyperliquid_service()
|
||
|
||
if self.hyperliquid:
|
||
logger.info(f"🔥 Hyperliquid 实盘交易: 已启用")
|
||
else:
|
||
logger.info(f"📊 Hyperliquid 实盘交易: 未启用(仅模拟盘)")
|
||
|
||
# Bitget 实盘服务(可选)
|
||
from app.services.bitget_live_trading_service import get_bitget_live_service
|
||
self.bitget = get_bitget_live_service()
|
||
|
||
if self.bitget:
|
||
logger.info(f"🔥 Bitget 实盘交易: 已启用")
|
||
else:
|
||
logger.info(f"📊 Bitget 实盘交易: 未启用(仅模拟盘)")
|
||
|
||
# 初始化平台执行器
|
||
from app.crypto_agent.executor import PaperTradingExecutor, BitgetExecutor, HyperliquidExecutor
|
||
|
||
self.executors = {}
|
||
|
||
# 模拟盘执行器
|
||
if self.settings.paper_trading_enabled:
|
||
self.executors['PaperTrading'] = PaperTradingExecutor()
|
||
logger.info(f" 📊 模拟盘执行器: 已初始化")
|
||
|
||
# Bitget 执行器
|
||
if self.bitget:
|
||
self.executors['Bitget'] = BitgetExecutor()
|
||
logger.info(f" 🔥 Bitget 执行器: 已初始化")
|
||
|
||
# Hyperliquid 执行器
|
||
if self.hyperliquid:
|
||
self.executors['Hyperliquid'] = HyperliquidExecutor()
|
||
logger.info(f" 🔥 Hyperliquid 执行器: 已初始化")
|
||
|
||
# 状态管理
|
||
self.last_signals: Dict[str, Dict[str, Any]] = {}
|
||
self.last_execution_preview: Dict[str, Dict[str, Any]] = {}
|
||
self.signal_cooldown: Dict[str, datetime] = {}
|
||
|
||
# 账户初始余额持久化(用于计算回撤)
|
||
self._initial_balances: Dict[str, float] = {}
|
||
self._load_initial_balances()
|
||
self._platform_halts: Dict[str, Dict[str, Any]] = {}
|
||
self._load_platform_halts()
|
||
self._execution_events: deque[Dict[str, Any]] = deque(maxlen=120)
|
||
self._analysis_events: deque[Dict[str, Any]] = deque(maxlen=240)
|
||
self._analysis_monitor: Dict[str, Any] = {
|
||
"last_heartbeat_at": None,
|
||
"last_cycle_started_at": None,
|
||
"last_cycle_completed_at": None,
|
||
"last_cycle_status": "idle",
|
||
"last_cycle_error": "",
|
||
"current_cycle_symbol": None,
|
||
"current_cycle_index": 0,
|
||
"current_cycle_total": 0,
|
||
"last_analysis_started_at": None,
|
||
"last_analysis_completed_at": None,
|
||
"last_analysis_symbol": None,
|
||
"last_analysis_status": "idle",
|
||
"last_analysis_detail": "",
|
||
"next_scheduled_run_at": None,
|
||
}
|
||
self._analysis_notification_state: Dict[str, Any] = {
|
||
"last_signal_at": None,
|
||
"last_signal_symbol": None,
|
||
"last_heartbeat_notified_at": None,
|
||
}
|
||
self._lane_analysis_state: Dict[str, Dict[str, Any]] = {}
|
||
self._event_analysis_state: Dict[str, Dict[str, Any]] = {}
|
||
self._event_analysis_tasks: Dict[str, asyncio.Task] = {}
|
||
self._price_monitor_registered = False
|
||
|
||
# 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损
|
||
# key=order_id, value={symbol, is_long, size/contracts, tp_price, sl_price}
|
||
self._hl_pending_tp_sl: Dict[str, Dict] = {}
|
||
self._bg_pending_tp_sl: Dict[str, Dict] = {}
|
||
|
||
# 配置
|
||
self.symbols = self.settings.crypto_symbols.split(',')
|
||
if self.bitget:
|
||
sync_result = self.bitget.sync_default_leverage(
|
||
self.symbols,
|
||
leverage=self.settings.bitget_default_leverage
|
||
)
|
||
logger.info(f"Bitget 默认杠杆同步结果: {sync_result}")
|
||
|
||
# 运行状态
|
||
self.running = False
|
||
self._event_loop = None
|
||
|
||
# 注册到系统监控
|
||
monitor = get_system_monitor()
|
||
self._monitor_info = monitor.register_agent(
|
||
agent_id="crypto_agent",
|
||
name="加密货币智能体",
|
||
agent_type="crypto"
|
||
)
|
||
monitor.update_config("crypto_agent", {
|
||
"symbols": self.symbols,
|
||
"auto_trading_enabled": True, # 模拟交易始终启用
|
||
"hyperliquid_enabled": self.hyperliquid is not None,
|
||
"bitget_enabled": self.bitget is not None,
|
||
"analysis_interval": "每5分钟轻扫描,LLM分层冷却"
|
||
})
|
||
|
||
logger.info(f"加密货币智能体初始化完成(LLM 驱动),监控交易对: {self.symbols}")
|
||
logger.info(f"📊 模拟交易: 始终启用")
|
||
|
||
def _record_execution_event(self,
|
||
platform: str,
|
||
event_type: str,
|
||
symbol: str = "",
|
||
decision: Optional[Dict[str, Any]] = None,
|
||
reason: str = "",
|
||
status: str = "info",
|
||
extra: Optional[Dict[str, Any]] = None):
|
||
event = {
|
||
"timestamp": datetime.now().isoformat(),
|
||
"platform": platform,
|
||
"event_type": event_type,
|
||
"status": status,
|
||
"symbol": symbol or (decision or {}).get("symbol", ""),
|
||
"decision": (decision or {}).get("decision"),
|
||
"action": (decision or {}).get("action"),
|
||
"reason": reason or (decision or {}).get("reason") or (decision or {}).get("reasoning", ""),
|
||
}
|
||
if extra:
|
||
event.update(extra)
|
||
self._execution_events.appendleft(event)
|
||
|
||
def get_recent_execution_events(self, limit: int = 30) -> List[Dict[str, Any]]:
|
||
return list(self._execution_events)[:limit]
|
||
|
||
def _touch_analysis_heartbeat(self):
|
||
self._analysis_monitor["last_heartbeat_at"] = datetime.now().isoformat()
|
||
|
||
def _get_lane_state(self, symbol: str) -> Dict[str, Any]:
|
||
return self._lane_analysis_state.setdefault(symbol, {
|
||
"last_intraday_at": None,
|
||
"last_trend_at": None,
|
||
"cached_intraday": None,
|
||
"cached_trend": None,
|
||
"last_force_reason": "",
|
||
})
|
||
|
||
def _get_event_analysis_state(self, symbol: str) -> Dict[str, Any]:
|
||
return self._event_analysis_state.setdefault(symbol, {
|
||
"window_start_at": None,
|
||
"window_start_price": None,
|
||
"last_triggered_at": None,
|
||
"last_trigger_reason": "",
|
||
"last_price": None,
|
||
})
|
||
|
||
def _parse_iso_datetime(self, value: Optional[str]) -> Optional[datetime]:
|
||
if not value:
|
||
return None
|
||
try:
|
||
return datetime.fromisoformat(str(value))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
def _classify_execution_block_reason(self,
|
||
platform_name: str,
|
||
decision: Optional[Dict[str, Any]]) -> tuple[str, str]:
|
||
if self._is_platform_halted(platform_name):
|
||
halt_info = self._platform_halts.get(platform_name, {})
|
||
halt_reason = halt_info.get("reason") or "平台已触发风控停机"
|
||
return "平台停机", halt_reason
|
||
|
||
decision = decision or {}
|
||
decision_type = str(decision.get("decision", "")).upper()
|
||
reason = str(decision.get("reason") or decision.get("reasoning") or "未返回具体原因").strip()
|
||
if any(keyword in reason for keyword in ["无适配信号", "未匹配到信号", "未选中信号"]):
|
||
return "无适配信号", "该平台本轮没有匹配到可执行信号"
|
||
|
||
if any(keyword in reason for keyword in ["账户余额无效", "余额不足", "可用余额不足", "保证金不足", "账户权益不足"]):
|
||
return "资金不足", reason
|
||
|
||
if any(keyword in reason for keyword in ["有效杠杆", "最小保证金", "最小下单", "合约张数", "名义仓位", "下单数量不足", "仓位价值过小"]):
|
||
return "仓位不达标", reason
|
||
|
||
if any(keyword in reason for keyword in ["同向持仓", "已有持仓", "无需重复开仓", "已有同向仓位"]):
|
||
return "已有同向仓位", reason
|
||
|
||
if any(keyword in reason for keyword in ["挂单等待", "挂单中", "待成交", "已有挂单", "等待成交"]):
|
||
return "等待挂单成交", reason
|
||
|
||
if any(keyword in reason for keyword in ["撤销反向挂单", "先撤销", "取消挂单", "撤单后再"]):
|
||
return "挂单切换中", reason
|
||
|
||
if any(keyword in reason for keyword in ["风控", "回撤", "熔断", "风险控制", "止损停机"]):
|
||
return "风控拦截", reason
|
||
|
||
if any(keyword in reason for keyword in ["未启用", "未初始化", "不可用"]):
|
||
return "平台不可用", reason
|
||
|
||
if decision_type in {"CLOSE", "REDUCE"}:
|
||
return "平仓未执行", reason
|
||
if decision_type == "CANCEL_PENDING":
|
||
return "撤单未执行", reason
|
||
if decision_type in {"OPEN", "ADD"}:
|
||
return "未满足执行条件", reason
|
||
|
||
return "未执行", reason
|
||
|
||
async def _maybe_send_analysis_heartbeat(self):
|
||
if not self.settings.feishu_enabled:
|
||
return
|
||
|
||
now = datetime.now()
|
||
interval = timedelta(minutes=self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES)
|
||
state = self._analysis_notification_state
|
||
last_heartbeat_notified_at = self._parse_iso_datetime(state.get("last_heartbeat_notified_at"))
|
||
if last_heartbeat_notified_at and now - last_heartbeat_notified_at < interval:
|
||
return
|
||
|
||
last_signal_at = self._parse_iso_datetime(state.get("last_signal_at"))
|
||
if last_signal_at and now - last_signal_at < interval:
|
||
return
|
||
|
||
window_start = now - interval
|
||
recent_events = [
|
||
event for event in self._analysis_events
|
||
if self._parse_iso_datetime(event.get("timestamp")) and self._parse_iso_datetime(event.get("timestamp")) >= window_start
|
||
]
|
||
if not recent_events:
|
||
return
|
||
|
||
cycle_completed = sum(1 for event in recent_events if event.get("event_type") == "cycle_completed")
|
||
symbol_completed = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_completed")
|
||
symbol_skipped = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_skipped")
|
||
symbol_errors = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_error")
|
||
valid_signal_total = sum(int(event.get("valid_signals", 0) or 0) for event in recent_events)
|
||
|
||
if cycle_completed <= 0 or valid_signal_total > 0:
|
||
return
|
||
|
||
last_cycle_completed_at = self._parse_iso_datetime(self._analysis_monitor.get("last_cycle_completed_at"))
|
||
if not last_cycle_completed_at or now - last_cycle_completed_at > interval:
|
||
return
|
||
|
||
last_symbol = self._analysis_monitor.get("last_analysis_symbol") or "-"
|
||
last_status = self._analysis_monitor.get("last_analysis_status") or "unknown"
|
||
last_detail = self._analysis_monitor.get("last_analysis_detail") or "最近一轮分析已完成"
|
||
threshold = self.settings.crypto_llm_threshold * 100
|
||
|
||
title = "💓 [分析心跳] 系统运行正常"
|
||
content = "\n".join([
|
||
f"最近 {self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES} 分钟持续完成市场分析,但暂无达到阈值的可执行信号。",
|
||
"",
|
||
f"**分析轮次**: {cycle_completed}",
|
||
f"**完成分析**: {symbol_completed} 个交易对",
|
||
f"**跳过分析**: {symbol_skipped} 次",
|
||
f"**分析异常**: {symbol_errors} 次",
|
||
f"**信号阈值**: {threshold:.0f}%",
|
||
f"**最近分析对象**: {last_symbol}",
|
||
f"**最近状态**: {last_status}",
|
||
f"**最近说明**: {last_detail}",
|
||
f"**最近完成时间**: {last_cycle_completed_at.strftime('%Y-%m-%d %H:%M:%S')}",
|
||
])
|
||
await self.feishu.send_card(title, content, "blue")
|
||
state["last_heartbeat_notified_at"] = now.isoformat()
|
||
self._record_analysis_event(
|
||
"heartbeat_notified",
|
||
status="info",
|
||
detail=f"已发送分析心跳通知,最近 {self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES} 分钟无有效信号",
|
||
extra={
|
||
"window_minutes": self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES,
|
||
"last_signal_at": state.get("last_signal_at"),
|
||
"last_signal_symbol": state.get("last_signal_symbol"),
|
||
},
|
||
)
|
||
|
||
def _build_pending_tp_sl_task(self,
|
||
symbol: str,
|
||
is_long: bool,
|
||
size: float,
|
||
tp_price: Optional[float],
|
||
sl_price: Optional[float],
|
||
order_status: Optional[str] = None,
|
||
has_real_order_id: bool = True,
|
||
retry_count: int = 0,
|
||
first_seen_at: Optional[str] = None,
|
||
last_alert_at: Optional[str] = None) -> Dict[str, Any]:
|
||
return {
|
||
"symbol": symbol,
|
||
"is_long": is_long,
|
||
"size": size,
|
||
"tp_price": tp_price,
|
||
"sl_price": sl_price,
|
||
"order_status": order_status,
|
||
"has_real_order_id": has_real_order_id,
|
||
"retry_count": retry_count,
|
||
"first_seen_at": first_seen_at or datetime.now().isoformat(),
|
||
"last_alert_at": last_alert_at,
|
||
}
|
||
|
||
def _detect_force_llm_trigger(self, symbol: str, data: Dict[str, pd.DataFrame]) -> tuple[bool, str]:
|
||
try:
|
||
df_5m = data.get('5m')
|
||
if df_5m is not None and len(df_5m) >= 3:
|
||
recent_5m = df_5m.iloc[-3:]
|
||
price_start = float(recent_5m.iloc[0]['close'])
|
||
price_end = float(recent_5m.iloc[-1]['close'])
|
||
if price_start > 0:
|
||
change_pct = abs(price_end - price_start) / price_start * 100
|
||
threshold = self.settings.crypto_force_llm_surge_threshold
|
||
if change_pct >= threshold:
|
||
direction = "上涨" if price_end > price_start else "下跌"
|
||
return True, f"15分钟突发{direction} {change_pct:.2f}% >= {threshold:.2f}%"
|
||
|
||
df_1h = data.get('1h')
|
||
if df_1h is not None and len(df_1h) >= 20 and df_5m is not None and not df_5m.empty:
|
||
current_price = float(df_5m.iloc[-1]['close'])
|
||
recent_1h = df_1h.iloc[-20:]
|
||
high = float(recent_1h['high'].max())
|
||
low = float(recent_1h['low'].min())
|
||
zone_threshold = self.settings.crypto_force_llm_trade_zone_pct
|
||
if current_price > 0:
|
||
high_distance = abs(high - current_price) / current_price * 100
|
||
low_distance = abs(current_price - low) / current_price * 100
|
||
if min(high_distance, low_distance) <= zone_threshold:
|
||
zone = "阻力" if high_distance <= low_distance else "支撑"
|
||
return True, f"价格接近20小时{zone}位,距离 {min(high_distance, low_distance):.2f}% <= {zone_threshold:.2f}%"
|
||
except Exception as e:
|
||
logger.debug(f"{symbol} 强制 LLM 触发检测失败: {e}")
|
||
|
||
return False, ""
|
||
|
||
def _resolve_llm_lanes_for_symbol(self, symbol: str, data: Dict[str, pd.DataFrame]) -> tuple[List[str], Dict[str, Any], str]:
|
||
now = datetime.now()
|
||
state = self._get_lane_state(symbol)
|
||
force, force_reason = self._detect_force_llm_trigger(symbol, data)
|
||
lanes: List[str] = []
|
||
|
||
intraday_last = self._parse_iso_datetime(state.get("last_intraday_at"))
|
||
trend_last = self._parse_iso_datetime(state.get("last_trend_at"))
|
||
intraday_cooldown = timedelta(minutes=self.settings.crypto_intraday_llm_cooldown_minutes)
|
||
trend_cooldown = timedelta(minutes=self.settings.crypto_trend_llm_cooldown_minutes)
|
||
|
||
if force or intraday_last is None or not state.get("cached_intraday") or now - intraday_last >= intraday_cooldown:
|
||
lanes.append("intraday")
|
||
if force or trend_last is None or not state.get("cached_trend") or now - trend_last >= trend_cooldown:
|
||
lanes.append("trend")
|
||
|
||
cached_results = {}
|
||
if state.get("cached_intraday"):
|
||
cached_results["intraday"] = state["cached_intraday"]
|
||
if state.get("cached_trend"):
|
||
cached_results["trend"] = state["cached_trend"]
|
||
|
||
if not lanes and not cached_results:
|
||
lanes = ["intraday", "trend"]
|
||
|
||
state["last_force_reason"] = force_reason if force else ""
|
||
return lanes, cached_results, force_reason
|
||
|
||
def _update_lane_analysis_state(self, symbol: str, market_signal: Dict[str, Any]):
|
||
state = self._get_lane_state(symbol)
|
||
now_iso = datetime.now().isoformat()
|
||
lane_results = market_signal.get("lane_results") or {}
|
||
fresh_lanes = set((market_signal.get("llm_lanes") or {}).get("fresh") or [])
|
||
|
||
if "intraday" in fresh_lanes and lane_results.get("intraday"):
|
||
state["last_intraday_at"] = now_iso
|
||
state["cached_intraday"] = lane_results["intraday"]
|
||
if "trend" in fresh_lanes and lane_results.get("trend"):
|
||
state["last_trend_at"] = now_iso
|
||
state["cached_trend"] = lane_results["trend"]
|
||
|
||
def _register_price_event_monitor(self):
|
||
if self._price_monitor_registered or not self.settings.crypto_event_analysis_enabled:
|
||
return
|
||
|
||
try:
|
||
from app.services.price_monitor_service import get_price_monitor_service
|
||
|
||
monitor = get_price_monitor_service()
|
||
for symbol in self.symbols:
|
||
monitor.subscribe_symbol(symbol)
|
||
monitor.add_price_callback(self._on_realtime_price_update)
|
||
self._price_monitor_registered = True
|
||
logger.info("✅ CryptoAgent 已接入实时行情事件触发分析")
|
||
except Exception as e:
|
||
logger.warning(f"实时行情事件触发分析接入失败: {e}")
|
||
|
||
def _on_realtime_price_update(self, symbol: str, price: float):
|
||
if not self.running or not self.settings.crypto_event_analysis_enabled:
|
||
return
|
||
if symbol not in self.symbols:
|
||
return
|
||
if not price or price <= 0:
|
||
return
|
||
|
||
now = datetime.now()
|
||
state = self._get_event_analysis_state(symbol)
|
||
state["last_price"] = price
|
||
|
||
window_minutes = self.settings.crypto_event_analysis_window_minutes
|
||
window_start_at = self._parse_iso_datetime(state.get("window_start_at"))
|
||
window_start_price = state.get("window_start_price")
|
||
|
||
if not window_start_at or not window_start_price or now - window_start_at >= timedelta(minutes=window_minutes):
|
||
state["window_start_at"] = now.isoformat()
|
||
state["window_start_price"] = price
|
||
return
|
||
|
||
change_pct = abs(price - float(window_start_price)) / float(window_start_price) * 100
|
||
threshold = self.settings.crypto_event_analysis_price_change_percent
|
||
if change_pct < threshold:
|
||
return
|
||
|
||
last_triggered_at = self._parse_iso_datetime(state.get("last_triggered_at"))
|
||
cooldown = timedelta(minutes=self.settings.crypto_event_analysis_cooldown_minutes)
|
||
if last_triggered_at and now - last_triggered_at < cooldown:
|
||
return
|
||
|
||
if symbol in self._event_analysis_tasks and not self._event_analysis_tasks[symbol].done():
|
||
return
|
||
|
||
direction = "上涨" if price > float(window_start_price) else "下跌"
|
||
reason = f"{window_minutes}分钟内{direction} {change_pct:.2f}% >= {threshold:.2f}%"
|
||
state["last_triggered_at"] = now.isoformat()
|
||
state["last_trigger_reason"] = reason
|
||
state["window_start_at"] = now.isoformat()
|
||
state["window_start_price"] = price
|
||
|
||
if self._event_loop and self._event_loop.is_running():
|
||
asyncio.run_coroutine_threadsafe(
|
||
self._run_event_triggered_analysis(symbol, reason),
|
||
self._event_loop,
|
||
)
|
||
logger.info(f"⚡ 已排队实时行情事件分析: {symbol} | {reason}")
|
||
else:
|
||
logger.warning(f"实时行情事件分析跳过: 事件循环不可用 ({symbol})")
|
||
|
||
async def _run_event_triggered_analysis(self, symbol: str, reason: str):
|
||
current_task = asyncio.current_task()
|
||
if current_task:
|
||
self._event_analysis_tasks[symbol] = current_task
|
||
|
||
self._record_analysis_event(
|
||
"event_analysis_triggered",
|
||
symbol=symbol,
|
||
status="info",
|
||
detail=reason,
|
||
extra={"trigger_reason": reason},
|
||
)
|
||
try:
|
||
await self.analyze_symbol(
|
||
symbol,
|
||
trigger_source="realtime_event",
|
||
force_lanes=["intraday"],
|
||
trigger_reason=reason,
|
||
)
|
||
finally:
|
||
task = self._event_analysis_tasks.get(symbol)
|
||
if task is current_task:
|
||
self._event_analysis_tasks.pop(symbol, None)
|
||
|
||
async def _maybe_alert_tp_sl_incomplete(self,
|
||
platform: str,
|
||
tracking_key: str,
|
||
task: Dict[str, Any],
|
||
reason: str,
|
||
force: bool = False):
|
||
now = datetime.now()
|
||
last_alert_at_raw = task.get("last_alert_at")
|
||
last_alert_at = None
|
||
if last_alert_at_raw:
|
||
try:
|
||
last_alert_at = datetime.fromisoformat(str(last_alert_at_raw))
|
||
except ValueError:
|
||
last_alert_at = None
|
||
|
||
should_alert = force
|
||
if not should_alert and task.get("retry_count", 0) >= self.TP_SL_RETRY_ALERT_THRESHOLD:
|
||
should_alert = (
|
||
last_alert_at is None or
|
||
now - last_alert_at >= timedelta(minutes=self.TP_SL_ALERT_COOLDOWN_MINUTES)
|
||
)
|
||
|
||
severity = "error" if task.get("retry_count", 0) >= self.TP_SL_MAX_RETRY_BEFORE_ERROR else "warning"
|
||
self._record_execution_event(
|
||
platform,
|
||
"tp_sl_incomplete",
|
||
symbol=f"{task.get('symbol', '')}USDT",
|
||
reason=reason,
|
||
status=severity,
|
||
extra={
|
||
"tracking_key": tracking_key,
|
||
"retry_count": task.get("retry_count", 0),
|
||
"missing_take_profit": task.get("tp_price") is not None,
|
||
"missing_stop_loss": task.get("sl_price") is not None,
|
||
},
|
||
)
|
||
|
||
if should_alert:
|
||
missing_parts = []
|
||
if task.get("tp_price") is not None:
|
||
missing_parts.append(f"TP={task.get('tp_price')}")
|
||
if task.get("sl_price") is not None:
|
||
missing_parts.append(f"SL={task.get('sl_price')}")
|
||
missing_desc = " / ".join(missing_parts) or "保护单缺失"
|
||
await self._send_alert_notification(
|
||
f"⚠️ [{platform}] 保护单不完整 - {task.get('symbol', '')}USDT",
|
||
"\n".join([
|
||
f"追踪ID: {tracking_key}",
|
||
f"缺失项目: {missing_desc}",
|
||
f"重试次数: {task.get('retry_count', 0)}",
|
||
f"原因: {reason}",
|
||
])
|
||
)
|
||
task["last_alert_at"] = now.isoformat()
|
||
|
||
def _record_analysis_event(self,
|
||
event_type: str,
|
||
symbol: str = "",
|
||
status: str = "info",
|
||
detail: str = "",
|
||
extra: Optional[Dict[str, Any]] = None):
|
||
event = {
|
||
"timestamp": datetime.now().isoformat(),
|
||
"event_type": event_type,
|
||
"status": status,
|
||
"symbol": symbol,
|
||
"detail": detail,
|
||
}
|
||
if extra:
|
||
event.update(extra)
|
||
self._analysis_events.appendleft(event)
|
||
self._touch_analysis_heartbeat()
|
||
|
||
def get_recent_analysis_events(self, limit: int = 40) -> List[Dict[str, Any]]:
|
||
return list(self._analysis_events)[:limit]
|
||
|
||
def _on_price_update(self, symbol: str, price: float):
|
||
"""处理实时价格更新(用于模拟交易)"""
|
||
if not self.paper_trading:
|
||
return
|
||
|
||
triggered = self.paper_trading.check_price_triggers(symbol, price)
|
||
|
||
for result in triggered:
|
||
if self._event_loop and self._event_loop.is_running():
|
||
# 根据事件类型选择不同的通知方法
|
||
event_type = result.get('event_type', 'order_closed')
|
||
if event_type == 'order_filled':
|
||
asyncio.run_coroutine_threadsafe(self._notify_order_filled(result), self._event_loop)
|
||
elif event_type == 'breakeven_triggered':
|
||
asyncio.run_coroutine_threadsafe(self._notify_breakeven_triggered(result), self._event_loop)
|
||
else:
|
||
asyncio.run_coroutine_threadsafe(self._notify_order_closed(result), self._event_loop)
|
||
else:
|
||
logger.warning(f"无法发送通知: 事件循环不可用")
|
||
|
||
async def _notify_order_filled(self, result: Dict[str, Any]):
|
||
"""发送挂单成交通知"""
|
||
side_text = "做多" if result.get('side') == 'long' else "做空"
|
||
side_icon = "🟢" if result.get('side') == 'long' else "🔴"
|
||
grade = result.get('signal_grade', 'N/A')
|
||
|
||
title = f"✅ 挂单成交 - {result.get('symbol')}"
|
||
|
||
content_parts = [
|
||
f"{side_icon} **方向**: {side_text}",
|
||
f"⭐ **信号等级**: {grade}",
|
||
f"💰 **挂单价**: ${result.get('entry_price', 0):,.2f}",
|
||
f"🎯 **成交价**: ${result.get('filled_price', 0):,.2f}",
|
||
f"💵 **仓位**: ${result.get('quantity', 0):,.0f}",
|
||
]
|
||
|
||
if result.get('stop_loss'):
|
||
content_parts.append(f"🛑 **止损**: ${result.get('stop_loss', 0):,.2f}")
|
||
if result.get('take_profit'):
|
||
content_parts.append(f"🎯 **止盈**: ${result.get('take_profit', 0):,.2f}")
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu_paper.send_card(title, content, "green")
|
||
if self.settings.telegram_enabled:
|
||
message = f"{title}\n\n{content}"
|
||
await self.telegram.send_message(message)
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
logger.info(f"已发送挂单成交通知: {result.get('order_id')}")
|
||
|
||
async def _notify_pending_cancelled(self, result: Dict[str, Any]):
|
||
"""发送挂单撤销通知"""
|
||
side_text = "做多" if result.get('side') == 'long' else "做空"
|
||
side_icon = "🟢" if result.get('side') == 'long' else "🔴"
|
||
new_side_text = "做多" if result.get('new_side') == 'long' else "做空"
|
||
|
||
title = f"⚠️ 挂单撤销 - {result.get('symbol')}"
|
||
|
||
content_parts = [
|
||
f"{side_icon} **原方向**: {side_text}",
|
||
f"💰 **挂单价**: ${result.get('entry_price', 0):,.2f}",
|
||
f"",
|
||
f"📝 **原因**: 收到反向{new_side_text}信号,自动撤销",
|
||
]
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu_paper.send_card(title, content, "orange")
|
||
if self.settings.telegram_enabled:
|
||
message = f"{title}\n\n{content}"
|
||
await self.telegram.send_message(message)
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
logger.info(f"已发送挂单撤销通知: {result.get('order_id')}")
|
||
|
||
async def _notify_breakeven_triggered(self, result: Dict[str, Any]):
|
||
"""发送移动止损触发通知"""
|
||
side_text = "做多" if result.get('side') == 'long' else "做空"
|
||
side_icon = '🟢' if result.get('side') == 'long' else '🔴'
|
||
pnl_percent = result.get('current_pnl_percent', 0)
|
||
|
||
title = f"📈 移动止损已启动 - {result.get('symbol')}"
|
||
|
||
content_parts = [
|
||
f"{side_icon} **方向**: {side_text}",
|
||
f"",
|
||
f"💰 **开仓价**: ${result.get('filled_price', 0):,.2f}",
|
||
f"📈 **当前盈利**: {pnl_percent:+.2f}%",
|
||
f"🛑 **新止损价**: ${result.get('new_stop_loss', 0):,.2f}",
|
||
f"",
|
||
f"💰 锁定利润,让利润奔跑"
|
||
]
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu_paper.send_card(title, content, "green")
|
||
if self.settings.telegram_enabled:
|
||
message = f"{title}\n\n{content}"
|
||
await self.telegram.send_message(message)
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
logger.info(f"已发送移动止损通知: {result.get('order_id')}")
|
||
|
||
async def _notify_order_closed(self, result: Dict[str, Any]):
|
||
"""发送订单平仓通知"""
|
||
status = result.get('status', '')
|
||
is_win = result.get('is_win', False)
|
||
|
||
if status == 'closed_tp':
|
||
emoji = "🎯"
|
||
status_text = "止盈平仓"
|
||
color = "green"
|
||
elif status == 'closed_sl':
|
||
emoji = "🛑"
|
||
status_text = "止损平仓"
|
||
color = "red"
|
||
elif status == 'closed_ts':
|
||
emoji = "📈"
|
||
status_text = "移动止盈"
|
||
color = "green"
|
||
elif status == 'closed_be':
|
||
emoji = "🔒"
|
||
status_text = "保本止损"
|
||
color = "orange"
|
||
else:
|
||
emoji = "📤"
|
||
status_text = "手动平仓"
|
||
color = "blue"
|
||
|
||
win_text = "盈利" if is_win else "亏损"
|
||
win_emoji = "✅" if is_win else "❌"
|
||
side_text = "做多" if result.get('side') == 'long' else "做空"
|
||
side_icon = "🟢" if result.get('side') == 'long' else "🔴"
|
||
|
||
title = f"{emoji} 订单{status_text}"
|
||
|
||
content_parts = [
|
||
f"{side_icon} **方向**: {side_text}",
|
||
f"💰 **交易对**: {result.get('symbol')}",
|
||
f"📊 **入场**: ${result.get('entry_price', 0):,.2f}",
|
||
f"🎯 **出场**: ${result.get('exit_price', 0):,.2f}",
|
||
f"{win_emoji} **{win_text}**: {result.get('pnl_percent', 0):+.2f}% (${result.get('pnl_amount', 0):+.2f})",
|
||
f"⏱️ **持仓时间**: {result.get('hold_duration', 'N/A')}",
|
||
]
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu_paper.send_card(title, content, color)
|
||
if self.settings.telegram_enabled:
|
||
message = f"{title}\n\n{content}"
|
||
await self.telegram.send_message(message)
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
logger.info(f"已发送订单平仓通知: {result.get('order_id')}")
|
||
|
||
async def _notify_expired_orders_cancelled(self, cancelled_orders: List[Dict[str, Any]]):
|
||
"""
|
||
发送超时订单取消通知
|
||
|
||
Args:
|
||
cancelled_orders: 被取消的订单列表
|
||
"""
|
||
if not cancelled_orders:
|
||
return
|
||
|
||
title = f"⏰ 已自动取消 {len(cancelled_orders)} 个超时挂单"
|
||
|
||
# 构建订单列表内容
|
||
order_lines = []
|
||
for order in cancelled_orders[:5]: # 最多显示5个
|
||
side_icon = "🟢" if order['side'] == 'long' else "🔴"
|
||
order_lines.append(
|
||
f"{side_icon} **{order['symbol']}** ({order['side']})\n"
|
||
f" 入场价: ${order['entry_price']:.2f} | "
|
||
f"已挂单: {order['age_hours']:.1f}小时"
|
||
)
|
||
|
||
if len(cancelled_orders) > 5:
|
||
order_lines.append(f"\n... 还有 {len(cancelled_orders) - 5} 个订单")
|
||
|
||
content_parts = [
|
||
f"⏰ **挂单超时自动取消**",
|
||
f"📊 **取消数量**: {len(cancelled_orders)} 个",
|
||
f"⚙️ **超时阈值**: {self.paper_trading.order_timeout_hours} 小时",
|
||
"",
|
||
"**取消的订单**:",
|
||
]
|
||
content_parts.extend(order_lines)
|
||
content_parts.append("\n💡 挂单超时自动取消,释放仓位供新信号使用")
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
# 发送通知
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu_paper.send_card(title, content, "orange")
|
||
if self.settings.telegram_enabled:
|
||
message = f"{title}\n\n{content}"
|
||
await self.telegram.send_message(message)
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
|
||
logger.info(f"已发送超时订单取消通知: {len(cancelled_orders)} 个订单")
|
||
|
||
def _get_seconds_until_next_5min(self) -> int:
|
||
"""计算距离下一个5分钟整点的秒数"""
|
||
now = datetime.now()
|
||
current_minute = now.minute
|
||
current_second = now.second
|
||
|
||
minutes_past = current_minute % 5
|
||
if minutes_past == 0 and current_second == 0:
|
||
return 0
|
||
minutes_to_wait = 5 - minutes_past if minutes_past > 0 else 5
|
||
seconds_to_wait = minutes_to_wait * 60 - current_second
|
||
|
||
return seconds_to_wait
|
||
|
||
async def run(self):
|
||
"""主运行循环"""
|
||
self.running = True
|
||
self._event_loop = asyncio.get_event_loop()
|
||
|
||
# 更新状态为启动中
|
||
monitor = get_system_monitor()
|
||
monitor.update_status("crypto_agent", AgentStatus.STARTING)
|
||
|
||
# 启动横幅
|
||
logger.info("\n" + "=" * 60)
|
||
logger.info("🚀 加密货币交易信号智能体(LLM 驱动)")
|
||
logger.info("=" * 60)
|
||
logger.info(f" 监控交易对: {', '.join(self.symbols)}")
|
||
logger.info(f" 运行模式: 每5分钟轻扫描,LLM分层冷却")
|
||
logger.info(f" 分析引擎: LLM 自主分析")
|
||
logger.info(f" 交易模式: 自动交易已启用")
|
||
logger.info("=" * 60 + "\n")
|
||
|
||
# 更新状态为运行中
|
||
monitor.update_status("crypto_agent", AgentStatus.RUNNING)
|
||
|
||
# 注意:不再启动独立的价格监控
|
||
# 价格监控由 main.py 中的 price_monitor_loop 统一处理,避免重复检查
|
||
logger.info(f"交易已启用(由后台统一监控)")
|
||
self._register_price_event_monitor()
|
||
|
||
# 发送启动通知(卡片格式)
|
||
title = "🚀 加密货币智能体已启动"
|
||
|
||
# 构建卡片内容
|
||
content_parts = [
|
||
f"🤖 **驱动引擎**: LLM 自主分析",
|
||
f"📊 **监控交易对**: {len(self.symbols)} 个",
|
||
f" {', '.join(self.symbols)}",
|
||
f"⏰ **运行频率**: 每5分钟轻扫描",
|
||
f"🧊 **LLM 冷却**: 日内 {self.settings.crypto_intraday_llm_cooldown_minutes} 分钟 / 趋势 {self.settings.crypto_trend_llm_cooldown_minutes} 分钟",
|
||
f"💰 **交易系统**: 已启用(后台统一监控)",
|
||
f"🎯 **分析维度**: 技术面 + 资金面 + 情绪面",
|
||
]
|
||
|
||
content = "\n".join(content_parts)
|
||
await self.feishu.send_card(title, content, "green")
|
||
await self.telegram.send_startup_notification(self.symbols)
|
||
|
||
while self.running:
|
||
try:
|
||
wait_seconds = self._get_seconds_until_next_5min()
|
||
if wait_seconds > 0:
|
||
next_run = datetime.now() + timedelta(seconds=wait_seconds)
|
||
self._analysis_monitor["next_scheduled_run_at"] = next_run.isoformat()
|
||
self._analysis_monitor["last_cycle_status"] = "waiting"
|
||
self._touch_analysis_heartbeat()
|
||
logger.info(f"⏳ 等待 {wait_seconds} 秒,下次运行: {next_run.strftime('%H:%M:%S')}")
|
||
await asyncio.sleep(wait_seconds)
|
||
|
||
run_time = datetime.now()
|
||
self._analysis_monitor["last_cycle_started_at"] = run_time.isoformat()
|
||
self._analysis_monitor["last_cycle_status"] = "running"
|
||
self._analysis_monitor["last_cycle_error"] = ""
|
||
self._analysis_monitor["current_cycle_symbol"] = None
|
||
self._analysis_monitor["current_cycle_index"] = 0
|
||
self._analysis_monitor["current_cycle_total"] = len(self.symbols)
|
||
self._analysis_monitor["next_scheduled_run_at"] = None
|
||
self._record_analysis_event(
|
||
"cycle_started",
|
||
status="info",
|
||
detail=f"新一轮分析开始,计划扫描 {len(self.symbols)} 个交易对",
|
||
extra={"symbols": list(self.symbols)},
|
||
)
|
||
logger.info("\n" + "=" * 60)
|
||
logger.info(f"⏰ 定时任务执行 [{run_time.strftime('%Y-%m-%d %H:%M:%S')}]")
|
||
logger.info("=" * 60)
|
||
|
||
# 1. 首先检查账户级止损(所有平台)
|
||
should_stop, stop_reason = await self._check_account_level_stop_loss()
|
||
if should_stop:
|
||
logger.error(f"🚨 {stop_reason}")
|
||
# 分平台熔断后继续保留信号层和其他平台运行
|
||
logger.warning("账户级止损已触发平台熔断,主循环继续运行")
|
||
|
||
# 检查并取消超时挂单(在分析开始前)
|
||
cancelled = self.paper_trading.check_and_cancel_expired_orders()
|
||
if cancelled:
|
||
logger.info(f"🔄 已自动取消 {len(cancelled)} 个超时挂单")
|
||
# 发送超时取消通知
|
||
await self._notify_expired_orders_cancelled(cancelled)
|
||
|
||
# 使用执行器检查挂单超时(各平台)
|
||
await self._check_pending_order_timeouts()
|
||
|
||
# 使用执行器检查持仓管理(止盈/超时退出/移动止损)
|
||
await self._check_position_management_all_platforms()
|
||
|
||
# 检查实盘挂单是否已成交,补设止盈止损
|
||
if self.hyperliquid:
|
||
await self._check_and_set_pending_tp_sl_hyperliquid()
|
||
await self._check_hyperliquid_missing_tp_sl()
|
||
if self.bitget:
|
||
await self._check_and_set_pending_tp_sl_bitget()
|
||
await self._check_bitget_missing_tp_sl() # 兜底:检查缺少的 TP/SL 并补救
|
||
|
||
for index, symbol in enumerate(self.symbols, start=1):
|
||
self._analysis_monitor["current_cycle_symbol"] = symbol
|
||
self._analysis_monitor["current_cycle_index"] = index
|
||
self._touch_analysis_heartbeat()
|
||
await self.analyze_symbol(symbol)
|
||
|
||
self._analysis_monitor["last_cycle_completed_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_cycle_status"] = "completed"
|
||
self._analysis_monitor["current_cycle_symbol"] = None
|
||
self._record_analysis_event(
|
||
"cycle_completed",
|
||
status="success",
|
||
detail=f"本轮分析完成,共扫描 {len(self.symbols)} 个交易对",
|
||
)
|
||
await self._maybe_send_analysis_heartbeat()
|
||
logger.info("\n" + "─" * 60)
|
||
logger.info(f"✅ 本轮分析完成,共分析 {len(self.symbols)} 个交易对")
|
||
logger.info("─" * 60 + "\n")
|
||
|
||
await asyncio.sleep(2)
|
||
|
||
except Exception as e:
|
||
self._analysis_monitor["last_cycle_completed_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_cycle_status"] = "error"
|
||
self._analysis_monitor["last_cycle_error"] = str(e)
|
||
self._record_analysis_event(
|
||
"cycle_error",
|
||
status="error",
|
||
detail=f"分析主循环异常: {str(e)}",
|
||
)
|
||
logger.error(f"❌ 分析循环出错: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
await asyncio.sleep(10)
|
||
|
||
def stop(self):
|
||
"""停止运行"""
|
||
self.running = False
|
||
|
||
# 更新状态
|
||
monitor = get_system_monitor()
|
||
monitor.update_status("crypto_agent", AgentStatus.STOPPED)
|
||
|
||
logger.info("加密货币智能体已停止")
|
||
|
||
def _check_volatility(self, symbol: str, data: Dict[str, pd.DataFrame]) -> tuple[bool, str, float]:
|
||
"""
|
||
检查波动率,判断是否值得进行 LLM 分析(组合方案)
|
||
|
||
使用 1 小时 K 线判断趋势波动,5 分钟 K 线检测突发波动
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
data: 多周期K线数据
|
||
|
||
Returns:
|
||
(should_analyze, reason, volatility_percent)
|
||
should_analyze: 是否应该进行分析
|
||
reason: 原因说明
|
||
volatility_percent: 1小时波动率百分比
|
||
"""
|
||
# 检查是否启用波动率过滤
|
||
if not self.settings.crypto_volatility_filter_enabled:
|
||
return True, "波动率过滤未启用", 0
|
||
|
||
try:
|
||
# 1. 首先检查 1 小时趋势波动率
|
||
df_1h = data.get('1h')
|
||
if df_1h is None or len(df_1h) < 20:
|
||
# 数据不足,保守起见允许分析
|
||
return True, "1小时数据不足,允许分析", 0
|
||
|
||
# 获取最近20根K线
|
||
recent_1h = df_1h.iloc[-20:]
|
||
|
||
# 计算最高价和最低价
|
||
high = recent_1h['high'].max()
|
||
low = recent_1h['low'].min()
|
||
current_price = float(recent_1h.iloc[-1]['close'])
|
||
|
||
# 计算1小时波动率
|
||
if low > 0:
|
||
volatility_1h_percent = ((high - low) / low) * 100
|
||
else:
|
||
volatility_1h_percent = 0
|
||
|
||
# 计算价格变化范围(相对于当前价格)
|
||
price_range_high_percent = ((high - current_price) / current_price) * 100 if current_price > 0 else 0
|
||
price_range_low_percent = ((current_price - low) / current_price) * 100 if current_price > 0 else 0
|
||
|
||
# 从配置读取阈值
|
||
min_volatility = self.settings.crypto_min_volatility_percent
|
||
min_price_range = self.settings.crypto_min_price_range_percent
|
||
|
||
# 如果1小时波动率足够大,直接允许分析
|
||
if volatility_1h_percent >= min_volatility or price_range_high_percent >= min_price_range or price_range_low_percent >= min_price_range:
|
||
return True, f"1小时趋势活跃 (波动率 {volatility_1h_percent:.2f}%),值得分析", volatility_1h_percent
|
||
|
||
# 2. 1小时波动率较低,检查5分钟突发波动
|
||
df_5m = data.get('5m')
|
||
if df_5m is not None and len(df_5m) >= 3:
|
||
# 获取最近3根5分钟K线(15分钟内的变化)
|
||
recent_5m = df_5m.iloc[-3:]
|
||
|
||
# 计算5分钟价格变化幅度
|
||
price_start = float(recent_5m.iloc[0]['close'])
|
||
price_end = float(recent_5m.iloc[-1]['close'])
|
||
|
||
if price_start > 0:
|
||
price_change_5m = abs(price_end - price_start) / price_start * 100
|
||
else:
|
||
price_change_5m = 0
|
||
|
||
# 从配置读取5分钟突发波动阈值
|
||
surge_threshold = self.settings.crypto_5m_surge_threshold
|
||
|
||
logger.debug(f"{symbol} 5分钟价格变化: {price_start:.2f} -> {price_end:.2f} = {price_change_5m:.2f}% (阈值: {surge_threshold}%)")
|
||
|
||
# 如果5分钟突发波动超过阈值,仍然允许分析
|
||
if price_change_5m >= surge_threshold:
|
||
direction = "上涨" if price_end > price_start else "下跌"
|
||
return True, f"5分钟突发{direction} ({price_change_5m:.2f}% > {surge_threshold}%),强制分析", volatility_1h_percent
|
||
|
||
# 3. 波动率过低,跳过分析
|
||
reason = f"波动率过低 (1小时: {volatility_1h_percent:.2f}% < {min_volatility}%, 5分钟无突发波动),跳过分析"
|
||
logger.info(f"⏸️ {symbol} {reason}")
|
||
return False, reason, volatility_1h_percent
|
||
|
||
except Exception as e:
|
||
logger.warning(f"{symbol} 波动率检查失败: {e},允许分析")
|
||
return True, "波动率检查失败,允许分析", 0
|
||
|
||
async def analyze_symbol(self,
|
||
symbol: str,
|
||
trigger_source: str = "schedule",
|
||
force_lanes: Optional[List[str]] = None,
|
||
trigger_reason: str = ""):
|
||
"""
|
||
分析单个交易对(信号分析 + 平台执行规则)
|
||
|
||
当前流程:
|
||
1. 市场信号分析器分析市场(不包含仓位信息)
|
||
2. 各平台按自身规则筛选并处理信号
|
||
3. 执行交易动作
|
||
|
||
Args:
|
||
symbol: 交易对,如 'BTCUSDT'
|
||
"""
|
||
try:
|
||
# 更新活动时间
|
||
monitor = get_system_monitor()
|
||
monitor.update_activity("crypto_agent")
|
||
self._analysis_monitor["last_analysis_started_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_analysis_symbol"] = symbol
|
||
self._analysis_monitor["last_analysis_status"] = "running"
|
||
self._analysis_monitor["last_analysis_detail"] = "开始获取数据"
|
||
self._record_analysis_event(
|
||
"symbol_analysis_started",
|
||
symbol=symbol,
|
||
status="info",
|
||
detail="开始分析",
|
||
)
|
||
|
||
logger.info(f"\n{'─' * 50}")
|
||
logger.info(f"📊 {symbol} 分析开始 ({trigger_source})")
|
||
logger.info(f"{'─' * 50}")
|
||
|
||
# 1. 获取多周期数据
|
||
data = self.exchange.get_multi_timeframe_data(symbol)
|
||
|
||
if not self._validate_data(data):
|
||
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_analysis_status"] = "warning"
|
||
self._analysis_monitor["last_analysis_detail"] = "数据不完整,跳过分析"
|
||
self._record_analysis_event(
|
||
"symbol_analysis_skipped",
|
||
symbol=symbol,
|
||
status="warning",
|
||
detail="数据不完整,跳过分析",
|
||
)
|
||
logger.warning(f"⚠️ {symbol} 数据不完整,跳过分析")
|
||
return
|
||
|
||
# 当前价格
|
||
current_price = float(data['5m'].iloc[-1]['close'])
|
||
price_change_24h = self._calculate_price_change(data['1h'])
|
||
logger.info(f"💰 当前价格: ${current_price:,.2f} ({price_change_24h})")
|
||
|
||
# 1.5. 波动率检查(节省 LLM 调用)
|
||
should_analyze, volatility_reason, volatility = self._check_volatility(symbol, data)
|
||
if not should_analyze:
|
||
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_analysis_status"] = "skipped"
|
||
self._analysis_monitor["last_analysis_detail"] = volatility_reason
|
||
self._record_analysis_event(
|
||
"symbol_analysis_skipped",
|
||
symbol=symbol,
|
||
status="hold",
|
||
detail=volatility_reason,
|
||
extra={"volatility_percent": volatility},
|
||
)
|
||
logger.info(f"⏸️ {volatility_reason},跳过本次 LLM 分析")
|
||
return
|
||
|
||
# ============================================================
|
||
# 第一阶段:市场信号分析(不包含仓位信息)
|
||
# ============================================================
|
||
lanes_to_run, cached_lane_results, force_reason = self._resolve_llm_lanes_for_symbol(symbol, data)
|
||
if force_lanes:
|
||
merged_lanes = set(lanes_to_run)
|
||
merged_lanes.update(force_lanes)
|
||
lanes_to_run = sorted(merged_lanes)
|
||
force_reason = trigger_reason or force_reason or f"{trigger_source} 强制刷新 {', '.join(force_lanes)}"
|
||
logger.info(f"\n🤖 【第一阶段:市场信号分析】 lanes={lanes_to_run or ['cache_only']}")
|
||
if force_reason:
|
||
logger.info(f" ⚡ 强制触发 LLM: {force_reason}")
|
||
elif not lanes_to_run:
|
||
logger.info(" 🧊 LLM 冷却中,使用上一轮 lane 缓存结果")
|
||
|
||
self._record_analysis_event(
|
||
"llm_lane_plan",
|
||
symbol=symbol,
|
||
status="info",
|
||
detail=force_reason or (f"本轮执行 lane: {', '.join(lanes_to_run)}" if lanes_to_run else "LLM 冷却中,使用缓存 lane 结果"),
|
||
extra={
|
||
"lanes_to_run": lanes_to_run,
|
||
"cache_only": not bool(lanes_to_run),
|
||
"force_reason": force_reason,
|
||
"trigger_source": trigger_source,
|
||
},
|
||
)
|
||
|
||
market_signal = await self.market_analyzer.analyze(
|
||
symbol, data,
|
||
symbols=self.symbols,
|
||
lanes=lanes_to_run,
|
||
cached_lane_results=cached_lane_results,
|
||
)
|
||
self._update_lane_analysis_state(symbol, market_signal)
|
||
|
||
# 输出市场分析结果
|
||
self._log_market_signal(market_signal)
|
||
|
||
# 存储最新信号(用于下一轮分析的上下文)
|
||
self.last_signals[symbol] = {
|
||
'timestamp': datetime.now().isoformat(),
|
||
'trend': market_signal.get('trend', 'unknown'),
|
||
'trend_strength': market_signal.get('trend_strength', 'unknown'),
|
||
'signals': market_signal.get('signals', []),
|
||
'key_levels': market_signal.get('key_levels', {}),
|
||
'current_price': current_price
|
||
}
|
||
|
||
# 过滤掉 wait 信号,只保留 buy/sell 信号
|
||
signals = market_signal.get('signals', [])
|
||
trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']]
|
||
|
||
if not trade_signals:
|
||
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_analysis_status"] = "completed"
|
||
self._analysis_monitor["last_analysis_detail"] = "完成分析,无交易信号"
|
||
self._record_analysis_event(
|
||
"symbol_analysis_completed",
|
||
symbol=symbol,
|
||
status="success",
|
||
detail="完成分析,无交易信号",
|
||
extra={"trade_signals": 0, "valid_signals": 0},
|
||
)
|
||
logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望")
|
||
return
|
||
|
||
# 检查是否有达到阈值的交易信号
|
||
threshold = self.settings.crypto_llm_threshold * 100 # 转换为百分比
|
||
valid_signals = [s for s in trade_signals if s.get('confidence', 0) >= threshold]
|
||
|
||
if not valid_signals:
|
||
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_analysis_status"] = "completed"
|
||
self._analysis_monitor["last_analysis_detail"] = f"完成分析,但无信号达到阈值 {threshold}%"
|
||
self._record_analysis_event(
|
||
"symbol_analysis_completed",
|
||
symbol=symbol,
|
||
status="success",
|
||
detail=f"完成分析,但无信号达到阈值 {threshold}%",
|
||
extra={"trade_signals": len(trade_signals), "valid_signals": 0},
|
||
)
|
||
logger.info(f"\n⏸️ 结论: 无交易信号达到置信度阈值 ({threshold}%),继续观望")
|
||
return
|
||
|
||
logger.info(f"\n✅ 发现 {len(valid_signals)} 个有效交易信号(达到 {threshold}% 阈值)")
|
||
for signal in valid_signals:
|
||
logger.info(
|
||
f" - {signal.get('timeframe', signal.get('type', 'unknown'))} | "
|
||
f"{signal.get('action')} | {signal.get('confidence', 0)}%"
|
||
)
|
||
|
||
# ============================================================
|
||
# 发送市场信号通知
|
||
# ============================================================
|
||
await self._send_market_signal_notification(market_signal, current_price)
|
||
|
||
# ============================================================
|
||
# 第二阶段:各平台独立处理交易信号(基于硬编码规则)
|
||
# ============================================================
|
||
logger.info(f"\n🤖 【第二阶段:各平台独立处理信号】")
|
||
|
||
# 2.1 模拟盘处理
|
||
if self.settings.paper_trading_enabled:
|
||
logger.info(f"\n📊 【模拟盘】")
|
||
paper_positions, paper_account, paper_pending = self._get_paper_trading_state()
|
||
paper_signal = self._select_signal_for_platform(valid_signals, 'PaperTrading', market_state=market_signal.get('market_state', '中性'), trend_direction=market_signal.get('trend_direction', 'neutral'))
|
||
if paper_signal:
|
||
logger.info(
|
||
f" 采用信号: {paper_signal.get('timeframe', 'unknown')} | "
|
||
f"{paper_signal.get('action')} | {paper_signal.get('confidence', 0)}%"
|
||
)
|
||
trading_signal = self._build_execution_signal(symbol, paper_signal, current_price, market_signal)
|
||
paper_decision = self.execute_signal_with_rules(
|
||
trading_signal, 'PaperTrading', paper_account, paper_positions, paper_pending
|
||
)
|
||
paper_decision = self._normalize_execution_decision(
|
||
paper_decision, paper_positions, paper_pending
|
||
)
|
||
else:
|
||
logger.info(" 无可执行信号")
|
||
paper_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||
else:
|
||
paper_decision = {"action": "IGNORE", "reason": "未启用"}
|
||
logger.info(f"⏸️ 模拟盘交易未启用")
|
||
|
||
# 2.2 Hyperliquid 实盘处理
|
||
if self.hyperliquid:
|
||
logger.info(f"\n🔥 【Hyperliquid】")
|
||
hl_positions, hl_account, hl_pending = self._get_hyperliquid_trading_state()
|
||
hl_signal = self._select_signal_for_platform(valid_signals, 'Hyperliquid', market_state=market_signal.get('market_state', '中性'), trend_direction=market_signal.get('trend_direction', 'neutral'))
|
||
if hl_signal:
|
||
logger.info(
|
||
f" 采用信号: {hl_signal.get('timeframe', 'unknown')} | "
|
||
f"{hl_signal.get('action')} | {hl_signal.get('confidence', 0)}%"
|
||
)
|
||
trading_signal = self._build_execution_signal(symbol, hl_signal, current_price, market_signal)
|
||
hl_decision = self.execute_signal_with_rules(
|
||
trading_signal, 'Hyperliquid', hl_account, hl_positions, hl_pending
|
||
)
|
||
hl_decision = self._normalize_execution_decision(
|
||
hl_decision, hl_positions, hl_pending
|
||
)
|
||
else:
|
||
logger.info(" 无可执行信号")
|
||
hl_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||
else:
|
||
hl_decision = {"action": "IGNORE", "reason": "未启用"}
|
||
logger.info(f"⏸️ Hyperliquid 实盘交易未启用")
|
||
|
||
# 2.3 Bitget 实盘处理
|
||
if self.bitget:
|
||
logger.info(f"\n🔥 【Bitget】")
|
||
bg_positions, bg_account, bg_pending = self._get_bitget_trading_state()
|
||
bg_signal = self._select_signal_for_platform(valid_signals, 'Bitget', market_state=market_signal.get('market_state', '中性'), trend_direction=market_signal.get('trend_direction', 'neutral'))
|
||
if bg_signal:
|
||
logger.info(
|
||
f" 采用信号: {bg_signal.get('timeframe', 'unknown')} | "
|
||
f"{bg_signal.get('action')} | {bg_signal.get('confidence', 0)}%"
|
||
)
|
||
trading_signal = self._build_execution_signal(symbol, bg_signal, current_price, market_signal)
|
||
bg_decision = self.execute_signal_with_rules(
|
||
trading_signal, 'Bitget', bg_account, bg_positions, bg_pending
|
||
)
|
||
bg_decision = self._normalize_execution_decision(
|
||
bg_decision, bg_positions, bg_pending
|
||
)
|
||
else:
|
||
logger.info(" 无可执行信号")
|
||
bg_decision = {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||
else:
|
||
bg_decision = {"action": "IGNORE", "reason": "未启用"}
|
||
logger.info(f"⏸️ Bitget 实盘交易未启用")
|
||
|
||
self.last_execution_preview[symbol] = {
|
||
'timestamp': datetime.now().isoformat(),
|
||
'current_price': current_price,
|
||
'paper': paper_decision,
|
||
'hyperliquid': hl_decision,
|
||
'bitget': bg_decision,
|
||
}
|
||
|
||
# ============================================================
|
||
# 第三阶段:执行交易动作(各平台独立)
|
||
# ============================================================
|
||
await self._execute_decisions(paper_decision, hl_decision, bg_decision, market_signal, current_price)
|
||
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_analysis_status"] = "completed"
|
||
self._analysis_monitor["last_analysis_detail"] = f"完成分析,产生 {len(valid_signals)} 个有效信号"
|
||
self._record_analysis_event(
|
||
"symbol_analysis_completed",
|
||
symbol=symbol,
|
||
status="success",
|
||
detail=f"完成分析,产生 {len(valid_signals)} 个有效信号",
|
||
extra={"trade_signals": len(trade_signals), "valid_signals": len(valid_signals)},
|
||
)
|
||
|
||
except Exception as e:
|
||
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
|
||
self._analysis_monitor["last_analysis_status"] = "error"
|
||
self._analysis_monitor["last_analysis_detail"] = str(e)
|
||
self._record_analysis_event(
|
||
"symbol_analysis_error",
|
||
symbol=symbol,
|
||
status="error",
|
||
detail=str(e),
|
||
)
|
||
logger.error(f"❌ 分析 {symbol} 出错: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
def _log_market_signal(self, signal: Dict[str, Any]):
|
||
"""输出市场信号分析结果"""
|
||
logger.info(f" 市场状态: {signal.get('market_state')}")
|
||
logger.info(f" 趋势: {signal.get('trend')}")
|
||
|
||
# 新闻情绪
|
||
news_sentiment = signal.get('news_sentiment', '')
|
||
if news_sentiment:
|
||
sentiment_icon = {'positive': '📈', 'negative': '📉', 'neutral': '➖'}.get(news_sentiment, '')
|
||
logger.info(f" 新闻情绪: {sentiment_icon} {news_sentiment}")
|
||
|
||
# 关键价位
|
||
import re
|
||
key_levels = signal.get('key_levels', {})
|
||
if key_levels.get('support') or key_levels.get('resistance'):
|
||
# 从字符串中提取数字(处理 "66065 (15m布林下轨)" 这种格式)
|
||
def extract_number(val):
|
||
if isinstance(val, (int, float)):
|
||
return float(val)
|
||
if isinstance(val, str):
|
||
# 提取第一个数字
|
||
match = re.search(r'[\d,]+\.?\d*', val.replace(',', ''))
|
||
if match:
|
||
return float(match.group())
|
||
return None
|
||
|
||
supports = [extract_number(s) for s in key_levels.get('support', [])[:2]]
|
||
resistances = [extract_number(r) for r in key_levels.get('resistance', [])[:2]]
|
||
support_str = ', '.join([f"${s:,.2f}" for s in supports if s is not None])
|
||
resistance_str = ', '.join([f"${r:,.2f}" for r in resistances if r is not None])
|
||
logger.info(f" 支撑位: {support_str or '-'}")
|
||
logger.info(f" 阻力位: {resistance_str or '-'}")
|
||
|
||
# 信号列表 - 区分交易信号和观望建议
|
||
signals = signal.get('signals', [])
|
||
trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']]
|
||
wait_signals = [s for s in signals if s.get('action') == 'wait']
|
||
|
||
if trade_signals:
|
||
logger.info(f"\n🎯 【发现 {len(trade_signals)} 个交易信号】")
|
||
for i, sig in enumerate(trade_signals, 1):
|
||
signal_type = sig.get('timeframe', 'unknown')
|
||
type_map = {'short_term': '短线', 'medium_term': '中线', 'long_term': '长线'}
|
||
type_text = type_map.get(signal_type, signal_type)
|
||
|
||
action = sig.get('action', 'hold')
|
||
action_map = {'buy': '🟢 做多', 'sell': '🔴 做空'}
|
||
action_text = action_map.get(action, action)
|
||
|
||
confidence = sig.get('confidence', 0)
|
||
|
||
logger.info(f"\n [{i}] {type_text} | {action_text}")
|
||
logger.info(f" 信心度: {confidence}%")
|
||
logger.info(f" 入场: ${sig.get('entry_price', 'N/A')}")
|
||
logger.info(f" 止损: ${sig.get('stop_loss', 'N/A')}")
|
||
logger.info(f" 止盈: ${sig.get('take_profit', 'N/A')}")
|
||
logger.info(f" 理由: {sig.get('reasoning', 'N/A')}")
|
||
|
||
if wait_signals:
|
||
logger.info(f"\n📋 【{len(wait_signals)} 个观望建议(不触发交易)】")
|
||
for i, sig in enumerate(wait_signals, 1):
|
||
signal_type = sig.get('timeframe', 'unknown')
|
||
type_map = {'short_term': '短线', 'medium_term': '中线', 'long_term': '长线'}
|
||
type_text = type_map.get(signal_type, signal_type)
|
||
|
||
confidence = sig.get('confidence', 0)
|
||
|
||
logger.info(f"\n [{i}] {type_text} | 观望")
|
||
logger.info(f" 信心度: {confidence}%")
|
||
logger.info(f" 理由: {sig.get('reasoning', 'N/A')}")
|
||
|
||
def _get_paper_trading_state(self) -> tuple:
|
||
"""
|
||
获取模拟盘交易状态(持仓和账户)
|
||
|
||
Returns:
|
||
(positions, account, pending_orders) - 持仓列表、账户状态、挂单列表
|
||
"""
|
||
# 模拟交易
|
||
active_orders = self.paper_trading.get_active_orders()
|
||
account = self.paper_trading.get_account_status()
|
||
|
||
# 分离持仓和挂单
|
||
position_list = []
|
||
pending_orders = []
|
||
for order in active_orders:
|
||
if order.get('status') == 'open' and order.get('filled_price'):
|
||
# 已成交的订单作为持仓
|
||
position = {
|
||
'order_id': order.get('order_id'),
|
||
'symbol': order.get('symbol'),
|
||
'side': 'buy' if order.get('side') == 'long' else 'sell',
|
||
'holding': order.get('quantity', 0),
|
||
'entry_price': order.get('filled_price') or order.get('entry_price'),
|
||
'unrealized_pnl_pct': order.get('pnl_percent', 0),
|
||
'stop_loss': order.get('stop_loss'),
|
||
'take_profit': order.get('take_profit'),
|
||
'opened_at': order.get('opened_at'),
|
||
'created_at': order.get('created_at'),
|
||
}
|
||
position_list.append(self._build_runtime_position_state(position))
|
||
elif order.get('status') == 'pending':
|
||
# 未成交的订单作为挂单
|
||
pending_orders.append({
|
||
'order_id': order.get('order_id'),
|
||
'symbol': order.get('symbol'),
|
||
'side': 'buy' if order.get('side') == 'long' else 'sell',
|
||
'entry_price': order.get('entry_price'),
|
||
'quantity': order.get('quantity', 0),
|
||
'entry_type': order.get('entry_type', 'market'),
|
||
'confidence': order.get('confidence', 0),
|
||
'created_at': order.get('created_at'),
|
||
})
|
||
|
||
return position_list, account, pending_orders
|
||
|
||
def _get_hyperliquid_trading_state(self) -> tuple:
|
||
"""
|
||
获取 Hyperliquid 实盘交易状态(持仓和账户)
|
||
|
||
Returns:
|
||
(positions, account, pending_orders) - 持仓列表、账户状态、挂单列表
|
||
"""
|
||
try:
|
||
# 获取账户状态
|
||
hl_state = self.hyperliquid.get_account_state()
|
||
|
||
# 转换持仓格式
|
||
position_list = []
|
||
for pos in hl_state["positions"]:
|
||
if not isinstance(pos, dict):
|
||
continue
|
||
position_data = pos.get("position", {})
|
||
if not isinstance(position_data, dict):
|
||
continue
|
||
coin = position_data.get("coin")
|
||
size = float(position_data.get("szi", 0))
|
||
|
||
if size != 0:
|
||
entry_price = float(position_data.get("entryPx", 0))
|
||
unrealized_pnl = float(position_data.get("unrealizedPnl", 0))
|
||
mark_price = float(position_data.get("markPx", 0) or position_data.get("mark_price", 0) or 0)
|
||
|
||
# 获取止盈止损价格(从挂单中查询)
|
||
tp_sl_prices = self.hyperliquid.get_tp_sl_prices(coin)
|
||
|
||
position = {
|
||
'symbol': f"{coin}USDT", # BTC → BTCUSDT
|
||
'side': 'buy' if size > 0 else 'sell',
|
||
'holding': abs(size),
|
||
'entry_price': entry_price,
|
||
'mark_price': mark_price,
|
||
'unrealized_pnl': unrealized_pnl,
|
||
'stop_loss': tp_sl_prices.get('stop_loss'),
|
||
'take_profit': tp_sl_prices.get('take_profit'),
|
||
'opened_at': datetime.fromtimestamp(position_data.get("timestamp", 0) / 1000).isoformat()
|
||
if position_data.get("timestamp") else None,
|
||
}
|
||
position_list.append(self._build_runtime_position_state(position))
|
||
|
||
# 转换账户格式(匹配模拟盘格式)
|
||
account = {
|
||
'current_balance': hl_state["account_value"],
|
||
'initial_balance': self.hyperliquid.initial_balance,
|
||
'used_margin': hl_state["total_margin_used"],
|
||
'available_balance': hl_state["available_balance"],
|
||
'available': hl_state["available_balance"], # 决策器期望的键名
|
||
'order_leverage': min(getattr(self.hyperliquid, 'max_total_leverage', 10), 10),
|
||
'total_position_value': sum(abs(float(p.get("position", {}).get("szi", 0)) *
|
||
float(p.get("position", {}).get("entryPx", 0)))
|
||
for p in hl_state["positions"]),
|
||
'max_total_leverage': self.hyperliquid.max_total_leverage
|
||
}
|
||
|
||
# 计算当前总杠杆
|
||
if account['current_balance'] > 0:
|
||
account['current_total_leverage'] = account['total_position_value'] / account['current_balance']
|
||
else:
|
||
account['current_total_leverage'] = 0
|
||
|
||
# 获取挂单(包括止盈止损)
|
||
all_orders = self.hyperliquid.get_open_orders()
|
||
pending_orders = []
|
||
for order in all_orders:
|
||
pending_orders.append({
|
||
'order_id': order.get('order_id'),
|
||
'symbol': f"{order['symbol']}USDT", # 转换格式
|
||
'side': order.get('side'),
|
||
'entry_price': order.get('price'),
|
||
'quantity': order.get('size'),
|
||
'entry_type': 'limit',
|
||
'is_reduce_only': order.get('is_reduce_only', False),
|
||
'created_at': order.get('created_at'),
|
||
})
|
||
|
||
return position_list, account, pending_orders
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取 Hyperliquid 状态失败: {e}")
|
||
return [], {}, []
|
||
|
||
def _normalize_symbol(self, symbol: str) -> str:
|
||
"""统一交易对格式为 BTCUSDT"""
|
||
if not symbol:
|
||
return symbol
|
||
return symbol if symbol.endswith('USDT') else f"{symbol}USDT"
|
||
|
||
def _build_follow_up_open_decision(self, decision: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""为复合动作构建二段式开仓决策"""
|
||
follow_up = dict(decision)
|
||
follow_up.pop('next_decision', None)
|
||
follow_up['decision'] = 'OPEN'
|
||
follow_up['action'] = decision.get('signal_action', decision.get('action'))
|
||
follow_up['symbol'] = self._normalize_symbol(decision.get('symbol', ''))
|
||
return follow_up
|
||
|
||
def _normalize_execution_decision(self,
|
||
decision: Dict[str, Any],
|
||
positions: List[Dict[str, Any]],
|
||
pending_orders: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||
"""将复合动作归一化为执行器可落地的动作"""
|
||
if not decision:
|
||
return decision
|
||
|
||
decision_type = decision.get('decision', 'HOLD')
|
||
if decision_type in {'OPEN', 'ADD', 'CLOSE', 'CANCEL_PENDING', 'HOLD'}:
|
||
return decision
|
||
|
||
symbol = self._normalize_symbol(decision.get('symbol', ''))
|
||
signal_action = decision.get('signal_action', decision.get('action'))
|
||
opposite_side = 'sell' if signal_action == 'buy' else 'buy'
|
||
actionable_pending_orders = self._get_actionable_pending_orders(pending_orders)
|
||
|
||
same_positions = [
|
||
pos for pos in positions
|
||
if self._normalize_symbol(pos.get('symbol', '')) == symbol and pos.get('side') == signal_action
|
||
]
|
||
opposite_positions = [
|
||
pos for pos in positions
|
||
if self._normalize_symbol(pos.get('symbol', '')) == symbol and pos.get('side') == opposite_side
|
||
]
|
||
opposite_pending = [
|
||
order for order in actionable_pending_orders
|
||
if self._normalize_symbol(order.get('symbol', '')) == symbol and order.get('side') == opposite_side
|
||
]
|
||
|
||
def build_close(target_positions: List[Dict[str, Any]], reason: str) -> Dict[str, Any]:
|
||
close_decision = dict(decision)
|
||
close_decision['decision'] = 'CLOSE'
|
||
close_decision['action'] = 'CLOSE'
|
||
close_decision['symbol'] = symbol
|
||
close_decision['reason'] = reason
|
||
close_decision['reasoning'] = reason
|
||
order_ids = [pos.get('order_id') for pos in target_positions if pos.get('order_id')]
|
||
if order_ids:
|
||
close_decision['orders_to_close'] = order_ids
|
||
return close_decision
|
||
|
||
def build_cancel(target_orders: List[Dict[str, Any]], reason: str) -> Dict[str, Any]:
|
||
cancel_decision = dict(decision)
|
||
cancel_decision['decision'] = 'CANCEL_PENDING'
|
||
cancel_decision['action'] = 'CANCEL_PENDING'
|
||
cancel_decision['symbol'] = symbol
|
||
cancel_decision['reason'] = reason
|
||
cancel_decision['reasoning'] = reason
|
||
cancel_decision['orders_to_cancel'] = [
|
||
order.get('order_id') for order in target_orders if order.get('order_id')
|
||
]
|
||
return cancel_decision
|
||
|
||
if decision_type in {'FLIP', 'CLOSE_OPPOSITE'}:
|
||
if opposite_positions:
|
||
normalized = build_close(opposite_positions, decision.get('reasoning', decision.get('reason', '反向仓位先平仓')))
|
||
normalized['next_decision'] = self._build_follow_up_open_decision(decision)
|
||
return normalized
|
||
if opposite_pending:
|
||
normalized = build_cancel(opposite_pending, decision.get('reasoning', decision.get('reason', '先撤销反向挂单')))
|
||
normalized['next_decision'] = self._build_follow_up_open_decision(decision)
|
||
return normalized
|
||
return self._build_follow_up_open_decision(decision)
|
||
|
||
if decision_type == 'CANCEL_AND_OPEN':
|
||
if opposite_pending:
|
||
normalized = build_cancel(opposite_pending, decision.get('reasoning', decision.get('reason', '先撤销反向挂单')))
|
||
normalized['next_decision'] = self._build_follow_up_open_decision(decision)
|
||
return normalized
|
||
return self._build_follow_up_open_decision(decision)
|
||
|
||
if decision_type == 'ROLL':
|
||
if same_positions:
|
||
normalized = build_close(same_positions, decision.get('reasoning', decision.get('reason', '滚仓先平旧仓')))
|
||
normalized['next_decision'] = self._build_follow_up_open_decision(decision)
|
||
return normalized
|
||
return self._build_follow_up_open_decision(decision)
|
||
|
||
logger.warning(f"未知复合决策类型,降级为 HOLD: {decision_type}")
|
||
fallback = dict(decision)
|
||
fallback['decision'] = 'HOLD'
|
||
fallback['reasoning'] = fallback.get('reasoning', fallback.get('reason', f'未支持的决策类型: {decision_type}'))
|
||
return fallback
|
||
|
||
async def _execute_decisions(self, paper_decision: Dict[str, Any],
|
||
hyperliquid_decision: Dict[str, Any],
|
||
bitget_decision: Dict[str, Any],
|
||
market_signal: Dict[str, Any], current_price: float):
|
||
"""执行交易决策(三轨独立)"""
|
||
# 保存本轮所有达到阈值的可交易信号,避免分流后只落一条信号
|
||
threshold = self.settings.crypto_llm_threshold * 100
|
||
symbol = market_signal.get('symbol')
|
||
signals = market_signal.get('signals', [])
|
||
valid_signals = [
|
||
signal for signal in signals
|
||
if signal.get('action') in {'buy', 'sell'} and signal.get('confidence', 0) >= threshold
|
||
]
|
||
|
||
for signal in valid_signals:
|
||
signal_to_save = signal.copy()
|
||
signal_to_save['signal_type'] = 'crypto'
|
||
signal_to_save['symbol'] = symbol
|
||
signal_to_save['current_price'] = current_price
|
||
self.signal_db.add_signal(signal_to_save)
|
||
|
||
# ============================================================
|
||
# 执行模拟盘决策
|
||
# ============================================================
|
||
if paper_decision and not self._is_platform_halted('PaperTrading'):
|
||
await self._execute_paper_decisions(paper_decision, market_signal, current_price)
|
||
elif paper_decision and self._is_platform_halted('PaperTrading'):
|
||
self._record_execution_event(
|
||
"PaperTrading",
|
||
"platform_halted_skip",
|
||
symbol=paper_decision.get("symbol", market_signal.get("symbol", "")),
|
||
decision=paper_decision,
|
||
reason="平台已停机,跳过执行",
|
||
status="warning",
|
||
)
|
||
|
||
# ============================================================
|
||
# 执行 Hyperliquid 决策
|
||
# ============================================================
|
||
if hyperliquid_decision and self.hyperliquid and not self._is_platform_halted('Hyperliquid'):
|
||
await self._execute_hyperliquid_decisions(hyperliquid_decision, market_signal, current_price)
|
||
elif hyperliquid_decision and self.hyperliquid and self._is_platform_halted('Hyperliquid'):
|
||
self._record_execution_event(
|
||
"Hyperliquid",
|
||
"platform_halted_skip",
|
||
symbol=hyperliquid_decision.get("symbol", market_signal.get("symbol", "")),
|
||
decision=hyperliquid_decision,
|
||
reason="平台已停机,跳过执行",
|
||
status="warning",
|
||
)
|
||
|
||
# ============================================================
|
||
# 执行 Bitget 决策
|
||
# ============================================================
|
||
if bitget_decision and self.bitget and not self._is_platform_halted('Bitget'):
|
||
await self._execute_bitget_decisions(bitget_decision, market_signal, current_price)
|
||
elif bitget_decision and self.bitget and self._is_platform_halted('Bitget'):
|
||
self._record_execution_event(
|
||
"Bitget",
|
||
"platform_halted_skip",
|
||
symbol=bitget_decision.get("symbol", market_signal.get("symbol", "")),
|
||
decision=bitget_decision,
|
||
reason="平台已停机,跳过执行",
|
||
status="warning",
|
||
)
|
||
|
||
await self._notify_execution_summary_if_needed(
|
||
market_signal=market_signal,
|
||
current_price=current_price,
|
||
decisions={
|
||
"PaperTrading": paper_decision,
|
||
"Hyperliquid": hyperliquid_decision,
|
||
"Bitget": bitget_decision,
|
||
},
|
||
)
|
||
|
||
async def _notify_execution_summary_if_needed(
|
||
self,
|
||
market_signal: Dict[str, Any],
|
||
current_price: float,
|
||
decisions: Dict[str, Dict[str, Any]],
|
||
):
|
||
"""当存在可交易信号,但本轮所有平台都未真正执行时,仅发送一条汇总通知。"""
|
||
actionable: Dict[str, Dict[str, Any]] = {}
|
||
executed = False
|
||
|
||
for platform_name, decision in (decisions or {}).items():
|
||
if not decision:
|
||
continue
|
||
if decision.get('_execution_succeeded'):
|
||
executed = True
|
||
if decision.get('decision') in {'OPEN', 'ADD', 'CLOSE', 'CANCEL_PENDING'}:
|
||
actionable[platform_name] = decision
|
||
|
||
if executed or not actionable:
|
||
return
|
||
|
||
symbol = market_signal.get('symbol', '')
|
||
signal = self._get_best_signal_from_market(market_signal)
|
||
if not signal:
|
||
return
|
||
|
||
confidence = signal.get('confidence', 0)
|
||
entry_type = signal.get('entry_type', 'market')
|
||
entry_price = signal.get('entry_price', current_price)
|
||
signal_timeframe = signal.get('timeframe', signal.get('type', 'unknown'))
|
||
timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
|
||
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe)
|
||
action = signal.get('action', 'wait')
|
||
action_text = {'buy': '做多', 'sell': '做空', 'wait': '观望'}.get(action, action)
|
||
|
||
title = f"[执行汇总] {symbol} 信号未落单"
|
||
content_parts = [
|
||
f"**信号**: {action_text} | {timeframe_text} | 📈 **{confidence}%**",
|
||
"",
|
||
f"**入场方式**: {entry_type}",
|
||
f"**建议入场价**: ${entry_price:,.2f}" if isinstance(entry_price, (int, float)) else f"**建议入场价**: {entry_price}",
|
||
f"**当前价格**: ${current_price:,.2f}",
|
||
"",
|
||
"**平台结果**:",
|
||
]
|
||
blocked_platforms: List[Dict[str, Any]] = []
|
||
|
||
for platform_name, decision in actionable.items():
|
||
tag, detail = self._classify_execution_block_reason(platform_name, decision)
|
||
content_parts.append(f"- {platform_name}: **{tag}** | {detail}")
|
||
blocked_platforms.append({
|
||
"platform": platform_name,
|
||
"tag": tag,
|
||
"detail": detail,
|
||
"decision": decision.get("decision"),
|
||
})
|
||
|
||
content = "\n".join(content_parts)
|
||
self._record_execution_event(
|
||
"SYSTEM",
|
||
"execution_blocked_summary",
|
||
symbol=symbol,
|
||
reason=f"{action_text} {timeframe_text} 信号未落单",
|
||
status="warning",
|
||
extra={
|
||
"signal_action": action,
|
||
"signal_action_text": action_text,
|
||
"signal_timeframe": signal_timeframe,
|
||
"signal_timeframe_text": timeframe_text,
|
||
"confidence": confidence,
|
||
"entry_type": entry_type,
|
||
"entry_price": entry_price,
|
||
"current_price": current_price,
|
||
"blocked_platforms": blocked_platforms,
|
||
},
|
||
)
|
||
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu.send_card(title, content, "orange")
|
||
if self.settings.telegram_enabled:
|
||
await self.telegram.send_message(f"{title}\n\n{content}")
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
|
||
async def _execute_paper_decisions(self, decision: Dict[str, Any],
|
||
market_signal: Dict[str, Any],
|
||
current_price: float):
|
||
"""执行模拟盘决策(使用执行器)"""
|
||
try:
|
||
decision_type = decision.get('decision', 'HOLD')
|
||
next_decision = decision.get('next_decision')
|
||
|
||
if decision_type == 'HOLD':
|
||
hold_reason = decision.get('reason', decision.get('reasoning', '观望'))
|
||
logger.info(f"\n📊 交易决策: {hold_reason}")
|
||
self._record_execution_event("PaperTrading", "hold", decision=decision, reason=hold_reason, status="hold")
|
||
# 仅记录日志,不发飞书通知(避免消息过多)
|
||
return
|
||
|
||
logger.info(f"\n📊 【执行交易】")
|
||
|
||
# 使用执行器
|
||
executor = self.executors.get('PaperTrading')
|
||
if not executor:
|
||
logger.error(f" ❌ 模拟盘执行器未初始化")
|
||
return
|
||
|
||
# 执行开仓/加仓
|
||
if decision_type in ['OPEN', 'ADD']:
|
||
result = await executor.execute_open(decision, current_price)
|
||
|
||
if result.get('success'):
|
||
order_id = result.get('order_id', 'unknown')
|
||
logger.info(f" ✅ 交易成功: 订单ID {order_id}")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event(
|
||
"PaperTrading", "open_success", decision=decision, status="success",
|
||
reason=decision.get('reason', decision.get('reasoning', '')),
|
||
extra={"order_id": order_id},
|
||
)
|
||
|
||
# TP/SL 警告
|
||
if result.get('tp_sl_warning'):
|
||
logger.warning(f" ⚠️ 止盈止损设置失败: {result['tp_sl_warning']}")
|
||
else:
|
||
error = result.get('error', result.get('message', '未知错误'))
|
||
logger.error(f" ❌ 交易失败: {error}")
|
||
self._record_execution_event("PaperTrading", "open_failed", decision=decision, reason=error, status="error")
|
||
# 仅记录日志,不发飞书通知(避免消息过多)
|
||
|
||
# 执行平仓
|
||
elif decision_type == 'CLOSE':
|
||
result = await executor.execute_close(decision, current_price)
|
||
|
||
if result.get('success'):
|
||
logger.info(f" ✅ 平仓成功")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event("PaperTrading", "close_success", decision=decision, status="success")
|
||
if next_decision:
|
||
await self._execute_paper_decisions(next_decision, market_signal, current_price)
|
||
else:
|
||
error = result.get('error', '平仓失败')
|
||
logger.error(f" ❌ 平仓失败: {error}")
|
||
self._record_execution_event("PaperTrading", "close_failed", decision=decision, reason=error, status="error")
|
||
|
||
# 执行撤单
|
||
elif decision_type == 'CANCEL_PENDING':
|
||
orders_to_cancel = decision.get('orders_to_cancel', [])
|
||
success_count = 0
|
||
|
||
for order_info in orders_to_cancel:
|
||
order_id = order_info if isinstance(order_info, str) else order_info.get('order_id', '')
|
||
symbol = decision.get('symbol', '')
|
||
result = await executor.execute_cancel(order_id, symbol)
|
||
if result.get('success'):
|
||
success_count += 1
|
||
|
||
if success_count > 0:
|
||
logger.info(f" ✅ 成功取消 {success_count} 个挂单")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event(
|
||
"PaperTrading", "cancel_success", decision=decision, status="success",
|
||
extra={"cancelled_count": success_count},
|
||
)
|
||
if next_decision:
|
||
await self._execute_paper_decisions(next_decision, market_signal, current_price)
|
||
else:
|
||
logger.warning(f" ⚠️ 没有成功取消任何挂单")
|
||
self._record_execution_event("PaperTrading", "cancel_failed", decision=decision, reason="没有成功取消任何挂单", status="warning")
|
||
else:
|
||
logger.warning(f" ⚠️ 模拟盘暂不支持的执行动作: {decision_type}")
|
||
self._record_execution_event("PaperTrading", "unsupported_decision", decision=decision, reason=f"暂不支持的执行动作: {decision_type}", status="warning")
|
||
|
||
except Exception as e:
|
||
logger.error(f" ❌ 模拟盘执行异常: {e}")
|
||
self._record_execution_event("PaperTrading", "exception", decision=decision, reason=str(e), status="error")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
|
||
def _get_best_signal_from_market(self, market_signal: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""从市场信号中获取最佳信号"""
|
||
signals = market_signal.get('signals', [])
|
||
if not signals:
|
||
return {}
|
||
|
||
# 按信心度排序,取最高的
|
||
sorted_signals = sorted(signals, key=lambda x: x.get('confidence', 0), reverse=True)
|
||
return sorted_signals[0]
|
||
|
||
def _select_signal_for_platform(self, signals: List[Dict[str, Any]],
|
||
platform_name: str,
|
||
market_state: str = '中性',
|
||
trend_direction: str = 'neutral') -> Optional[Dict[str, Any]]:
|
||
"""根据平台偏好和市场状态选择最适合执行的信号"""
|
||
if not signals:
|
||
return None
|
||
|
||
# 震荡市:趋势信号降权(信心 × 0.8),优先选择日内反转信号
|
||
adjusted_signals = []
|
||
for signal in signals:
|
||
s = dict(signal) # 不修改原信号
|
||
confidence = s.get('confidence', 50)
|
||
lane = s.get('timeframe') or s.get('type') or 'unknown'
|
||
|
||
if market_state == '震荡市' and lane == 'medium_term':
|
||
confidence = int(confidence * 0.8)
|
||
s['_regime_adjusted'] = True
|
||
s['_original_confidence'] = s.get('confidence', 50)
|
||
s['confidence'] = confidence
|
||
elif market_state in ('趋势市', '日内趋势') and lane == 'short_term':
|
||
pass # 趋势市不降权日内信号
|
||
adjusted_signals.append(s)
|
||
|
||
# 逆势信号大幅降权(安全网,主过滤在 _merge_lane_results)
|
||
if trend_direction in ('uptrend', 'downtrend'):
|
||
forbidden = 'sell' if trend_direction == 'uptrend' else 'buy'
|
||
for s in adjusted_signals:
|
||
if s.get('action') == forbidden:
|
||
original = s.get('_original_confidence', s.get('confidence', 50))
|
||
s['confidence'] = int(s.get('confidence', 50) * 0.3)
|
||
s['_trend_penalized'] = True
|
||
s['_original_confidence'] = original
|
||
|
||
lane_priority = self.PLATFORM_SIGNAL_PRIORITY.get(platform_name, ['short_term', 'medium_term'])
|
||
by_lane: Dict[str, List[Dict[str, Any]]] = {}
|
||
for signal in adjusted_signals:
|
||
lane = signal.get('timeframe') or signal.get('type') or 'unknown'
|
||
by_lane.setdefault(lane, []).append(signal)
|
||
|
||
for lane in lane_priority:
|
||
candidates = by_lane.get(lane, [])
|
||
if candidates:
|
||
best = sorted(candidates, key=lambda item: item.get('confidence', 0), reverse=True)[0]
|
||
if best.get('_regime_adjusted'):
|
||
logger.info(f" 📊 震荡市降权: {lane} 原始信心={best.get('_original_confidence')} → 调整后={best.get('confidence')}")
|
||
return best
|
||
|
||
return sorted(adjusted_signals, key=lambda item: item.get('confidence', 0), reverse=True)[0]
|
||
|
||
def _build_execution_signal(self, symbol: str, signal: Dict[str, Any],
|
||
current_price: float,
|
||
market_signal: Dict[str, Any] = None) -> Dict[str, Any]:
|
||
"""构建传给执行规则层的标准信号格式"""
|
||
signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term'
|
||
position_size = signal.get('position_size') or self.SIGNAL_POSITION_SIZE_DEFAULTS.get(signal_type, 'light')
|
||
funding_rate_data = market_signal.get('funding_rate_data') if market_signal else None
|
||
range_metrics = market_signal.get('range_metrics') or {} if market_signal else {}
|
||
market_location = market_signal.get('market_location') or {} if market_signal else {}
|
||
|
||
return {
|
||
'symbol': symbol,
|
||
'action': signal.get('action'),
|
||
'confidence': signal.get('confidence', 50),
|
||
'grade': signal.get('grade', 'C'),
|
||
'entry_type': signal.get('entry_type', 'market'),
|
||
'entry_price': signal.get('entry_price', current_price),
|
||
'stop_loss': signal.get('stop_loss'),
|
||
'take_profit': signal.get('take_profit'),
|
||
'reasoning': signal.get('reasoning', ''),
|
||
'timeframe': signal_type,
|
||
'type': signal_type,
|
||
'position_size': position_size,
|
||
'current_price': current_price,
|
||
'market_state': market_signal.get('market_state', '中性') if market_signal else '中性',
|
||
'regime': range_metrics.get('regime', ''),
|
||
'range_metrics': range_metrics,
|
||
'market_location': market_location,
|
||
'funding_rate_data': funding_rate_data,
|
||
'crowding_bias': (funding_rate_data or {}).get('crowding_bias'),
|
||
'crowding_regime': (funding_rate_data or {}).get('crowding_regime'),
|
||
'crowding_score': (funding_rate_data or {}).get('crowding_score'),
|
||
}
|
||
|
||
def _get_signal_for_decision(self, market_signal: Dict[str, Any], decision: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""优先返回与当前执行决策匹配的信号,用于通知和展示"""
|
||
if not market_signal:
|
||
return {}
|
||
|
||
target_timeframe = decision.get('timeframe') or decision.get('type')
|
||
target_action = decision.get('signal_action') or decision.get('action')
|
||
signals = market_signal.get('signals', [])
|
||
|
||
if target_timeframe or target_action:
|
||
matched = [
|
||
signal for signal in signals
|
||
if (not target_timeframe or (signal.get('timeframe') or signal.get('type')) == target_timeframe)
|
||
and (not target_action or signal.get('action') == target_action)
|
||
]
|
||
if matched:
|
||
return sorted(matched, key=lambda item: item.get('confidence', 0), reverse=True)[0]
|
||
|
||
return self._get_best_signal_from_market(market_signal)
|
||
|
||
def _get_signal_execution_rule(self, signal: Dict[str, Any]) -> Dict[str, float]:
|
||
signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term'
|
||
return self.SIGNAL_EXECUTION_RULES.get(signal_type, self.SIGNAL_EXECUTION_RULES['medium_term'])
|
||
|
||
def _get_actionable_pending_orders(self, pending_orders: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
return [order for order in pending_orders if not order.get('is_reduce_only')]
|
||
|
||
def _parse_runtime_datetime(self, value: Any) -> Optional[datetime]:
|
||
if not value:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
return value
|
||
if isinstance(value, (int, float)):
|
||
timestamp = value / 1000 if value > 10**12 else value
|
||
try:
|
||
return datetime.fromtimestamp(timestamp)
|
||
except (OverflowError, OSError, ValueError):
|
||
return None
|
||
if isinstance(value, str):
|
||
try:
|
||
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
def _build_runtime_position_state(self,
|
||
position: Dict[str, Any],
|
||
reference_price: Optional[float] = None) -> Dict[str, Any]:
|
||
enriched = dict(position)
|
||
entry_price = float(position.get('entry_price', 0) or 0)
|
||
side = position.get('side')
|
||
|
||
current_price_raw = position.get('current_price', position.get('mark_price', reference_price))
|
||
current_price = float(current_price_raw or 0) if current_price_raw not in (None, '') else 0.0
|
||
if current_price > 0:
|
||
enriched['current_price'] = current_price
|
||
|
||
pnl_pct = position.get('unrealized_pnl_pct')
|
||
if not isinstance(pnl_pct, (int, float)) and entry_price > 0 and current_price > 0:
|
||
if side == 'buy':
|
||
pnl_pct = (current_price - entry_price) / entry_price * 100
|
||
elif side == 'sell':
|
||
pnl_pct = (entry_price - current_price) / entry_price * 100
|
||
if isinstance(pnl_pct, (int, float)):
|
||
enriched['unrealized_pnl_pct'] = round(float(pnl_pct), 4)
|
||
|
||
take_profit = position.get('take_profit')
|
||
remaining_tp_pct = position.get('remaining_tp_pct')
|
||
if not isinstance(remaining_tp_pct, (int, float)) and current_price > 0 and isinstance(take_profit, (int, float)):
|
||
if side == 'buy':
|
||
remaining_tp_pct = max(0.0, (float(take_profit) - current_price) / current_price * 100)
|
||
elif side == 'sell':
|
||
remaining_tp_pct = max(0.0, (current_price - float(take_profit)) / current_price * 100)
|
||
if isinstance(remaining_tp_pct, (int, float)):
|
||
enriched['remaining_tp_pct'] = round(float(remaining_tp_pct), 4)
|
||
|
||
stop_loss = position.get('stop_loss')
|
||
stop_to_entry_pct = position.get('stop_to_entry_pct')
|
||
if not isinstance(stop_to_entry_pct, (int, float)) and entry_price > 0 and isinstance(stop_loss, (int, float)):
|
||
if side == 'buy':
|
||
stop_to_entry_pct = (float(stop_loss) - entry_price) / entry_price * 100
|
||
elif side == 'sell':
|
||
stop_to_entry_pct = (entry_price - float(stop_loss)) / entry_price * 100
|
||
if isinstance(stop_to_entry_pct, (int, float)):
|
||
enriched['stop_to_entry_pct'] = round(float(stop_to_entry_pct), 4)
|
||
enriched['is_protected'] = bool(float(stop_to_entry_pct) >= -0.2)
|
||
elif isinstance(position.get('is_protected'), bool):
|
||
enriched['is_protected'] = position.get('is_protected')
|
||
|
||
opened_at = self._parse_runtime_datetime(position.get('opened_at'))
|
||
if opened_at:
|
||
now = datetime.now(opened_at.tzinfo) if opened_at.tzinfo else datetime.now()
|
||
holding_hours = max(0.0, (now - opened_at).total_seconds() / 3600)
|
||
enriched['opened_at'] = opened_at.isoformat()
|
||
enriched['holding_hours'] = round(holding_hours, 2)
|
||
|
||
return enriched
|
||
|
||
def _resolve_position_pnl_pct(self, position: Dict[str, Any], signal: Dict[str, Any]) -> float:
|
||
pnl_pct = position.get('unrealized_pnl_pct')
|
||
if isinstance(pnl_pct, (int, float)):
|
||
return float(pnl_pct)
|
||
|
||
entry_price = float(position.get('entry_price', 0) or 0)
|
||
current_price = float(signal.get('current_price', signal.get('entry_price', 0)) or 0)
|
||
side = position.get('side')
|
||
|
||
if entry_price <= 0 or current_price <= 0:
|
||
return 0.0
|
||
|
||
if side == 'buy':
|
||
return (current_price - entry_price) / entry_price * 100
|
||
if side == 'sell':
|
||
return (entry_price - current_price) / entry_price * 100
|
||
return 0.0
|
||
|
||
def _remaining_target_distance_pct(self, signal: Dict[str, Any], position: Dict[str, Any]) -> Optional[float]:
|
||
remaining_tp_pct = position.get('remaining_tp_pct')
|
||
if isinstance(remaining_tp_pct, (int, float)):
|
||
return float(remaining_tp_pct)
|
||
|
||
current_price = float(signal.get('current_price', signal.get('entry_price', 0)) or 0)
|
||
take_profit = position.get('take_profit') or signal.get('take_profit')
|
||
side = position.get('side') or signal.get('action')
|
||
|
||
if current_price <= 0 or not isinstance(take_profit, (int, float)):
|
||
return None
|
||
|
||
if side == 'buy':
|
||
return max(0.0, (float(take_profit) - current_price) / current_price * 100)
|
||
if side == 'sell':
|
||
return max(0.0, (current_price - float(take_profit)) / current_price * 100)
|
||
return None
|
||
|
||
def _is_position_protected(self, position: Dict[str, Any]) -> bool:
|
||
if isinstance(position.get('is_protected'), bool):
|
||
return position.get('is_protected')
|
||
|
||
entry_price = float(position.get('entry_price', 0) or 0)
|
||
stop_loss = position.get('stop_loss')
|
||
side = position.get('side')
|
||
if entry_price <= 0 or not isinstance(stop_loss, (int, float)):
|
||
return False
|
||
if side == 'buy':
|
||
return float(stop_loss) >= entry_price * 0.998
|
||
if side == 'sell':
|
||
return float(stop_loss) <= entry_price * 1.002
|
||
return False
|
||
|
||
def _should_replace_pending_order(self, signal: Dict[str, Any], order: Dict[str, Any]) -> tuple[bool, str]:
|
||
entry_type = signal.get('entry_type', 'market')
|
||
if entry_type != 'limit':
|
||
return False, "仅 limit 信号考虑替换挂单"
|
||
|
||
signal_price = float(signal.get('entry_price', 0) or 0)
|
||
order_price = float(order.get('entry_price', 0) or 0)
|
||
current_price = float(signal.get('current_price', signal_price) or signal_price or 0)
|
||
signal_side = signal.get('action')
|
||
signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term'
|
||
market_location = signal.get('market_location') or {}
|
||
rule = self._get_signal_execution_rule(signal)
|
||
|
||
if signal_price <= 0 or order_price <= 0 or current_price <= 0:
|
||
return False, "价格无效,不替换"
|
||
|
||
location_tag = market_location.get('location_tag')
|
||
preferred_tag = 'near_long_zone' if signal_side == 'buy' else 'near_short_zone'
|
||
preferred_dist = market_location.get(
|
||
'distance_to_best_long_zone_pct' if signal_side == 'buy' else 'distance_to_best_short_zone_pct'
|
||
)
|
||
|
||
if location_tag in {'middle_of_range', 'far_from_trade_zone'}:
|
||
return False, f"当前 market_location={location_tag},不在优先替换区"
|
||
if location_tag == 'between_trade_zones':
|
||
if signal_type != 'short_term':
|
||
return False, "当前位于交易区之间,仅短线信号允许主动替换挂单"
|
||
if not isinstance(preferred_dist, (int, float)) or preferred_dist > 1.0:
|
||
return False, f"当前距优先交易区 {preferred_dist}% 过远,不替换"
|
||
elif location_tag and location_tag != preferred_tag:
|
||
return False, f"当前 market_location={location_tag},与信号方向优先交易区不匹配"
|
||
|
||
price_diff_pct = abs(signal_price - order_price) / order_price * 100
|
||
if price_diff_pct < 0.5:
|
||
return False, f"新旧挂单价格差 {price_diff_pct:.2f}% < 0.5%"
|
||
|
||
signal_is_better = (
|
||
(signal_side == 'buy' and signal_price < order_price) or
|
||
(signal_side == 'sell' and signal_price > order_price)
|
||
)
|
||
if not signal_is_better:
|
||
return False, "新挂单价格不优于旧挂单"
|
||
|
||
signal_distance_to_current = abs(signal_price - current_price) / current_price * 100
|
||
if signal_distance_to_current > rule['min_add_price_gap_pct'] * 2.5:
|
||
return False, f"新挂单距现价 {signal_distance_to_current:.1f}% 过远"
|
||
|
||
return True, f"新挂单更优 {order_price:.2f} → {signal_price:.2f}"
|
||
|
||
async def _send_market_signal_notification(self, market_signal: Dict[str, Any],
|
||
current_price: float):
|
||
"""发送市场信号通知(第一阶段)- 调用前已确保有有效信号"""
|
||
try:
|
||
# 获取配置的阈值
|
||
threshold = self.settings.crypto_llm_threshold * 100 # 转换为百分比
|
||
|
||
# 过滤达到阈值的信号(防御性检查)
|
||
signals = market_signal.get('signals', [])
|
||
valid_signals = [s for s in signals if s.get('confidence', 0) >= threshold]
|
||
|
||
if not valid_signals:
|
||
return
|
||
|
||
# 取最佳信号(按信心度排序)
|
||
best_signal = sorted(valid_signals, key=lambda x: x.get('confidence', 0), reverse=True)[0]
|
||
|
||
# 构建通知消息 - 完全匹配旧格式
|
||
symbol = market_signal.get('symbol')
|
||
market_state = market_signal.get('market_state')
|
||
trend = market_signal.get('trend')
|
||
|
||
# 注意:经过 market_signal_analyzer 解析后
|
||
# - 'action' 是 buy/sell/wait (交易方向)
|
||
# - 'timeframe' 是 short_term/medium_term/long_term (周期)
|
||
sig_action = best_signal.get('action', 'hold') # buy/sell/wait
|
||
timeframe = best_signal.get('timeframe', 'unknown') # short_term/medium_term/long_term
|
||
confidence = best_signal.get('confidence', 0)
|
||
entry_val = best_signal.get('entry_price', 'N/A')
|
||
sl_val = best_signal.get('stop_loss', 'N/A')
|
||
tp_val = best_signal.get('take_profit', 'N/A')
|
||
reasoning = best_signal.get('reasoning', '无')
|
||
|
||
# 格式化价格用于显示(处理 float 和 N/A)
|
||
def format_price(price_value):
|
||
if price_value is None or price_value == 'N/A':
|
||
return 'N/A'
|
||
if isinstance(price_value, float):
|
||
return f"{price_value:,.2f}"
|
||
return str(price_value)
|
||
|
||
entry = format_price(entry_val)
|
||
sl = format_price(sl_val)
|
||
tp = format_price(tp_val)
|
||
|
||
# 类型映射
|
||
type_map = {'short_term': '短线', 'medium_term': '中线', 'long_term': '长线'}
|
||
timeframe_text = type_map.get(timeframe, timeframe)
|
||
|
||
action_map = {'buy': '🟢 做多', 'sell': '🔴 做空', 'hold': '➖ 观望'}
|
||
action_text = action_map.get(sig_action, sig_action)
|
||
|
||
# 入场类型 - 从信号中获取
|
||
entry_type = best_signal.get('entry_type', 'market')
|
||
entry_type_text = '现价入场' if entry_type == 'market' else '挂单等待'
|
||
entry_type_icon = '⚡' if entry_type == 'market' else '⏳'
|
||
|
||
# 等级(基于信心度映射)- 与 market_signal_analyzer.py 保持一致
|
||
# A级(80-100): 量价配合 + 多指标共振 + 多周期确认
|
||
# B级(60-79): 量价配合 + 主要指标确认
|
||
# C级(40-59): 有机会但量价不够理想
|
||
# D级(<40): 量价背离或信号矛盾
|
||
if confidence >= 80:
|
||
grade = 'A'
|
||
grade_icon = '⭐⭐⭐'
|
||
elif confidence >= 60:
|
||
grade = 'B'
|
||
grade_icon = '⭐⭐'
|
||
elif confidence >= 40:
|
||
grade = 'C'
|
||
grade_icon = '⭐'
|
||
else:
|
||
grade = 'D'
|
||
grade_icon = ''
|
||
|
||
position_size = best_signal.get('position_size') or self.SIGNAL_POSITION_SIZE_DEFAULTS.get(timeframe, 'light')
|
||
position_icon = {'heavy': '🔥', 'medium': '📊', 'light': '🌱', 'micro': '🌿'}.get(position_size, '🌱')
|
||
position_text = {'heavy': '重仓', 'medium': '中仓', 'light': '轻仓', 'micro': '微仓'}.get(position_size, '轻仓')
|
||
|
||
# 计算止损止盈百分比(价格已经是 float)
|
||
try:
|
||
# 使用当前价格作为入场价(如果 entry_price 是 N/A)
|
||
entry_for_calc = entry_val if isinstance(entry_val, (int, float)) else current_price
|
||
|
||
if isinstance(sl_val, (int, float)) and isinstance(entry_for_calc, (int, float)) and entry_for_calc > 0:
|
||
if sig_action == 'buy':
|
||
sl_percent = ((sl_val - entry_for_calc) / entry_for_calc * 100)
|
||
else:
|
||
sl_percent = ((entry_for_calc - sl_val) / entry_for_calc * 100)
|
||
sl_display = f"{sl_percent:+.1f}%"
|
||
else:
|
||
sl_display = "N/A"
|
||
|
||
if isinstance(tp_val, (int, float)) and isinstance(entry_for_calc, (int, float)) and entry_for_calc > 0:
|
||
if sig_action == 'buy':
|
||
tp_percent = ((tp_val - entry_for_calc) / entry_for_calc * 100)
|
||
else:
|
||
tp_percent = ((entry_for_calc - tp_val) / entry_for_calc * 100)
|
||
tp_display = f"{tp_percent:+.1f}%"
|
||
else:
|
||
tp_display = "N/A"
|
||
except:
|
||
sl_display = "N/A"
|
||
tp_display = "N/A"
|
||
|
||
# 构建卡片标题和颜色 - 添加 [信号] 前缀区分
|
||
if sig_action == 'buy':
|
||
title = f"[信号] 🟢 {symbol} {timeframe_text}做多信号"
|
||
color = "green"
|
||
else:
|
||
title = f"[信号] 🔴 {symbol} {timeframe_text}做空信号"
|
||
color = "red"
|
||
|
||
# 构建卡片内容
|
||
content_parts = [
|
||
f"**{timeframe_text}** | **{grade}**{grade_icon} | **{confidence}%** 置信度",
|
||
f"{entry_type_icon} **入场**: {entry_type_text} | {position_icon} **仓位**: {position_text}",
|
||
f"",
|
||
]
|
||
|
||
# 入场价格显示
|
||
if entry_type == 'limit':
|
||
# 限价订单:显示建议挂单价格和当前价格
|
||
content_parts.append(f"📋 **挂单价格**: ${entry}")
|
||
content_parts.append(f"📍 **当前价格**: ${format_price(current_price)}")
|
||
else:
|
||
# 市价订单:显示当前价格作为入场价
|
||
content_parts.append(f"💰 **入场价**: ${entry}")
|
||
|
||
if sl != 'N/A':
|
||
content_parts.append(f"🛑 **止损价**: ${sl} ({sl_display})")
|
||
if tp != 'N/A':
|
||
content_parts.append(f"🎯 **止盈价**: ${tp} ({tp_display})")
|
||
|
||
content_parts.append(f"")
|
||
content_parts.append(f"📝 **分析理由**:")
|
||
# HTML转义reasoning,避免特殊字符破坏HTML格式
|
||
import html
|
||
escaped_reasoning = html.escape(reasoning) if reasoning else reasoning
|
||
content_parts.append(f"{escaped_reasoning}")
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
# 根据配置发送通知 - [信号] 发送到 crypto webhook
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu.send_card(title, content, color)
|
||
self._analysis_notification_state["last_signal_at"] = datetime.now().isoformat()
|
||
self._analysis_notification_state["last_signal_symbol"] = symbol
|
||
if self.settings.telegram_enabled:
|
||
# Telegram 使用文本格式
|
||
message = f"{title}\n\n{content}"
|
||
await self.telegram.send_message(message)
|
||
if self.settings.dingtalk_enabled:
|
||
# 钉钉使用 ActionCard 格式
|
||
await self.dingtalk.send_action_card(title, content)
|
||
logger.info(f" 📤 已发送市场信号通知 (阈值: {threshold}%)")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"发送市场信号通知失败: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
|
||
async def _send_signal_notification(self, market_signal: Dict[str, Any],
|
||
decision: Dict[str, Any], current_price: float,
|
||
prefix: str = "", hl_order_status: str = None):
|
||
"""发送交易执行通知(第三阶段)
|
||
hl_order_status: Hyperliquid 限价单实际状态 'resting'|'filled'|None
|
||
"""
|
||
try:
|
||
decision_type = decision.get('decision', 'HOLD')
|
||
|
||
# 只在非观望决策时发送执行通知
|
||
if decision_type == 'HOLD':
|
||
return
|
||
|
||
# 构建消息 - 使用旧格式风格
|
||
symbol = market_signal.get('symbol')
|
||
|
||
# 添加前缀到标题
|
||
title_prefix = f"{prefix} " if prefix else ""
|
||
action = decision.get('action', '')
|
||
reasoning = decision.get('reasoning', '')
|
||
risk_analysis = decision.get('risk_analysis', '')
|
||
position_size = decision.get('position_size', 'N/A')
|
||
quantity = decision.get('quantity', 'N/A')
|
||
stop_loss = decision.get('stop_loss', '')
|
||
take_profit = decision.get('take_profit', '')
|
||
# confidence 优先从决策本身读取,否则从市场信号的最佳信号读取
|
||
confidence = decision.get('confidence')
|
||
if confidence is None:
|
||
_best = self._get_signal_for_decision(market_signal, decision)
|
||
confidence = _best.get('confidence', 0) if _best else 0
|
||
|
||
# 决策类型映射
|
||
decision_map = {
|
||
'OPEN': '开仓',
|
||
'CLOSE': '平仓',
|
||
'ADD': '加仓',
|
||
'REDUCE': '减仓'
|
||
}
|
||
decision_text = decision_map.get(decision_type, decision_type)
|
||
|
||
# 账户类型标识
|
||
account_type = "📊"
|
||
|
||
# 方向图标
|
||
if 'long' in action.lower() or 'buy' in action.lower():
|
||
action_icon = '🟢'
|
||
action_text = '做多'
|
||
elif 'short' in action.lower() or 'sell' in action.lower():
|
||
action_icon = '🔴'
|
||
action_text = '做空'
|
||
else:
|
||
action_icon = '➖'
|
||
action_text = action
|
||
|
||
# 从市场信号中获取入场方式(需要在构建标题之前)
|
||
best_signal = self._get_signal_for_decision(market_signal, decision)
|
||
entry_type = best_signal.get('entry_type', 'market') if best_signal else 'market'
|
||
signal_timeframe = best_signal.get('timeframe', best_signal.get('type', 'unknown')) if best_signal else 'unknown'
|
||
timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
|
||
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe)
|
||
|
||
# 对 Hyperliquid 限价单:用实际订单状态决定显示
|
||
# resting=真的在挂单中, filled=已立即成交, None=非HL或市价单
|
||
if hl_order_status == 'resting':
|
||
entry_type_text = '挂单'
|
||
entry_type_icon = '⏳'
|
||
elif hl_order_status == 'filled':
|
||
entry_type_text = '现价成交'
|
||
entry_type_icon = '⚡'
|
||
else:
|
||
entry_type_text = '现价单' if entry_type == 'market' else '挂单'
|
||
entry_type_icon = '⚡' if entry_type == 'market' else '⏳'
|
||
|
||
# 仓位图标
|
||
position_map = {'heavy': '🔥 重仓', 'medium': '📊 中仓', 'light': '🌱 轻仓'}
|
||
position_display = position_map.get(position_size, '🌱 轻仓')
|
||
|
||
# 构建卡片标题:Hyperliquid 限价单区分实际状态
|
||
if decision_type == 'OPEN':
|
||
if hl_order_status == 'resting':
|
||
decision_title = '挂单中'
|
||
elif hl_order_status == 'filled':
|
||
decision_title = '开仓(立即成交)'
|
||
else:
|
||
decision_title = '挂单' if entry_type == 'limit' else '开仓'
|
||
title = f"{title_prefix}[执行] {account_type} {symbol} {decision_title}"
|
||
color = "green"
|
||
elif decision_type == 'CLOSE':
|
||
decision_title = '挂单' if entry_type == 'limit' else '平仓'
|
||
title = f"{title_prefix}[执行] {account_type} {symbol} {decision_title}"
|
||
color = "orange"
|
||
elif decision_type == 'ADD':
|
||
if hl_order_status == 'resting':
|
||
decision_title = '加仓挂单中'
|
||
elif hl_order_status == 'filled':
|
||
decision_title = '加仓(立即成交)'
|
||
else:
|
||
decision_title = '挂单' if entry_type == 'limit' else '加仓'
|
||
title = f"{title_prefix}[执行] {account_type} {symbol} {decision_title}"
|
||
color = "green"
|
||
elif decision_type == 'REDUCE':
|
||
decision_title = '挂单' if entry_type == 'limit' else '减仓'
|
||
title = f"{title_prefix}[执行] {account_type} {symbol} {decision_title}"
|
||
color = "orange"
|
||
else:
|
||
title = f"{title_prefix}[执行] {account_type} {symbol} 交易执行"
|
||
color = "blue"
|
||
|
||
# 构建卡片内容
|
||
# quantity 是保证金金额,需要显示持仓价值 = 保证金 × 杠杆
|
||
margin = quantity if quantity != 'N/A' else 0
|
||
leverage = self.paper_trading.leverage # 使用实际的杠杆配置(而非硬编码 20)
|
||
position_value = margin * leverage if isinstance(margin, (int, float)) else 'N/A'
|
||
position_value_display = f"${position_value:,.2f}" if isinstance(position_value, (int, float)) else "N/A"
|
||
|
||
# 根据入场方式显示不同的价格信息
|
||
if entry_type == 'market':
|
||
price_display = f"💵 **入场价**: ${current_price:,.2f} (现价)"
|
||
else:
|
||
entry_price = best_signal.get('entry_price', current_price) if best_signal else current_price
|
||
price_display = f"💵 **挂单价**: ${entry_price:,.2f} (等待)"
|
||
|
||
content_parts = [
|
||
f"{action_icon} **操作**: {decision_text} ({action_text})",
|
||
f"🧭 **信号类型**: {timeframe_text}",
|
||
f"{entry_type_icon} **入场方式**: {entry_type_text}",
|
||
f"{position_display.replace(' ', ': **')} | 📈 信心度: **{confidence}%**",
|
||
f"",
|
||
f"💰 **持仓价值**: {position_value_display}",
|
||
price_display,
|
||
]
|
||
|
||
if stop_loss:
|
||
content_parts.append(f"🛑 **止损价**: ${stop_loss}")
|
||
if take_profit:
|
||
content_parts.append(f"🎯 **止盈价**: ${take_profit}")
|
||
|
||
# 决策理由和风险分析(挂单中不显示,等成交后再显示)
|
||
is_pending = (hl_order_status == 'resting') or \
|
||
(entry_type == 'limit' and decision_type in ['OPEN', 'ADD'])
|
||
if not is_pending:
|
||
content_parts.append(f"")
|
||
content_parts.append(f"📝 **决策**: {reasoning}")
|
||
|
||
if risk_analysis:
|
||
content_parts.append(f"⚠️ **风险**: {risk_analysis}")
|
||
|
||
content_parts.append(f"")
|
||
if is_pending:
|
||
content_parts.append(f"⏳ 等待成交")
|
||
else:
|
||
content_parts.append(f"💼 交易已执行")
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
# 根据配置发送通知 - 所有订单执行都发送到 paper trading webhook
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu_paper.send_card(title, content, color)
|
||
if self.settings.telegram_enabled:
|
||
# Telegram 使用文本格式
|
||
message = f"{title}\n\n{content}"
|
||
await self.telegram.send_message(message)
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
logger.info(f" 📤 已发送交易执行通知: {decision_text}")
|
||
|
||
except Exception as e:
|
||
logger.warning(f"发送交易执行通知失败: {e}")
|
||
|
||
async def _notify_execution_failure(self, market_signal: Dict[str, Any],
|
||
decision: Dict[str, Any], reason: str,
|
||
prefix: str = ""):
|
||
"""发送执行失败通知(决策给出了 OPEN/ADD 但实际未能开仓)"""
|
||
try:
|
||
symbol = market_signal.get('symbol', '')
|
||
decision_type = decision.get('decision', 'OPEN')
|
||
action = decision.get('action', '')
|
||
title_prefix = f"{prefix} " if prefix else ""
|
||
|
||
action_text = "做多" if 'buy' in action.lower() else ("做空" if 'sell' in action.lower() else action)
|
||
decision_text = {'OPEN': '开仓', 'ADD': '加仓'}.get(decision_type, decision_type)
|
||
best_signal = self._get_signal_for_decision(market_signal, decision)
|
||
signal_timeframe = best_signal.get('timeframe', best_signal.get('type', 'unknown')) if best_signal else 'unknown'
|
||
timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
|
||
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe)
|
||
|
||
title = f"{title_prefix}⚠️ {symbol} {decision_text}未执行"
|
||
content = "\n".join([
|
||
f"🔴 **决策**: {decision_text}({action_text})",
|
||
f"🧭 **信号类型**: {timeframe_text}",
|
||
f"❌ **未执行原因**: {reason}",
|
||
f"🕐 **时间**: {datetime.now().strftime('%H:%M:%S')}",
|
||
])
|
||
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu_paper.send_card(title, content, "red")
|
||
if self.settings.telegram_enabled:
|
||
await self.telegram.send_message(f"{title}\n\n{content}")
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
|
||
logger.info(f" 📤 已发送执行失败通知: {reason}")
|
||
except Exception as e:
|
||
logger.warning(f"发送执行失败通知失败: {e}")
|
||
|
||
async def _execute_paper_trade(self, decision: Dict[str, Any], market_signal: Dict[str, Any], current_price: float):
|
||
"""执行模拟交易"""
|
||
try:
|
||
symbol = decision.get('symbol')
|
||
action = decision.get('signal_action') or decision.get('action', '')
|
||
position_size = decision.get('position_size', 'light')
|
||
raw_signal_type = decision.get('timeframe') or decision.get('type') or 'medium_term'
|
||
|
||
quantity = decision.get('margin', decision.get('quantity', 0))
|
||
if quantity and quantity > 0:
|
||
margin = float(quantity)
|
||
position_value = round(margin * self.paper_trading.leverage, 2)
|
||
logger.info(f" 使用统一决策保证金: ${margin:.2f}")
|
||
else:
|
||
logger.info(f" 回退计算动态仓位: {position_size}")
|
||
margin, position_value = self.paper_trading._calculate_dynamic_position(
|
||
position_size=position_size,
|
||
symbol=symbol,
|
||
signal_type=raw_signal_type,
|
||
confidence=decision.get('confidence'),
|
||
grade=decision.get('grade'),
|
||
)
|
||
|
||
if margin <= 0:
|
||
logger.warning(f" ⚠️ 计算的保证金无效: {margin},无法开仓")
|
||
return False
|
||
|
||
quantity = margin # 保证金金额
|
||
|
||
logger.info(f" 准备创建订单: {symbol} {action} {position_size}")
|
||
logger.info(f" 保证金: ${quantity:.2f} | 持仓价值: ${position_value:.2f}")
|
||
|
||
# 转换决策的 action 为 paper_trading 期望的格式
|
||
trading_action = self._convert_trading_action(action)
|
||
|
||
# 兼容旧入口,但信号选择仍按当前决策对应的 lane 匹配
|
||
matched_signal = self._get_signal_for_decision(market_signal, decision)
|
||
entry_type = matched_signal.get('entry_type', 'market') if matched_signal else 'market'
|
||
entry_price = matched_signal.get('entry_price', current_price) if matched_signal else current_price
|
||
|
||
logger.info(f" 入场方式: {entry_type} | 入场价格: ${entry_price:,.2f}")
|
||
|
||
# 转换决策为订单格式(价格字段已在 LLM 解析时转换为 float)
|
||
order_data = {
|
||
'symbol': symbol,
|
||
'action': trading_action, # 使用转换后的 action
|
||
'entry_type': entry_type, # 使用信号中的入场方式
|
||
'entry_price': entry_price if entry_type == 'limit' else current_price, # limit单使用entry_price,market单使用current_price
|
||
'stop_loss': decision.get('stop_loss'),
|
||
'take_profit': decision.get('take_profit'),
|
||
'confidence': decision.get('confidence', 50),
|
||
'signal_grade': decision.get('grade', 'B'),
|
||
'position_size': position_size,
|
||
'signal_type': raw_signal_type,
|
||
'type': raw_signal_type,
|
||
'quantity': quantity # 使用计算后的保证金金额
|
||
}
|
||
|
||
logger.debug(f" 订单数据: {order_data}")
|
||
|
||
logger.info(f" 正在调用 create_order_from_signal...")
|
||
result = self.paper_trading.create_order_from_signal(order_data, current_price)
|
||
|
||
logger.info(f" create_order_from_signal 返回结果: {result}")
|
||
|
||
# 记录订单
|
||
order = result.get('order')
|
||
if order:
|
||
# quantity 是保证金金额,持仓价值 = 保证金 × 杠杆
|
||
leverage = self.paper_trading.leverage # 使用实际的杠杆配置
|
||
position_value = quantity * leverage
|
||
logger.info(f" ✅ 已创建订单: {order.order_id} | 仓位: {position_size} | 持仓价值: ${position_value:.2f}")
|
||
logger.info(f" 订单状态: {order.status.value} | 入场价: ${order.entry_price:,.2f}")
|
||
else:
|
||
# 订单创建失败,记录详细原因
|
||
reason = result.get('message', '未知原因')
|
||
cancelled_info = result.get('cancelled_orders', [])
|
||
logger.warning(f" ❌ 创建订单失败: {reason}")
|
||
if cancelled_info:
|
||
logger.warning(f" 已取消的反向订单: {len(cancelled_info)} 个")
|
||
|
||
# 返回结果
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行交易失败: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
|
||
def _convert_trading_action(self, action: str) -> str:
|
||
"""转换交易决策的 action 为 buy/sell 格式"""
|
||
if not action:
|
||
return 'buy'
|
||
|
||
action_lower = action.lower()
|
||
|
||
# 做多相关的 action
|
||
if 'long' in action_lower or 'buy' in action_lower:
|
||
return 'buy'
|
||
# 做空相关的 action
|
||
elif 'short' in action_lower or 'sell' in action_lower:
|
||
return 'sell'
|
||
|
||
# 默认返回 buy
|
||
return 'buy'
|
||
|
||
def _calculate_quantity_by_position_size(self, position_size: str, live_trading: bool = False) -> float:
|
||
"""根据仓位大小计算实际金额"""
|
||
if live_trading:
|
||
# 实盘交易配置
|
||
position_config = {
|
||
'heavy': self.settings.bitget_max_single_position,
|
||
'medium': self.settings.bitget_max_single_position * 0.6,
|
||
'light': self.settings.bitget_max_single_position * 0.3
|
||
}
|
||
else:
|
||
# 模拟交易配置
|
||
position_config = {
|
||
'heavy': self.settings.paper_trading_position_a, # 1000
|
||
'medium': self.settings.paper_trading_position_b, # 500
|
||
'light': self.settings.paper_trading_position_c # 200
|
||
}
|
||
|
||
return position_config.get(position_size, 200)
|
||
|
||
async def _execute_close(self, decision: Dict[str, Any], current_price: float) -> bool:
|
||
"""执行平仓
|
||
|
||
Args:
|
||
decision: 交易决策(应包含 orders_to_close 字段)
|
||
current_price: 当前价格
|
||
|
||
Returns:
|
||
是否成功执行平仓
|
||
"""
|
||
try:
|
||
symbol = decision.get('symbol')
|
||
orders_to_close = decision.get('orders_to_close', [])
|
||
|
||
if self.paper_trading:
|
||
logger.info(f" 🔒 平仓: {symbol}")
|
||
logger.info(f" 理由: {decision.get('reasoning', '')}")
|
||
|
||
# 如果决策中没有指定订单ID,则获取该交易对的所有活跃订单
|
||
if not orders_to_close:
|
||
logger.warning(f" ⚠️ 决策中未指定 orders_to_close,将平仓 {symbol} 的所有持仓")
|
||
active_orders = self.paper_trading.get_active_orders(symbol)
|
||
orders_to_close = [o.get('order_id') for o in active_orders if o.get('status') in ('OPEN', 'FILLED', 'PENDING')]
|
||
|
||
if not orders_to_close:
|
||
logger.warning(f" 没有找到需要平仓的订单")
|
||
return False
|
||
|
||
logger.info(f" 待平仓订单: {orders_to_close}")
|
||
|
||
closed_count = 0
|
||
for order_id in orders_to_close:
|
||
try:
|
||
# 先获取订单信息
|
||
order_info = self.paper_trading.get_order_by_id(order_id)
|
||
if not order_info:
|
||
logger.warning(f" ❌ 订单不存在: {order_id}")
|
||
continue
|
||
|
||
status = order_info.get('status')
|
||
|
||
if status == 'PENDING':
|
||
# 取消挂单
|
||
result = self.paper_trading.cancel_order(order_id)
|
||
if result.get('success'):
|
||
logger.info(f" ✅ 已取消挂单: {order_id}")
|
||
closed_count += 1
|
||
else:
|
||
logger.warning(f" ❌ 取消挂单失败: {order_id} - {result.get('message')}")
|
||
elif status in ('OPEN', 'FILLED'):
|
||
# 平仓已成交订单
|
||
result = self.paper_trading.close_order_manual(order_id, current_price)
|
||
if result:
|
||
logger.info(f" ✅ 已平仓: {order_id} @ ${current_price}")
|
||
closed_count += 1
|
||
else:
|
||
logger.warning(f" ❌ 平仓失败: {order_id}")
|
||
else:
|
||
logger.warning(f" ⚠️ 订单状态无需处理: {order_id} - {status}")
|
||
except Exception as e:
|
||
logger.error(f" ❌ 处理订单 {order_id} 失败: {e}")
|
||
|
||
logger.info(f" 📊 平仓汇总: {closed_count}/{len(orders_to_close)} 个订单已处理")
|
||
return closed_count > 0
|
||
else:
|
||
logger.warning(f" 交易服务未初始化")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行平仓失败: {e}")
|
||
import traceback
|
||
logger.error(traceback.format_exc())
|
||
return False
|
||
|
||
async def _execute_cancel_pending(self, decision: Dict[str, Any]) -> bool:
|
||
"""执行取消挂单
|
||
|
||
Args:
|
||
decision: 交易决策
|
||
|
||
Returns:
|
||
是否成功取消订单
|
||
"""
|
||
try:
|
||
symbol = decision.get('symbol')
|
||
decision_action = decision.get('action', '') # buy/sell
|
||
orders_to_cancel = decision.get('orders_to_cancel', [])
|
||
|
||
if not orders_to_cancel:
|
||
logger.info(f" ⚠️ 没有需要取消的订单")
|
||
return False
|
||
|
||
trading_service = self.paper_trading
|
||
|
||
if not trading_service:
|
||
logger.warning(f" 交易服务未启用")
|
||
return False
|
||
|
||
# 安全检查:验证要取消的订单是否属于当前symbol且方向相反
|
||
active_orders = trading_service.get_active_orders()
|
||
valid_orders = []
|
||
invalid_orders = []
|
||
wrong_direction_orders = []
|
||
|
||
for order_id in orders_to_cancel:
|
||
# 查找订单
|
||
order = next((o for o in active_orders if o.get('order_id') == order_id), None)
|
||
if not order:
|
||
logger.warning(f" ⚠️ 订单不存在: {order_id}")
|
||
invalid_orders.append(order_id)
|
||
continue
|
||
|
||
# 检查订单是否属于当前symbol
|
||
if order.get('symbol') != symbol:
|
||
logger.error(f" ❌ 安全拦截:订单 {order_id} 属于 {order.get('symbol')},不是当前分析标的 {symbol}")
|
||
invalid_orders.append(order_id)
|
||
continue
|
||
|
||
# 检查订单方向是否与决策相反
|
||
order_side = order.get('side') # long/short
|
||
# 决策是buy时应该取消short(做空),决策是sell时应该取消long(做多)
|
||
should_cancel = False
|
||
if decision_action == 'buy' and order_side == 'short':
|
||
should_cancel = True
|
||
elif decision_action == 'sell' and order_side == 'long':
|
||
should_cancel = True
|
||
elif decision_action in ['open_long', 'close_short']:
|
||
should_cancel = (order_side == 'short')
|
||
elif decision_action in ['open_short', 'close_long']:
|
||
should_cancel = (order_side == 'long')
|
||
|
||
if not should_cancel:
|
||
logger.error(f" ❌ 安全拦截:订单 {order_id} 方向为 {order_side},与决策 {decision_action} 同向,不应取消!")
|
||
wrong_direction_orders.append(order_id)
|
||
invalid_orders.append(order_id)
|
||
continue
|
||
|
||
valid_orders.append(order_id)
|
||
|
||
if invalid_orders:
|
||
logger.error(f" 🚫 拒绝取消 {len(invalid_orders)} 个不符合条件的订单")
|
||
|
||
if wrong_direction_orders:
|
||
logger.error(f" ⚠️ {len(wrong_direction_orders)} 个同向订单被拦截(不应取消同向订单)")
|
||
|
||
if not valid_orders:
|
||
logger.warning(f" ⚠️ 没有有效的订单可以取消")
|
||
return False
|
||
|
||
logger.info(f" 🚫 取消挂单: {symbol}")
|
||
logger.info(f" 取消订单数量: {len(valid_orders)}")
|
||
|
||
cancelled_count = 0
|
||
for order_id in valid_orders:
|
||
try:
|
||
# 取消订单
|
||
result = trading_service.cancel_order(order_id)
|
||
if result.get('success'):
|
||
cancelled_count += 1
|
||
logger.info(f" ✅ 已取消订单: {order_id}")
|
||
else:
|
||
logger.warning(f" ⚠️ 取消订单失败: {order_id} | {result.get('message', '')}")
|
||
except Exception as e:
|
||
logger.error(f" ❌ 取消订单异常: {order_id} | {e}")
|
||
|
||
logger.info(f" 📊 成功取消 {cancelled_count}/{len(valid_orders)} 个订单")
|
||
return cancelled_count > 0
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行取消挂单失败: {e}")
|
||
return False
|
||
|
||
async def _execute_update_pending(self, decision: Dict[str, Any]) -> bool:
|
||
"""执行更新挂单参数
|
||
|
||
Args:
|
||
decision: 交易决策
|
||
|
||
Returns:
|
||
是否成功更新订单
|
||
"""
|
||
try:
|
||
symbol = decision.get('symbol')
|
||
decision_action = decision.get('action', '') # buy/sell
|
||
orders_to_update = decision.get('orders_to_update', [])
|
||
|
||
# 获取新参数
|
||
new_entry_price = decision.get('entry_price')
|
||
new_stop_loss = decision.get('stop_loss')
|
||
new_take_profit = decision.get('take_profit')
|
||
|
||
if not orders_to_update:
|
||
logger.info(f" ⚠️ 没有需要更新的订单")
|
||
return False
|
||
|
||
if not all([new_entry_price, new_stop_loss, new_take_profit]):
|
||
logger.warning(f" ⚠️ 更新参数不完整: entry_price={new_entry_price}, stop_loss={new_stop_loss}, take_profit={new_take_profit}")
|
||
return False
|
||
|
||
trading_service = self.paper_trading
|
||
|
||
if not trading_service:
|
||
logger.warning(f" 交易服务未启用")
|
||
return False
|
||
|
||
# 安全检查:验证要更新的订单是否属于当前symbol且方向相同
|
||
active_orders = trading_service.get_active_orders()
|
||
valid_orders = []
|
||
invalid_orders = []
|
||
wrong_direction_orders = []
|
||
|
||
for order_id in orders_to_update:
|
||
# 查找订单
|
||
order = next((o for o in active_orders if o.order_id == order_id), None)
|
||
if not order:
|
||
logger.warning(f" ⚠️ 订单不存在: {order_id}")
|
||
invalid_orders.append(order_id)
|
||
continue
|
||
|
||
# 检查订单是否已成交(只能更新未成交的挂单)
|
||
if order.filled_price and order.filled_price > 0:
|
||
logger.warning(f" ⚠️ 订单已成交,无法更新: {order_id}")
|
||
invalid_orders.append(order_id)
|
||
continue
|
||
|
||
# 检查订单是否属于当前symbol
|
||
if order.symbol != symbol:
|
||
logger.error(f" ❌ 安全拦截:订单 {order_id} 属于 {order.symbol},不是当前分析标的 {symbol}")
|
||
invalid_orders.append(order_id)
|
||
continue
|
||
|
||
# 检查订单方向是否与决策相同
|
||
order_side = order.side.value # LONG/SHORT
|
||
# 决策是buy时应该更新LONG(做多),决策是sell时应该更新SHORT(做空)
|
||
should_update = False
|
||
if decision_action == 'buy' and order_side == 'LONG':
|
||
should_update = True
|
||
elif decision_action == 'sell' and order_side == 'SHORT':
|
||
should_update = True
|
||
|
||
if not should_update:
|
||
logger.error(f" ❌ 安全拦截:订单 {order_id} 方向为 {order_side},与决策 {decision_action} 方向不一致")
|
||
wrong_direction_orders.append(order_id)
|
||
invalid_orders.append(order_id)
|
||
continue
|
||
|
||
valid_orders.append(order)
|
||
|
||
if invalid_orders:
|
||
logger.error(f" 🚫 拒绝更新 {len(invalid_orders)} 个不符合条件的订单")
|
||
|
||
if wrong_direction_orders:
|
||
logger.error(f" ⚠️ {len(wrong_direction_orders)} 个方向不一致的订单被拦截")
|
||
|
||
if not valid_orders:
|
||
logger.warning(f" ⚠️ 没有有效的订单可以更新")
|
||
return False
|
||
|
||
logger.info(f" 🔄 更新挂单: {symbol}")
|
||
logger.info(f" 新参数: 入场=${new_entry_price:,.2f}, 止损=${new_stop_loss:,.2f}, 止盈=${new_take_profit:,.2f}")
|
||
|
||
updated_count = 0
|
||
for order in valid_orders:
|
||
try:
|
||
# 更新订单参数
|
||
result = trading_service.update_order(
|
||
order.order_id,
|
||
entry_price=new_entry_price,
|
||
stop_loss=new_stop_loss,
|
||
take_profit=new_take_profit
|
||
)
|
||
|
||
if result and result.get('success'):
|
||
updated_count += 1
|
||
logger.info(f" ✅ 订单更新成功: {order.order_id}")
|
||
logger.info(f" 旧参数: 入场=${order.entry_price:,.2f}, 止损=${order.stop_loss:,.2f}, 止盈=${order.take_profit:,.2f}")
|
||
else:
|
||
logger.warning(f" ⚠️ 更新订单失败: {order.order_id} | {result.get('message', '')}")
|
||
except Exception as e:
|
||
logger.error(f" ❌ 更新订单异常: {order.order_id} | {e}")
|
||
|
||
logger.info(f" 📊 成功更新 {updated_count}/{len(valid_orders)} 个订单")
|
||
return updated_count > 0
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行更新挂单失败: {e}")
|
||
return False
|
||
|
||
async def _execute_reduce(self, decision: Dict[str, Any]) -> bool:
|
||
"""执行减仓
|
||
|
||
Args:
|
||
decision: 交易决策
|
||
|
||
Returns:
|
||
是否成功执行减仓
|
||
"""
|
||
try:
|
||
symbol = decision.get('symbol')
|
||
|
||
logger.info(f" 📤 减仓: {symbol}")
|
||
logger.info(f" 理由: {decision.get('reasoning', '')}")
|
||
|
||
trading_service = self.paper_trading
|
||
|
||
if not trading_service:
|
||
logger.warning(f" 交易服务未初始化")
|
||
return False
|
||
|
||
# TODO: 实现减仓逻辑
|
||
# 减仓可以是部分平仓,需要根据决策中的参数执行
|
||
logger.info(f" ⚠️ 减仓功能待实现")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"执行减仓失败: {e}")
|
||
return False
|
||
|
||
# ============================================================
|
||
# Hyperliquid 执行方法
|
||
# ============================================================
|
||
|
||
async def _notify_hyperliquid_error(self, symbol: str, operation: str, error: str):
|
||
"""发送 Hyperliquid 操作失败的飞书/钉钉/Telegram 通知"""
|
||
title = f"❌ Hyperliquid 操作失败 - {symbol}"
|
||
content = "\n".join([
|
||
f"🔴 **操作**: {operation}",
|
||
f"⚠️ **错误**: {error}",
|
||
f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||
])
|
||
logger.error(f"[Hyperliquid] {operation} 失败 | {symbol} | {error}")
|
||
if self.settings.feishu_enabled and self.feishu_error:
|
||
await self.feishu_error.send_card(title, content, "red")
|
||
if self.settings.telegram_enabled:
|
||
await self.telegram.send_message(f"{title}\n\n{content}")
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
|
||
async def _notify_bitget_error(self, symbol: str, operation: str, error: str):
|
||
"""发送 Bitget 操作失败的飞书/钉钉/Telegram 通知"""
|
||
title = f"❌ Bitget 操作失败 - {symbol}"
|
||
content = "\n".join([
|
||
f"🔴 **操作**: {operation}",
|
||
f"⚠️ **错误**: {error}",
|
||
f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||
])
|
||
logger.error(f"[Bitget] {operation} 失败 | {symbol} | {error}")
|
||
if self.settings.feishu_enabled and self.feishu_error:
|
||
await self.feishu_error.send_card(title, content, "red")
|
||
if self.settings.telegram_enabled:
|
||
await self.telegram.send_message(f"{title}\n\n{content}")
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
|
||
def _get_bitget_trading_state(self) -> tuple:
|
||
"""
|
||
获取 Bitget 实盘交易状态(持仓和账户)
|
||
|
||
Returns:
|
||
(positions, account, pending_orders)
|
||
"""
|
||
# 1. 余额(独立 try,确保余额始终可用)
|
||
try:
|
||
bg_state = self.bitget.get_account_state()
|
||
except Exception as e:
|
||
logger.error(f"获取 Bitget 余额失败: {e}")
|
||
bg_state = {"account_value": 0, "total_margin_used": 0, "available_balance": 0}
|
||
|
||
logger.info(
|
||
f"[Bitget] 余额: account_value=${bg_state['account_value']:.2f}, "
|
||
f"available=${bg_state['available_balance']:.2f}"
|
||
)
|
||
|
||
# 2. 持仓(独立 try)
|
||
position_list = []
|
||
try:
|
||
for pos in self.bitget.get_open_positions():
|
||
coin = pos["coin"]
|
||
size = pos["size"]
|
||
if size != 0:
|
||
tp_sl = {}
|
||
try:
|
||
tp_sl = self.bitget.get_tp_sl_prices(coin)
|
||
except Exception as e:
|
||
logger.warning(f"获取 {coin} TP/SL 失败(不影响交易): {e}")
|
||
raw_position = pos.get("position", {}) if isinstance(pos.get("position"), dict) else {}
|
||
mark_price = float(
|
||
pos.get("mark_price")
|
||
or raw_position.get("markPrice")
|
||
or raw_position.get("mark_price")
|
||
or 0
|
||
)
|
||
position = {
|
||
'symbol': f"{coin}USDT",
|
||
'side': 'buy' if size > 0 else 'sell',
|
||
'holding': abs(size),
|
||
'entry_price': pos["entry_price"],
|
||
'mark_price': mark_price,
|
||
'unrealized_pnl': pos["unrealized_pnl"],
|
||
'stop_loss': tp_sl.get('stop_loss'),
|
||
'take_profit': tp_sl.get('take_profit'),
|
||
'opened_at': pos.get('opened_at'),
|
||
}
|
||
position_list.append(self._build_runtime_position_state(position))
|
||
except Exception as e:
|
||
logger.error(f"获取 Bitget 持仓失败: {e}")
|
||
|
||
# 3. 构建 account 字典
|
||
total_position_value = sum(
|
||
p['holding'] * p['entry_price'] for p in position_list
|
||
)
|
||
account = {
|
||
'current_balance': bg_state["account_value"],
|
||
'initial_balance': self.bitget.initial_balance,
|
||
'used_margin': bg_state["total_margin_used"],
|
||
'available_balance': bg_state["available_balance"],
|
||
'available': bg_state["available_balance"], # 决策器期望的键名
|
||
'order_leverage': 10,
|
||
'total_position_value': total_position_value,
|
||
'max_total_leverage': self.bitget.max_total_leverage,
|
||
}
|
||
if account['current_balance'] > 0:
|
||
account['current_total_leverage'] = total_position_value / account['current_balance']
|
||
else:
|
||
account['current_total_leverage'] = 0
|
||
|
||
# 4. 挂单(独立 try)
|
||
pending_orders = []
|
||
try:
|
||
all_orders = self.bitget.get_open_orders()
|
||
for order in all_orders:
|
||
pending_orders.append({
|
||
'order_id': order.get('order_id'),
|
||
'symbol': f"{order['symbol']}USDT",
|
||
'side': order.get('side', ''),
|
||
'entry_price': order.get('price'),
|
||
'quantity': order.get('size'),
|
||
'entry_type': 'limit',
|
||
'is_reduce_only': order.get('is_reduce_only', False),
|
||
'created_at': order.get('created_at'),
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"获取 Bitget 挂单失败: {e}")
|
||
|
||
return position_list, account, pending_orders
|
||
|
||
def _calculate_position_size(self, signal: Dict[str, Any],
|
||
account: Dict[str, Any],
|
||
platform_name: str) -> tuple:
|
||
"""
|
||
根据统一的权益百分比模型计算仓位大小
|
||
|
||
Returns:
|
||
(margin, reason) - 保证金金额和原因
|
||
"""
|
||
signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term'
|
||
confidence = signal.get('confidence', 50)
|
||
grade = signal.get('grade')
|
||
position_size = signal.get('position_size')
|
||
|
||
# 可用保证金
|
||
available = account.get('available', account.get('available_balance', 0))
|
||
balance = account.get('current_balance', 0)
|
||
|
||
logger.info(f" 仓位计算: available=${available}, balance={balance}, account_keys={list(account.keys())}")
|
||
|
||
if balance <= 0:
|
||
logger.warning(f" ❌ 账户权益无效: available={available}, balance={balance}")
|
||
return 0, "账户权益无效"
|
||
if available <= 0:
|
||
logger.warning(f" ❌ 可用保证金无效: available={available}, balance={balance}")
|
||
return 0, "可用保证金不足或账户可用余额读取失败"
|
||
|
||
# 应用平台规则
|
||
rules = self.PLATFORM_RULES.get(platform_name, {})
|
||
min_margin_rules = rules.get('min_margin', {})
|
||
max_margin_pct = rules.get('max_margin_pct', 0.1)
|
||
|
||
symbol = signal.get('symbol', '').replace('USDT', '').upper()
|
||
min_margin = min_margin_rules.get(symbol, 0)
|
||
current_leverage = account.get('current_total_leverage', 0)
|
||
max_leverage = account.get('max_total_leverage', 10)
|
||
order_leverage = account.get('order_leverage', 10)
|
||
min_effective_leverage = self.SIGNAL_MIN_EFFECTIVE_LEVERAGE.get(signal_type, 2.0)
|
||
|
||
target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct(
|
||
position_size=position_size,
|
||
signal_type=signal_type,
|
||
confidence=confidence,
|
||
grade=grade,
|
||
timeframe_multipliers=self.SIGNAL_MARGIN_MULTIPLIERS,
|
||
default_positions=self.SIGNAL_POSITION_SIZE_DEFAULTS,
|
||
)
|
||
|
||
# 市场状态仓位调整:震荡市降低仓位,避免来回被止损
|
||
regime = signal.get('regime', '')
|
||
REGIME_MARGIN_MULTIPLIERS = {
|
||
'ranging': 0.5, # 震荡市:仓位减半
|
||
'transitional': 0.7, # 过渡期:仓位七折
|
||
'weak_trend': 0.9, # 弱趋势:仓位九折
|
||
'strong_trend': 1.0, # 强趋势:满仓
|
||
}
|
||
regime_multiplier = REGIME_MARGIN_MULTIPLIERS.get(regime, 1.0)
|
||
if regime_multiplier < 1.0:
|
||
logger.info(f" 📊 市场状态调整: regime={regime}, 仓位系数={regime_multiplier}")
|
||
target_margin_pct *= regime_multiplier
|
||
|
||
# 连败降温:连败时降低仓位
|
||
streak_multiplier = signal.get('_streak_margin_multiplier', 1.0)
|
||
if streak_multiplier < 1.0:
|
||
logger.info(f" 📊 连败降温: 仓位系数={streak_multiplier}")
|
||
target_margin_pct *= streak_multiplier
|
||
|
||
margin, _, budget_reason = calculate_margin_and_position_value(
|
||
balance=balance,
|
||
available_margin=available,
|
||
current_total_leverage=current_leverage,
|
||
max_total_leverage=max_leverage,
|
||
order_leverage=order_leverage,
|
||
target_margin_pct=target_margin_pct,
|
||
max_margin_pct=max_margin_pct,
|
||
min_margin=min_margin,
|
||
min_effective_leverage=min_effective_leverage,
|
||
)
|
||
|
||
if margin <= 0:
|
||
return 0, budget_reason
|
||
|
||
return margin, (
|
||
f"{sizing_reason} | 平台: {platform_name} | "
|
||
f"最小有效杠杆 {min_effective_leverage:.1f}x | "
|
||
f"限制后保证金 ${margin:.2f} ({budget_reason})"
|
||
)
|
||
|
||
def _handle_same_direction(self, signal: Dict[str, Any],
|
||
positions: List[Dict],
|
||
pending_orders: List[Dict]) -> tuple:
|
||
"""
|
||
处理同向订单(持仓和挂单)
|
||
|
||
Returns:
|
||
(action, reason) - 动作和原因
|
||
"""
|
||
symbol = signal.get('symbol')
|
||
signal_side = signal.get('action')
|
||
signal_price = signal.get('entry_price', 0)
|
||
entry_type = signal.get('entry_type', 'market')
|
||
rule = self._get_signal_execution_rule(signal)
|
||
|
||
# 检查同向持仓
|
||
same_positions = [p for p in positions
|
||
if p.get('symbol') == symbol and p.get('side') == signal_side]
|
||
|
||
if same_positions:
|
||
pos = same_positions[0]
|
||
pos_entry = pos.get('entry_price', 0)
|
||
price_diff_pct = abs(signal_price - pos_entry) / pos_entry * 100 if pos_entry > 0 else 0
|
||
pnl_pct = self._resolve_position_pnl_pct(pos, signal)
|
||
remaining_tp_pct = self._remaining_target_distance_pct(signal, pos)
|
||
better_price = (signal_side == 'buy' and signal_price < pos_entry) or \
|
||
(signal_side == 'sell' and signal_price > pos_entry)
|
||
position_protected = self._is_position_protected(pos)
|
||
|
||
if remaining_tp_pct is not None and remaining_tp_pct < rule['min_remaining_tp_pct']:
|
||
return "HOLD", f"同向持仓距止盈仅剩{remaining_tp_pct:.1f}%,不再加仓/滚仓"
|
||
|
||
if position_protected:
|
||
return "HOLD", "同向持仓已进入保本/保护态,不再主动加仓"
|
||
|
||
# 规则1: 价格距离足够 + 持仓已有浮盈 + 新价格更优 → 加仓
|
||
if better_price and price_diff_pct >= rule['min_add_price_gap_pct'] and pnl_pct >= rule['min_add_profit_pct']:
|
||
return "ADD", f"加仓:价格差{price_diff_pct:.1f}%,盈利{pnl_pct:.1f}%"
|
||
|
||
# 规则2: 价格距离 < 2% → 忽略
|
||
if price_diff_pct < 2:
|
||
return "IGNORE", f"同向持仓价格差{price_diff_pct:.1f}% < 2%,忽略"
|
||
|
||
# 规则3: 持仓亏损且新价格更优 → 滚仓
|
||
if pnl_pct < rule['roll_loss_threshold_pct']:
|
||
if better_price:
|
||
return "ROLL", f"滚仓:持仓亏损{pnl_pct:.1f}%,新价格更优"
|
||
|
||
# 规则4: 其他情况 → HOLD
|
||
return "HOLD", f"有同向持仓(盈利{pnl_pct:.1f}%),继续持有"
|
||
|
||
# 检查同向挂单
|
||
same_orders = [o for o in pending_orders
|
||
if o.get('symbol') == symbol and o.get('side') == signal_side and not o.get('is_reduce_only')]
|
||
|
||
if same_orders:
|
||
order = sorted(
|
||
same_orders,
|
||
key=lambda item: item.get('created_at') or '',
|
||
reverse=True
|
||
)[0]
|
||
order_price = order.get('entry_price', 0)
|
||
price_diff_pct = abs(signal_price - order_price) / order_price * 100 if order_price > 0 else 0
|
||
signal_is_better = (
|
||
(signal_side == 'buy' and signal_price < order_price) or
|
||
(signal_side == 'sell' and signal_price > order_price)
|
||
)
|
||
|
||
# 规则5: 价格距离 < 2% → 忽略
|
||
if price_diff_pct < 2:
|
||
return "IGNORE", f"同向挂单价格差{price_diff_pct:.1f}% < 2%,忽略"
|
||
|
||
# 规则6: limit 信号且新挂单更优,同时位置允许时,优先替换旧挂单
|
||
should_replace, replace_reason = self._should_replace_pending_order(signal, order)
|
||
if should_replace:
|
||
return "REPLACE_PENDING", f"同向挂单存在,{replace_reason}"
|
||
|
||
# 规则7: 价格距离 >= 2% 且挂单 < 3 → 可再挂一单
|
||
if len(same_orders) < 3:
|
||
return "OPEN", f"同向挂单价格差{price_diff_pct:.1f}% >= 2%,可开新单"
|
||
else:
|
||
return "IGNORE", "同向挂单已达3个,忽略"
|
||
|
||
# 无同向订单 → 正常开仓
|
||
return "OPEN", "无同向订单,正常开仓"
|
||
|
||
def _handle_opposite_direction(self, signal: Dict[str, Any],
|
||
positions: List[Dict],
|
||
pending_orders: List[Dict]) -> tuple:
|
||
"""
|
||
处理反向订单(持仓和挂单)
|
||
|
||
Returns:
|
||
(action, reason) - 动作和原因
|
||
"""
|
||
symbol = signal.get('symbol')
|
||
signal_side = signal.get('action')
|
||
opposite_side = 'sell' if signal_side == 'buy' else 'buy'
|
||
confidence = signal.get('confidence', 0)
|
||
rule = self._get_signal_execution_rule(signal)
|
||
|
||
# 检查反向持仓
|
||
opposite_positions = [p for p in positions
|
||
if p.get('symbol') == symbol and p.get('side') == opposite_side]
|
||
|
||
if opposite_positions:
|
||
pos = opposite_positions[0]
|
||
pnl_pct = self._resolve_position_pnl_pct(pos, signal)
|
||
|
||
# 规则1: 信号强度 >= 90 → 强制反转
|
||
if confidence >= rule['flip_confidence'] and pnl_pct <= rule['protect_profit_pct']:
|
||
return "FLIP", f"强信号({confidence}%),平反向持仓并开新仓"
|
||
|
||
# 规则2: 持仓亏损 >= 1% → 平仓
|
||
if pnl_pct <= -1:
|
||
return "CLOSE_OPPOSITE", f"反向持仓亏损{pnl_pct:.1f}%,平仓后开新仓"
|
||
|
||
# 规则3: 持仓盈利 → 等待
|
||
if pnl_pct > 0:
|
||
return "WAIT", f"反向持仓盈利{pnl_pct:.1f}%,等待信号确认或持仓平仓"
|
||
|
||
# 规则4: 小亏损 → 平仓
|
||
if -1 < pnl_pct < 0:
|
||
return "CLOSE_OPPOSITE", f"反向持仓小亏损{pnl_pct:.1f}%,平仓"
|
||
|
||
# 检查反向挂单
|
||
opposite_orders = [o for o in pending_orders
|
||
if o.get('symbol') == symbol and o.get('side') == opposite_side and not o.get('is_reduce_only')]
|
||
|
||
if opposite_orders:
|
||
# 规则5: 取消反向挂单后开仓
|
||
return "CANCEL_AND_OPEN", f"取消 {len(opposite_orders)} 个反向挂单后开新仓"
|
||
|
||
# 无反向订单 → 正常开仓
|
||
return "OPEN", "无反向订单,正常开仓"
|
||
|
||
def _check_losing_streak(self, platform_name: str, max_lookback: int = 5) -> Dict[str, Any]:
|
||
"""
|
||
检查近期交易连败情况
|
||
|
||
Returns:
|
||
{
|
||
'losing_streak': int,
|
||
'should_cool_down': bool,
|
||
'margin_multiplier': float,
|
||
'reason': str,
|
||
}
|
||
"""
|
||
recent_orders = []
|
||
|
||
if platform_name == 'PaperTrading' and self.paper_trading:
|
||
recent_orders = self.paper_trading.get_order_history(limit=max_lookback)
|
||
# Bitget/Hyperliquid 实盘暂不查询历史(API 限制),用空列表
|
||
|
||
if not recent_orders:
|
||
return {'losing_streak': 0, 'should_cool_down': False, 'margin_multiplier': 1.0, 'reason': ''}
|
||
|
||
# 计算连败(从最近的交易往回数)
|
||
losing_streak = 0
|
||
for order in recent_orders:
|
||
pnl = order.get('pnl_amount', 0) or 0
|
||
if pnl < 0:
|
||
losing_streak += 1
|
||
else:
|
||
break # 遇到盈利就停止计数
|
||
|
||
if losing_streak >= 3:
|
||
return {
|
||
'losing_streak': losing_streak,
|
||
'should_cool_down': True,
|
||
'margin_multiplier': 0.3,
|
||
'reason': f"连败 {losing_streak} 次,降温(仓位×0.3)"
|
||
}
|
||
elif losing_streak >= 2:
|
||
return {
|
||
'losing_streak': losing_streak,
|
||
'should_cool_down': True,
|
||
'margin_multiplier': 0.5,
|
||
'reason': f"连败 {losing_streak} 次,降温(仓位×0.5)"
|
||
}
|
||
|
||
return {'losing_streak': losing_streak, 'should_cool_down': False, 'margin_multiplier': 1.0, 'reason': ''}
|
||
|
||
def _check_risk_control(self, signal: Dict[str, Any],
|
||
platform_name: str,
|
||
account: Dict[str, Any],
|
||
positions: List[Dict],
|
||
pending_orders: List[Dict]) -> tuple:
|
||
"""
|
||
其他风控检查
|
||
|
||
Returns:
|
||
(passed, reason) - 是否通过和原因
|
||
"""
|
||
# 1. 杠杆限制检查
|
||
current_leverage = account.get('current_total_leverage', 0)
|
||
max_leverage = account.get('max_total_leverage', 10)
|
||
remaining_leverage = max_leverage - current_leverage
|
||
|
||
if remaining_leverage <= 0:
|
||
return False, f"已达最大杠杆 {current_leverage:.1f}x/{max_leverage}x"
|
||
|
||
# 2. 可用余额检查(仅警告,不阻止执行——由交易所做最终校验)
|
||
available = account.get('available', account.get('available_balance', 0))
|
||
symbol = signal.get('symbol', '').replace('USDT', '').upper()
|
||
rules = self.PLATFORM_RULES.get(platform_name, {})
|
||
min_margin = rules.get('min_margin', {}).get(symbol, 10)
|
||
|
||
if available > 0 and available < min_margin:
|
||
logger.warning(f"[{platform_name}] 余额偏低 ${available:.2f} < ${min_margin},仍尝试执行")
|
||
|
||
# 3. 持仓数量限制(每个币种最多3个持仓+挂单)
|
||
symbol_orders = [o for o in positions + pending_orders if o.get('symbol') == signal.get('symbol')]
|
||
if len(symbol_orders) >= 3:
|
||
return False, f"{signal.get('symbol')} 持仓/挂单已达 {len(symbol_orders)} 个"
|
||
|
||
# 4. 盈亏比检查
|
||
entry = signal.get('entry_price', 0)
|
||
sl = signal.get('stop_loss')
|
||
tp = signal.get('take_profit')
|
||
|
||
signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term'
|
||
min_rr = 1.6 if signal_type == 'short_term' else 2.0 if signal_type == 'medium_term' else 1.2
|
||
|
||
if entry > 0 and sl and tp:
|
||
try:
|
||
sl = float(sl)
|
||
tp = float(tp)
|
||
|
||
if signal.get('action') == 'buy':
|
||
risk = entry - sl
|
||
reward = tp - entry
|
||
else:
|
||
risk = sl - entry
|
||
reward = entry - tp
|
||
|
||
if risk > 0:
|
||
stop_distance_pct = risk / entry * 100
|
||
take_distance_pct = reward / entry * 100
|
||
min_stop_pct = self.SIGNAL_MIN_STOP_LOSS_PCT.get(signal_type, 0.6)
|
||
min_take_pct = self.SIGNAL_MIN_TAKE_PROFIT_PCT.get(signal_type, 1.0)
|
||
|
||
if stop_distance_pct < min_stop_pct:
|
||
return False, f"{signal_type} 止损距离 {stop_distance_pct:.2f}% < {min_stop_pct:.1f}%,不执行"
|
||
if take_distance_pct < min_take_pct:
|
||
return False, f"{signal_type} 止盈距离 {take_distance_pct:.2f}% < {min_take_pct:.1f}%,不执行"
|
||
|
||
risk_reward_ratio = reward / risk
|
||
if risk_reward_ratio < min_rr:
|
||
return False, f"{signal_type} 盈亏比 {risk_reward_ratio:.2f} < {min_rr:.1f},不执行"
|
||
except:
|
||
pass # 价格解析失败,跳过检查
|
||
|
||
# 5. 资金费率检查(极端资金费率时拒绝开仓)
|
||
funding_data = signal.get('funding_rate_data')
|
||
if funding_data:
|
||
fr_pct = funding_data.get('funding_rate_percent', 0) or 0
|
||
signal_action = signal.get('action', '')
|
||
if signal_action == 'buy' and fr_pct > 0.05:
|
||
return False, f"资金费率过热 {fr_pct:.4f}%(做多拥挤),拒绝做多"
|
||
elif signal_action == 'sell' and fr_pct < -0.05:
|
||
return False, f"资金费率过冷 {fr_pct:.4f}%(做空拥挤),拒绝做空"
|
||
|
||
# 6. 连败降温检查(不拒绝,但降低仓位)
|
||
streak_info = self._check_losing_streak(platform_name)
|
||
if streak_info.get('should_cool_down'):
|
||
signal['_streak_margin_multiplier'] = streak_info['margin_multiplier']
|
||
logger.warning(f"[{platform_name}] ⚠️ {streak_info['reason']}")
|
||
|
||
return True, "通过风控检查"
|
||
|
||
def execute_signal_with_rules(self, signal: Dict[str, Any],
|
||
platform_name: str,
|
||
account: Dict[str, Any],
|
||
positions: List[Dict],
|
||
pending_orders: List[Dict]) -> Dict[str, Any]:
|
||
"""
|
||
平台独立处理交易信号(基于硬编码规则)
|
||
|
||
Args:
|
||
signal: 交易信号(包含 action, symbol, confidence 等)
|
||
platform_name: 平台名称 ('Bitget', 'PaperTrading', 'Hyperliquid')
|
||
account: 平台账户状态
|
||
positions: 当前持仓列表
|
||
pending_orders: 当前挂单列表
|
||
|
||
Returns:
|
||
执行决策字典
|
||
"""
|
||
if not signal:
|
||
return {
|
||
"decision": "HOLD",
|
||
"action": "IGNORE",
|
||
"reason": "无适配信号",
|
||
"reasoning": "无适配信号"
|
||
}
|
||
|
||
logger.info(f"\n🎯 [{platform_name}] 处理交易信号: {signal.get('action')} {signal.get('symbol')}")
|
||
|
||
# 预过滤:止盈止损单不参与开仓决策
|
||
actionable_pending_orders = [
|
||
order for order in pending_orders
|
||
if not order.get('is_reduce_only')
|
||
]
|
||
|
||
# 1. 风控检查
|
||
passed, reason = self._check_risk_control(signal, platform_name, account, positions, actionable_pending_orders)
|
||
if not passed:
|
||
logger.info(f" ❌ 风控未通过: {reason}")
|
||
return {
|
||
**signal,
|
||
"decision": "HOLD",
|
||
"action": "IGNORE",
|
||
"reason": reason,
|
||
"reasoning": reason,
|
||
}
|
||
|
||
# 2. 处理同向订单
|
||
same_action, same_reason = self._handle_same_direction(signal, positions, actionable_pending_orders)
|
||
|
||
if same_action in ["IGNORE", "HOLD", "WAIT"]:
|
||
logger.info(f" {same_action}: {same_reason}")
|
||
return {
|
||
**signal,
|
||
"decision": "HOLD",
|
||
"action": same_action,
|
||
"reason": same_reason,
|
||
"reasoning": same_reason,
|
||
}
|
||
|
||
# 3. 处理反向订单
|
||
opposite_action, opposite_reason = self._handle_opposite_direction(signal, positions, actionable_pending_orders)
|
||
|
||
# 4. 综合决策
|
||
final_action = None
|
||
final_reason = None
|
||
|
||
if same_action == "ADD":
|
||
# 加仓
|
||
final_action = "ADD"
|
||
final_reason = same_reason
|
||
elif same_action == "REPLACE_PENDING":
|
||
final_action = "REPLACE_PENDING"
|
||
final_reason = same_reason
|
||
elif same_action == "ROLL":
|
||
# 滚仓
|
||
final_action = "ROLL"
|
||
final_reason = same_reason
|
||
elif same_action == "OPEN":
|
||
# 正常开仓(无同向订单冲突)
|
||
if opposite_action in ["FLIP", "CLOSE_OPPOSITE", "CANCEL_AND_OPEN"]:
|
||
# 有反向订单需要处理
|
||
final_action = opposite_action
|
||
final_reason = opposite_reason
|
||
elif opposite_action in ["WAIT", "HOLD", "IGNORE"]:
|
||
final_action = "HOLD"
|
||
final_reason = opposite_reason
|
||
else:
|
||
# 无反向订单
|
||
final_action = "OPEN"
|
||
final_reason = "正常开仓"
|
||
else:
|
||
final_action = "HOLD"
|
||
final_reason = "复杂场景,保守观望"
|
||
|
||
# 5. 计算仓位大小
|
||
if final_action in ["OPEN", "ADD"]:
|
||
margin, margin_reason = self._calculate_position_size(signal, account, platform_name)
|
||
|
||
if margin <= 0:
|
||
logger.info(f" ❌ 仓位计算失败: {margin_reason}")
|
||
return {
|
||
"decision": "HOLD",
|
||
"action": "IGNORE",
|
||
"reason": margin_reason,
|
||
"reasoning": margin_reason
|
||
}
|
||
|
||
logger.info(f" ✅ {final_action}: {final_reason}, 保证金 ${margin:.2f}")
|
||
return {
|
||
"decision": final_action, # 兼容执行方法
|
||
"action": final_action,
|
||
"quantity": margin, # 兼容执行方法(使用 quantity)
|
||
"margin": margin,
|
||
"reason": final_reason,
|
||
"reasoning": final_reason, # 兼容执行方法
|
||
**signal
|
||
}
|
||
|
||
if final_action == "REPLACE_PENDING":
|
||
same_side_orders = [
|
||
order for order in actionable_pending_orders
|
||
if order.get('symbol') == signal.get('symbol') and order.get('side') == signal.get('action')
|
||
]
|
||
latest_order = sorted(
|
||
same_side_orders,
|
||
key=lambda item: item.get('created_at') or '',
|
||
reverse=True
|
||
)[0] if same_side_orders else None
|
||
|
||
if latest_order and latest_order.get('order_id'):
|
||
logger.info(f" REPLACE_PENDING: {final_reason}")
|
||
return {
|
||
"decision": "CANCEL_PENDING",
|
||
"action": "CANCEL_PENDING",
|
||
"orders_to_cancel": [latest_order.get('order_id')],
|
||
"next_decision": {
|
||
**signal,
|
||
"decision": "OPEN",
|
||
"action": "OPEN",
|
||
"signal_action": signal.get('action'),
|
||
"reason": final_reason,
|
||
"reasoning": final_reason,
|
||
},
|
||
"reason": final_reason,
|
||
"reasoning": final_reason,
|
||
"signal_action": signal.get('action'),
|
||
"symbol": signal.get('symbol'),
|
||
"timeframe": signal.get('timeframe'),
|
||
"type": signal.get('type'),
|
||
}
|
||
|
||
final_action = "OPEN"
|
||
final_reason = "未找到可替换挂单,回退为正常开仓"
|
||
|
||
# 其他动作(FLIP, ROLL, CLOSE_OPPOSITE 等)
|
||
logger.info(f" {final_action}: {final_reason}")
|
||
return {
|
||
"decision": final_action,
|
||
"action": final_action,
|
||
"reason": final_reason,
|
||
"reasoning": final_reason,
|
||
"signal_action": signal.get('action'),
|
||
"symbol": signal.get('symbol'),
|
||
"entry_price": signal.get('entry_price'),
|
||
"stop_loss": signal.get('stop_loss'),
|
||
"take_profit": signal.get('take_profit'),
|
||
"confidence": signal.get('confidence', 0),
|
||
"grade": signal.get('grade', 'C'),
|
||
"timeframe": signal.get('timeframe'),
|
||
"type": signal.get('type'),
|
||
"position_size": signal.get('position_size', 'light'),
|
||
}
|
||
|
||
async def _execute_bitget_decisions(self, decision: Dict[str, Any],
|
||
market_signal: Dict[str, Any],
|
||
current_price: float):
|
||
"""执行 Bitget 决策(使用执行器)"""
|
||
try:
|
||
decision_type = decision.get('decision', 'HOLD')
|
||
symbol = decision.get('symbol', 'UNKNOWN')
|
||
next_decision = decision.get('next_decision')
|
||
|
||
if decision_type == 'HOLD':
|
||
hold_reason = decision.get('reason', decision.get('reasoning', '观望'))
|
||
logger.info(f" Bitget 决策: {hold_reason}")
|
||
self._record_execution_event("Bitget", "hold", decision=decision, reason=hold_reason, status="hold")
|
||
# 仅记录日志,不发飞书通知(避免消息过多)
|
||
return
|
||
|
||
# 使用执行器
|
||
executor = self.executors.get('Bitget')
|
||
if not executor:
|
||
logger.warning(f" ⚠️ Bitget 执行器未初始化")
|
||
return
|
||
|
||
# 执行开仓/加仓
|
||
if decision_type in ['OPEN', 'ADD']:
|
||
logger.info(f" 准备执行 Bitget 交易...")
|
||
result = await executor.execute_open(decision, current_price)
|
||
|
||
if result.get('success'):
|
||
order_id = result.get('order_id', 'unknown')
|
||
order_status = result.get('order_status', 'filled')
|
||
logger.info(f" ✅ Bitget 交易成功: {order_id} ({order_status})")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event(
|
||
"Bitget", "open_success", decision=decision, status="success",
|
||
reason=decision.get('reason', decision.get('reasoning', '')),
|
||
extra={"order_id": order_id, "order_status": order_status},
|
||
)
|
||
|
||
# 发送通知
|
||
await self._send_signal_notification(
|
||
market_signal, decision, current_price,
|
||
prefix="[Bitget]",
|
||
hl_order_status=order_status
|
||
)
|
||
|
||
# TP/SL 警告
|
||
if result.get('tp_sl_warning'):
|
||
await self._notify_bitget_error(symbol, "设置止盈止损", result['tp_sl_warning'])
|
||
|
||
# 记录待设置的 TP/SL(如果是挂单)
|
||
if result.get('pending_tp_sl'):
|
||
order_id = result.get('order_id')
|
||
if order_id:
|
||
signal_action = decision.get('signal_action', decision.get('action'))
|
||
pending_tp_sl = result.get('pending_tp_sl') or {}
|
||
self._bg_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
|
||
symbol=symbol,
|
||
is_long=signal_action == 'buy',
|
||
size=result.get('contracts', 0),
|
||
tp_price=pending_tp_sl.get('tp_price'),
|
||
sl_price=pending_tp_sl.get('sl_price'),
|
||
)
|
||
logger.info(f" 📌 已记录挂单 TP/SL (oid={order_id})")
|
||
else:
|
||
error = result.get('error', result.get('message', '未知错误'))
|
||
logger.error(f" ❌ Bitget 交易失败: {error}")
|
||
self._record_execution_event("Bitget", "open_failed", decision=decision, reason=error, status="error")
|
||
await self._notify_bitget_error(symbol, decision_type, error)
|
||
|
||
# 执行平仓
|
||
elif decision_type == 'CLOSE':
|
||
logger.info(f" 准备 Bitget 平仓...")
|
||
result = await executor.execute_close(decision, current_price)
|
||
|
||
if result.get('success'):
|
||
logger.info(f" ✅ Bitget 平仓成功")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event("Bitget", "close_success", decision=decision, status="success")
|
||
if next_decision:
|
||
await self._execute_bitget_decisions(next_decision, market_signal, current_price)
|
||
else:
|
||
error = result.get('error', '未知错误')
|
||
logger.error(f" ❌ Bitget 平仓失败: {error}")
|
||
self._record_execution_event("Bitget", "close_failed", decision=decision, reason=error, status="error")
|
||
await self._notify_bitget_error(symbol, "平仓", error)
|
||
|
||
# 执行撤单
|
||
elif decision_type == 'CANCEL_PENDING':
|
||
logger.info(f" 准备取消 Bitget 挂单...")
|
||
orders_to_cancel = decision.get('orders_to_cancel', [])
|
||
success_count = 0
|
||
|
||
for order_info in orders_to_cancel:
|
||
order_id = order_info if isinstance(order_info, str) else order_info.get('order_id', '')
|
||
result = await executor.execute_cancel(order_id, symbol)
|
||
if result.get('success'):
|
||
success_count += 1
|
||
# 同时移除待设置的 TP/SL
|
||
self._bg_pending_tp_sl.pop(order_id, None)
|
||
|
||
if success_count > 0:
|
||
logger.info(f" ✅ Bitget 取消成功: {success_count} 个挂单")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event(
|
||
"Bitget", "cancel_success", decision=decision, status="success",
|
||
extra={"cancelled_count": success_count},
|
||
)
|
||
if next_decision:
|
||
await self._execute_bitget_decisions(next_decision, market_signal, current_price)
|
||
else:
|
||
error = "没有成功取消任何挂单"
|
||
logger.error(f" ❌ Bitget 取消失败: {error}")
|
||
self._record_execution_event("Bitget", "cancel_failed", decision=decision, reason=error, status="error")
|
||
await self._notify_bitget_error(symbol, "取消挂单", error)
|
||
else:
|
||
logger.warning(f" ⚠️ Bitget 暂不支持的执行动作: {decision_type}")
|
||
self._record_execution_event("Bitget", "unsupported_decision", decision=decision, reason=f"暂不支持的执行动作: {decision_type}", status="warning")
|
||
|
||
except Exception as e:
|
||
logger.error(f" ❌ Bitget 执行异常: {e}")
|
||
self._record_execution_event("Bitget", "exception", decision=decision, reason=str(e), status="error")
|
||
await self._notify_bitget_error(symbol, decision.get('decision', 'UNKNOWN'), str(e))
|
||
|
||
async def _execute_bitget_trade(self, decision: Dict[str, Any],
|
||
market_signal: Dict[str, Any],
|
||
current_price: float) -> Dict[str, Any]:
|
||
"""执行 Bitget 开仓/加仓"""
|
||
try:
|
||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||
action = decision.get('action', '') # buy/sell
|
||
entry_type = decision.get('entry_type', 'market')
|
||
entry_price = decision.get('entry_price', current_price)
|
||
is_buy = (action == 'buy') # 修复:用 action 字段判断方向
|
||
|
||
# 如果是加仓,先取消旧的止盈止损单
|
||
if decision.get('decision') == 'ADD':
|
||
self.bitget.cancel_tp_sl_orders(symbol)
|
||
|
||
# 计算合约张数
|
||
contracts = self._calculate_bitget_position_size(decision, current_price)
|
||
if contracts < 1:
|
||
return {"success": False, "error": f"仓位计算结果 {contracts} 张,低于最小下单量 1 张"}
|
||
|
||
# 设置杠杆
|
||
# 设置杠杆 (默认10x,最大10x)
|
||
leverage = min(decision.get('leverage', 10), 10)
|
||
self.bitget.update_leverage(symbol, leverage)
|
||
|
||
# 下单
|
||
if entry_type == 'market':
|
||
result = self.bitget.place_market_order(symbol, is_buy=is_buy, size=contracts)
|
||
else:
|
||
result = self.bitget.place_limit_order(symbol, is_buy=is_buy, size=contracts, price=entry_price)
|
||
|
||
if not result.get('success'):
|
||
return result
|
||
|
||
order_status = result.get('order_status', 'filled')
|
||
|
||
# 限价挂单中时验证订单是否真实存在
|
||
if entry_type == 'limit' and order_status == 'resting':
|
||
order_id = result.get('order_id', '')
|
||
open_orders = self.bitget.get_open_orders(symbol)
|
||
ids = [str(o.get('order_id', '')) for o in open_orders]
|
||
if order_id and order_id not in ids:
|
||
logger.warning(f"[Bitget] 挂单 {order_id} 未在挂单列表中,可能已被静默拒绝")
|
||
order_status = 'unknown'
|
||
|
||
result['verified_order_status'] = order_status
|
||
|
||
tp_price = decision.get('take_profit')
|
||
sl_price = decision.get('stop_loss')
|
||
|
||
if tp_price or sl_price:
|
||
if order_status != 'resting':
|
||
# 已成交:直接设置止盈止损
|
||
tp_sl_result = self.bitget.set_tp_sl(
|
||
symbol=symbol,
|
||
is_long=is_buy,
|
||
size=contracts,
|
||
tp_price=tp_price,
|
||
sl_price=sl_price,
|
||
)
|
||
if not tp_sl_result.get('success'):
|
||
result['tp_sl_warning'] = tp_sl_result.get('error', 'TP/SL 设置失败')
|
||
else:
|
||
# 挂单中:记录下来,等下次循环检测成交后补设
|
||
order_id = str(result.get('order_id', ''))
|
||
if order_id:
|
||
self._bg_pending_tp_sl[order_id] = {
|
||
'symbol': symbol,
|
||
'is_long': is_buy,
|
||
'contracts': contracts,
|
||
'tp_price': tp_price,
|
||
'sl_price': sl_price,
|
||
}
|
||
logger.info(f" 📌 [Bitget] 挂单 TP/SL 已记录 (oid={order_id}),等成交后补设")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Bitget 开仓失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
async def _execute_bitget_close(self, decision: Dict[str, Any],
|
||
current_price: float) -> Dict[str, Any]:
|
||
"""执行 Bitget 市价平仓"""
|
||
import math
|
||
try:
|
||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||
|
||
# 清理该 symbol 的挂单 TP/SL 追踪记录
|
||
self._bg_pending_tp_sl = {k: v for k, v in self._bg_pending_tp_sl.items() if v['symbol'] != symbol}
|
||
|
||
self.bitget.cancel_tp_sl_orders(symbol)
|
||
logger.info(f" 取消 Bitget 止盈止损订单")
|
||
|
||
position = self.bitget.get_position_for_symbol(symbol)
|
||
if not position:
|
||
return {"success": False, "error": "未找到持仓"}
|
||
|
||
size_in_coins = abs(position["size"]) # 已经是 BTC 数量
|
||
is_long = position["size"] > 0
|
||
|
||
# 精度处理:向下取整到 0.0001(Bitget 最小精度)
|
||
size_in_coins = math.floor(size_in_coins * 10000) / 10000
|
||
|
||
if size_in_coins < 0.0001:
|
||
return {"success": False, "error": f"持仓过小({size_in_coins} 币 < 最小 0.0001)"}
|
||
|
||
# 直接使用 BTC 数量平仓,不经过合约转换
|
||
try:
|
||
ccxt_symbol = self.bitget.trading_api._standardize_symbol(symbol + 'USDT')
|
||
side = 'sell' if is_long else 'buy'
|
||
order = self.bitget.trading_api.exchange.create_market_order(
|
||
symbol=ccxt_symbol,
|
||
side=side,
|
||
amount=size_in_coins,
|
||
params={
|
||
'reduceOnly': True,
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
}
|
||
)
|
||
if order:
|
||
logger.info(f"✅ Bitget 平仓成功: {symbol} {side} {size_in_coins} BTC")
|
||
return {"success": True, "order_id": str(order.get('id', '')), "symbol": symbol, "size": size_in_coins}
|
||
else:
|
||
return {"success": False, "error": "下单返回空"}
|
||
except Exception as e:
|
||
logger.error(f"❌ Bitget 平仓下单失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Bitget 平仓失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
async def _execute_bitget_cancel(self, decision: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""执行 Bitget 取消挂单"""
|
||
try:
|
||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||
# 清理该 symbol 的挂单 TP/SL 追踪记录
|
||
self._bg_pending_tp_sl = {k: v for k, v in self._bg_pending_tp_sl.items() if v['symbol'] != symbol}
|
||
result = self.bitget.cancel_all_orders(symbol)
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Bitget 取消挂单失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def _calculate_bitget_position_size(self, decision: Dict[str, Any], current_price: float) -> int:
|
||
"""
|
||
计算 Bitget 仓位大小(整数合约张数)
|
||
|
||
Returns:
|
||
可开仓合约数(整数张),0 表示不可开仓
|
||
"""
|
||
try:
|
||
account_state = self.bitget.get_account_state()
|
||
current_balance = account_state["account_value"]
|
||
available_balance = account_state["available_balance"]
|
||
|
||
total_position_value = sum(
|
||
abs(p["size"]) * p["entry_price"]
|
||
for p in self.bitget.get_open_positions()
|
||
)
|
||
|
||
# 获取决策层的保证金建议(如果有)
|
||
quantity = decision.get('quantity', 0) # 保证金金额
|
||
leverage = min(decision.get('leverage', 5), 10)
|
||
|
||
# 如果决策层提供了保证金,计算最小仓位价值
|
||
min_position_from_margin = 0
|
||
if quantity and isinstance(quantity, (int, float)) and quantity > 0:
|
||
min_position_from_margin = quantity * leverage # 保证金 × 杠杆
|
||
logger.info(f" 决策层保证金: ${quantity:.2f} → 最小仓位价值: ${min_position_from_margin:.2f}")
|
||
|
||
# 计算最大仓位限制
|
||
max_by_config = self.bitget.max_single_position
|
||
max_by_available = available_balance * leverage
|
||
max_by_total_leverage = (
|
||
current_balance * self.bitget.max_total_leverage - total_position_value
|
||
)
|
||
max_position_usd = min(max_by_config, max_by_available, max_by_total_leverage)
|
||
max_position_usd = min(max_position_usd, current_balance * 0.5)
|
||
|
||
# 确保最小仓位价值(如果决策层提供了保证金)
|
||
if min_position_from_margin > 0:
|
||
max_position_usd = max(max_position_usd, min_position_from_margin)
|
||
logger.info(f" 调整最小仓位: ${max_position_usd:.2f} (确保 ≥ 保证金 × 杠杆)")
|
||
|
||
# 详细日志
|
||
logger.info(f"💰 Bitget 仓位计算:")
|
||
logger.info(f" 账户余额: ${current_balance:.2f}, 可用: ${available_balance:.2f}")
|
||
logger.info(f" 当前持仓价值: ${total_position_value:.2f}")
|
||
logger.info(f" 杠杆: {leverage}x")
|
||
logger.info(f" 单笔上限: ${max_by_config:.2f}")
|
||
logger.info(f" 可用杠杆空间: ${max_by_available:.2f}")
|
||
logger.info(f" 总杠杆空间: ${max_by_total_leverage:.2f}")
|
||
logger.info(f" 最终仓位 USD: ${max_position_usd:.2f}")
|
||
|
||
if max_position_usd <= 0:
|
||
logger.warning(f"⚠️ Bitget 可用保证金不足,无法开仓 (balance={current_balance:.2f})")
|
||
return 0
|
||
|
||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||
contract_size = self.bitget.get_contract_size(symbol)
|
||
if contract_size <= 0 or current_price <= 0:
|
||
logger.warning(f"⚠️ Bitget 合约规格或价格无效 (contract_size={contract_size}, price={current_price})")
|
||
return 0
|
||
|
||
# notional → coins → contracts(向下取整)
|
||
coin_amount = max_position_usd / current_price
|
||
contracts = math.floor(coin_amount / contract_size)
|
||
|
||
logger.info(f" 币数量: {coin_amount:.6f}, 合约规格: {contract_size}, 张数: {contracts}")
|
||
|
||
# 如果计算出的张数 < 1,检查是否是保证金太少
|
||
if contracts < 1:
|
||
min_coins_needed = contract_size
|
||
min_usd_needed = min_coins_needed * current_price
|
||
min_margin_needed = min_usd_needed / leverage
|
||
|
||
logger.warning(f"⚠️ Bitget 仓位计算 {coin_amount:.4f} 币 = {contracts} 张,低于最小 1 张")
|
||
logger.warning(f" 最小需要: {min_coins_needed} 币 ≈ ${min_usd_needed:.2f}")
|
||
logger.warning(f" 最小保证金: ${min_margin_needed:.2f} (杠杆 {leverage}x)")
|
||
|
||
if quantity and isinstance(quantity, (int, float)) and quantity > 0:
|
||
logger.warning(f" 当前保证金: ${quantity:.2f},建议提高到至少 ${min_margin_needed:.2f}")
|
||
|
||
return 0
|
||
|
||
logger.info(
|
||
f"💰 Bitget 仓位: 最大{max_position_usd:.0f}USD → {coin_amount:.4f}{symbol} "
|
||
f"→ {contracts}张 (合约面值={contract_size}) @ ${current_price:.2f}"
|
||
)
|
||
return contracts
|
||
|
||
except Exception as e:
|
||
logger.error(f"Bitget 计算仓位大小失败: {e}")
|
||
return 0
|
||
|
||
async def _execute_hyperliquid_decisions(self, decision: Dict[str, Any],
|
||
market_signal: Dict[str, Any],
|
||
current_price: float):
|
||
"""执行 Hyperliquid 决策(使用执行器)"""
|
||
try:
|
||
decision_type = decision.get('decision', 'HOLD')
|
||
symbol = decision.get('symbol', 'UNKNOWN')
|
||
next_decision = decision.get('next_decision')
|
||
|
||
if decision_type == 'HOLD':
|
||
hold_reason = decision.get('reason', decision.get('reasoning', '观望'))
|
||
logger.info(f" Hyperliquid 决策: {hold_reason}")
|
||
self._record_execution_event("Hyperliquid", "hold", decision=decision, reason=hold_reason, status="hold")
|
||
# 仅记录日志,不发飞书通知(避免消息过多)
|
||
return
|
||
|
||
# 使用执行器
|
||
executor = self.executors.get('Hyperliquid')
|
||
if not executor:
|
||
logger.warning(f" ⚠️ Hyperliquid 执行器未初始化")
|
||
return
|
||
|
||
# 执行开仓/加仓
|
||
if decision_type in ['OPEN', 'ADD']:
|
||
logger.info(f" 准备执行 Hyperliquid 交易...")
|
||
result = await executor.execute_open(decision, current_price)
|
||
|
||
if result.get('success'):
|
||
order_status = result.get('order_status', 'filled')
|
||
logger.info(f" ✅ Hyperliquid 交易成功 ({order_status})")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event(
|
||
"Hyperliquid", "open_success", decision=decision, status="success",
|
||
reason=decision.get('reason', decision.get('reasoning', '')),
|
||
extra={"order_status": order_status, "order_id": result.get('order_id')},
|
||
)
|
||
await self._send_signal_notification(
|
||
market_signal, decision, current_price,
|
||
prefix="[Hyperliquid]",
|
||
hl_order_status=order_status
|
||
)
|
||
if result.get('pending_tp_sl'):
|
||
order_id = str(result.get('order_id') or '')
|
||
pending_tp_sl = result.get('pending_tp_sl') or {}
|
||
tracking_key = order_id or f"{symbol}:{datetime.now().timestamp()}"
|
||
signal_action = decision.get('signal_action', decision.get('action'))
|
||
self._hl_pending_tp_sl[tracking_key] = self._build_pending_tp_sl_task(
|
||
symbol=symbol.replace('USDT', ''),
|
||
is_long=signal_action == 'buy',
|
||
size=result.get('position_size') or result.get('size') or 0,
|
||
tp_price=pending_tp_sl.get('tp_price'),
|
||
sl_price=pending_tp_sl.get('sl_price'),
|
||
order_status=order_status,
|
||
has_real_order_id=bool(order_id),
|
||
)
|
||
logger.info(f" 📌 已记录 Hyperliquid TP/SL 待补设任务 (key={tracking_key})")
|
||
if result.get('tp_sl_warning'):
|
||
await self._notify_hyperliquid_error(symbol, "设置止盈止损", result['tp_sl_warning'])
|
||
else:
|
||
error = result.get('error', '未知错误')
|
||
logger.error(f" ❌ Hyperliquid 交易失败: {error}")
|
||
self._record_execution_event("Hyperliquid", "open_failed", decision=decision, reason=error, status="error")
|
||
await self._notify_hyperliquid_error(symbol, decision_type, error)
|
||
# 执行平仓
|
||
elif decision_type == 'CLOSE':
|
||
logger.info(f" 准备 Hyperliquid 平仓...")
|
||
result = await executor.execute_close(decision, current_price)
|
||
if result.get('success'):
|
||
logger.info(f" ✅ Hyperliquid 平仓成功")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event("Hyperliquid", "close_success", decision=decision, status="success")
|
||
if next_decision:
|
||
await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price)
|
||
else:
|
||
error = result.get('error', '未知错误')
|
||
logger.error(f" ❌ Hyperliquid 平仓失败: {error}")
|
||
self._record_execution_event("Hyperliquid", "close_failed", decision=decision, reason=error, status="error")
|
||
await self._notify_hyperliquid_error(symbol, "平仓", error)
|
||
# 执行撤单
|
||
elif decision_type == 'CANCEL_PENDING':
|
||
logger.info(f" 准备取消 Hyperliquid 挂单...")
|
||
orders_to_cancel = decision.get('orders_to_cancel', [])
|
||
success_count = 0 if orders_to_cancel else 0
|
||
for order_info in orders_to_cancel:
|
||
order_id = order_info if isinstance(order_info, str) else order_info.get('order_id', '')
|
||
result = await executor.execute_cancel(order_id, symbol)
|
||
if result.get('success'):
|
||
success_count += 1
|
||
if success_count > 0:
|
||
logger.info(f" ✅ Hyperliquid 取消成功: {success_count} 个")
|
||
decision['_execution_succeeded'] = True
|
||
self._record_execution_event(
|
||
"Hyperliquid", "cancel_success", decision=decision, status="success",
|
||
extra={"cancelled_count": success_count},
|
||
)
|
||
if next_decision:
|
||
await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price)
|
||
else:
|
||
error = "没有成功取消任何挂单"
|
||
logger.error(f" ❌ Hyperliquid 取消失败: {error}")
|
||
self._record_execution_event("Hyperliquid", "cancel_failed", decision=decision, reason=error, status="error")
|
||
await self._notify_hyperliquid_error(symbol, "取消挂单", error)
|
||
else:
|
||
logger.warning(f" ⚠️ Hyperliquid 暂不支持的执行动作: {decision_type}")
|
||
self._record_execution_event("Hyperliquid", "unsupported_decision", decision=decision, reason=f"暂不支持的执行动作: {decision_type}", status="warning")
|
||
except Exception as e:
|
||
logger.error(f" ❌ Hyperliquid 执行异常: {e}")
|
||
self._record_execution_event("Hyperliquid", "exception", decision=decision, reason=str(e), status="error")
|
||
await self._notify_hyperliquid_error(symbol, decision_type, str(e))
|
||
|
||
async def _execute_hyperliquid_trade(self, decision: Dict[str, Any],
|
||
market_signal: Dict[str, Any],
|
||
current_price: float) -> Dict[str, Any]:
|
||
"""执行 Hyperliquid 开仓/加仓"""
|
||
try:
|
||
symbol = decision.get('symbol', '').replace('USDT', '') # BTCUSDT → BTC
|
||
action = decision.get('action', '') # buy/sell
|
||
entry_type = decision.get('entry_type', 'market')
|
||
entry_price = decision.get('entry_price', current_price)
|
||
is_buy = (action == 'buy') # 修复:用 action 字段判断方向
|
||
|
||
# 计算仓位大小(基于可用保证金和风控)
|
||
size = self._calculate_hyperliquid_position_size(decision, current_price)
|
||
|
||
# 检查保证金是否充足
|
||
if size <= 0:
|
||
return {"success": False, "error": "保证金不足,无法开仓"}
|
||
|
||
# 更新杠杆
|
||
leverage = min(decision.get('leverage', 10), 10)
|
||
self.hyperliquid.update_leverage(symbol, leverage)
|
||
|
||
# 如果是加仓,先取消旧的止盈止损
|
||
if decision.get('action') == 'ADD':
|
||
self.hyperliquid.cancel_tp_sl_orders(symbol)
|
||
logger.info(f" 取消旧的止盈止损订单")
|
||
|
||
# 执行交易
|
||
if entry_type == 'market':
|
||
result = self.hyperliquid.place_market_order(
|
||
symbol=symbol,
|
||
is_buy=is_buy,
|
||
size=size
|
||
)
|
||
else: # limit
|
||
result = self.hyperliquid.place_limit_order(
|
||
symbol=symbol,
|
||
is_buy=is_buy,
|
||
size=size,
|
||
price=entry_price
|
||
)
|
||
|
||
# 如果开仓成功,处理止盈止损 + 验证订单实际状态
|
||
if result.get('success'):
|
||
order_status = result.get('order_status', 'filled') # market单默认filled
|
||
|
||
# 限价单:如果立即成交(filled),验证持仓是否存在
|
||
if entry_type == 'limit' and order_status == 'filled':
|
||
position = self.hyperliquid.get_position_for_symbol(symbol)
|
||
if position:
|
||
logger.info(f" ✅ 限价单立即成交,持仓确认: {symbol} size={position['size']}")
|
||
else:
|
||
logger.warning(f" ⚠️ 限价单显示 filled 但未查到持仓,可能已被平仓或数据延迟")
|
||
|
||
# 限价单:如果仍在挂单中(resting),验证订单是否在挂单列表
|
||
elif entry_type == 'limit' and order_status == 'resting':
|
||
order_id = result.get('order_id')
|
||
open_orders = self.hyperliquid.get_open_orders(symbol)
|
||
if any(o.get('order_id') == order_id for o in open_orders):
|
||
logger.info(f" ✅ 限价单已挂出并确认可见: oid={order_id}")
|
||
else:
|
||
logger.warning(f" ⚠️ 限价单 oid={order_id} 未在挂单列表中查到(可能已成交或延迟)")
|
||
|
||
# 将实际状态写回 result,供通知层使用
|
||
result['verified_order_status'] = order_status
|
||
|
||
tp_price = decision.get('take_profit')
|
||
sl_price = decision.get('stop_loss')
|
||
|
||
if tp_price or sl_price:
|
||
# 只有已成交的订单才设置止盈止损(挂单中的不设,等成交后再设)
|
||
if order_status != 'resting':
|
||
tp_sl_result = self.hyperliquid.set_tp_sl(
|
||
symbol=symbol,
|
||
is_long=is_buy,
|
||
size=size,
|
||
tp_price=tp_price,
|
||
sl_price=sl_price
|
||
)
|
||
|
||
if not tp_sl_result.get('success'):
|
||
logger.warning(f" ⚠️ 设置止盈止损失败: {tp_sl_result.get('error')}")
|
||
result['tp_sl_warning'] = tp_sl_result.get('error', '设置止盈止损失败')
|
||
else:
|
||
# 挂单中:记录下来,等下次循环检测成交后补设
|
||
order_id = str(result.get('order_id', ''))
|
||
if order_id:
|
||
self._hl_pending_tp_sl[order_id] = {
|
||
'symbol': symbol,
|
||
'is_long': is_buy,
|
||
'size': size,
|
||
'tp_price': tp_price,
|
||
'sl_price': sl_price,
|
||
}
|
||
logger.info(f" 📌 [Hyperliquid] 挂单 TP/SL 已记录 (oid={order_id}),等成交后补设")
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Hyperliquid 交易执行失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
async def _execute_hyperliquid_close(self, decision: Dict[str, Any],
|
||
current_price: float) -> Dict[str, Any]:
|
||
"""执行 Hyperliquid 平仓"""
|
||
try:
|
||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||
|
||
# 清理该 symbol 的挂单 TP/SL 追踪记录
|
||
self._hl_pending_tp_sl = {k: v for k, v in self._hl_pending_tp_sl.items() if v['symbol'] != symbol}
|
||
|
||
# 先取消所有止盈止损订单
|
||
self.hyperliquid.cancel_tp_sl_orders(symbol)
|
||
logger.info(f" 取消止盈止损订单")
|
||
|
||
# 获取当前持仓
|
||
position = self.hyperliquid.get_position_for_symbol(symbol)
|
||
|
||
if not position:
|
||
return {"success": False, "error": "未找到持仓"}
|
||
|
||
size = abs(position["size"])
|
||
is_long = position["size"] > 0
|
||
|
||
# 平仓(方向相反)
|
||
result = self.hyperliquid.place_market_order(
|
||
symbol=symbol,
|
||
is_buy=not is_long,
|
||
size=size,
|
||
reduce_only=True
|
||
)
|
||
|
||
return result
|
||
|
||
except Exception as e:
|
||
logger.error(f"Hyperliquid 平仓失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
async def _execute_hyperliquid_cancel(self, decision: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""执行 Hyperliquid 取消挂单"""
|
||
try:
|
||
symbol = decision.get('symbol', '').replace('USDT', '')
|
||
# 清理该 symbol 的挂单 TP/SL 追踪记录
|
||
self._hl_pending_tp_sl = {k: v for k, v in self._hl_pending_tp_sl.items() if v['symbol'] != symbol}
|
||
result = self.hyperliquid.cancel_all_orders(symbol)
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Hyperliquid 取消挂单失败: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
async def _check_and_set_pending_tp_sl_hyperliquid(self):
|
||
"""检查 Hyperliquid 挂单是否已成交,若成交则补设止盈止损"""
|
||
if self._is_platform_halted('Hyperliquid'):
|
||
return
|
||
if not self._hl_pending_tp_sl:
|
||
return
|
||
try:
|
||
for order_id, info in list(self._hl_pending_tp_sl.items()):
|
||
symbol = info['symbol']
|
||
has_real_order_id = info.get('has_real_order_id', True)
|
||
if has_real_order_id:
|
||
open_orders = self.hyperliquid.get_open_orders(symbol)
|
||
still_open = any(str(o.get('order_id')) == order_id for o in open_orders)
|
||
else:
|
||
still_open = info.get('order_status') == 'resting'
|
||
if not still_open:
|
||
# 订单已不在挂单列表 → 已成交,补设 TP/SL
|
||
tp_price = info.get('tp_price')
|
||
sl_price = info.get('sl_price')
|
||
position = self.hyperliquid.get_position_for_symbol(symbol)
|
||
if not position:
|
||
logger.info(f"[Hyperliquid] 挂单/持仓 {order_id} ({symbol}) 当前无持仓,跳过 TP/SL 补设")
|
||
del self._hl_pending_tp_sl[order_id]
|
||
continue
|
||
size = info.get('size') or abs(position.get('size', 0))
|
||
if size <= 0:
|
||
logger.warning(f"[Hyperliquid] 挂单/持仓 {order_id} ({symbol}) 数量无效,跳过 TP/SL 补设")
|
||
del self._hl_pending_tp_sl[order_id]
|
||
continue
|
||
logger.info(f"[Hyperliquid] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...")
|
||
tp_sl_result = self.hyperliquid.set_tp_sl(
|
||
symbol=symbol,
|
||
is_long=info['is_long'],
|
||
size=size,
|
||
tp_price=tp_price,
|
||
sl_price=sl_price,
|
||
)
|
||
info['retry_count'] = int(info.get('retry_count', 0)) + 1
|
||
tp_set = tp_sl_result.get('tp_set', False)
|
||
sl_set = tp_sl_result.get('sl_set', False)
|
||
if tp_set and sl_set:
|
||
logger.info(f"[Hyperliquid] ✅ TP/SL 补设成功: {symbol} TP={tp_price} SL={sl_price}")
|
||
elif tp_set or sl_set:
|
||
missing_tp = tp_price if not tp_set else None
|
||
missing_sl = sl_price if not sl_set else None
|
||
self._hl_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
|
||
symbol=info['symbol'],
|
||
is_long=info['is_long'],
|
||
size=size,
|
||
tp_price=missing_tp,
|
||
sl_price=missing_sl,
|
||
order_status=info.get('order_status'),
|
||
has_real_order_id=info.get('has_real_order_id', True),
|
||
retry_count=info.get('retry_count', 0),
|
||
first_seen_at=info.get('first_seen_at'),
|
||
last_alert_at=info.get('last_alert_at'),
|
||
)
|
||
set_text = "TP" if tp_set else "SL"
|
||
fail_text = "TP" if not tp_set else "SL"
|
||
logger.warning(f"[Hyperliquid] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设")
|
||
await self._maybe_alert_tp_sl_incomplete(
|
||
"Hyperliquid",
|
||
order_id,
|
||
self._hl_pending_tp_sl[order_id],
|
||
f"{set_text}已设,{fail_text}补设失败",
|
||
)
|
||
continue
|
||
else:
|
||
logger.warning(f"[Hyperliquid] ⚠️ TP/SL 补设失败: {tp_sl_result.get('errors') or tp_sl_result.get('error')}")
|
||
await self._maybe_alert_tp_sl_incomplete(
|
||
"Hyperliquid",
|
||
order_id,
|
||
info,
|
||
str(tp_sl_result.get('errors') or tp_sl_result.get('error') or 'TP/SL补设失败'),
|
||
)
|
||
continue
|
||
del self._hl_pending_tp_sl[order_id]
|
||
except Exception as e:
|
||
logger.error(f"[Hyperliquid] 检查挂单 TP/SL 补设异常: {e}")
|
||
|
||
async def _check_and_set_pending_tp_sl_bitget(self):
|
||
"""检查 Bitget 挂单是否已成交,若成交则补设止盈止损"""
|
||
if self._is_platform_halted('Bitget'):
|
||
return
|
||
if not self._bg_pending_tp_sl:
|
||
return
|
||
try:
|
||
for order_id, info in list(self._bg_pending_tp_sl.items()):
|
||
symbol = info['symbol']
|
||
open_orders = self.bitget.get_open_orders(symbol)
|
||
still_open = any(str(o.get('order_id')) == order_id for o in open_orders)
|
||
if not still_open:
|
||
# 订单已不在挂单列表 → 已成交,补设 TP/SL
|
||
tp_price = info.get('tp_price')
|
||
sl_price = info.get('sl_price')
|
||
logger.info(f"[Bitget] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...")
|
||
tp_sl_result = self.bitget.set_tp_sl(
|
||
symbol=symbol,
|
||
is_long=info['is_long'],
|
||
size=info['size'],
|
||
tp_price=tp_price,
|
||
sl_price=sl_price,
|
||
)
|
||
info['retry_count'] = int(info.get('retry_count', 0)) + 1
|
||
tp_set = tp_sl_result.get('tp_set', False)
|
||
sl_set = tp_sl_result.get('sl_set', False)
|
||
|
||
if tp_set and sl_set:
|
||
logger.info(f"[Bitget] ✅ TP/SL 补设成功: {symbol} TP={tp_price} SL={sl_price}")
|
||
elif tp_set or sl_set:
|
||
# 部分成功:更新 pending 只保留缺失的
|
||
missing_tp = tp_price if not tp_set else None
|
||
missing_sl = sl_price if not sl_set else None
|
||
if missing_tp or missing_sl:
|
||
self._bg_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
|
||
symbol=info['symbol'],
|
||
is_long=info['is_long'],
|
||
size=info['size'],
|
||
tp_price=missing_tp,
|
||
sl_price=missing_sl,
|
||
retry_count=info.get('retry_count', 0),
|
||
first_seen_at=info.get('first_seen_at'),
|
||
last_alert_at=info.get('last_alert_at'),
|
||
)
|
||
set_text = "TP" if tp_set else "SL"
|
||
fail_text = "TP" if not tp_set else "SL"
|
||
logger.warning(f"[Bitget] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设")
|
||
await self._maybe_alert_tp_sl_incomplete(
|
||
"Bitget",
|
||
order_id,
|
||
self._bg_pending_tp_sl[order_id],
|
||
f"{set_text}已设,{fail_text}补设失败",
|
||
)
|
||
continue # 不删除,下轮继续
|
||
else:
|
||
logger.warning(f"[Bitget] ⚠️ TP/SL 补设失败: {tp_sl_result.get('errors')}")
|
||
await self._maybe_alert_tp_sl_incomplete(
|
||
"Bitget",
|
||
order_id,
|
||
info,
|
||
str(tp_sl_result.get('errors') or 'TP/SL补设失败'),
|
||
)
|
||
continue # 不删除,下轮继续重试
|
||
|
||
del self._bg_pending_tp_sl[order_id]
|
||
except Exception as e:
|
||
logger.error(f"[Bitget] 检查挂单 TP/SL 补设异常: {e}")
|
||
|
||
async def _check_bitget_missing_tp_sl(self):
|
||
"""定时检查 Bitget 持仓是否缺少止盈止损,缺少则从信号补救"""
|
||
if not self.bitget:
|
||
return
|
||
try:
|
||
positions = self.bitget.get_open_positions()
|
||
if not positions:
|
||
return
|
||
|
||
for pos in positions:
|
||
symbol = pos.get('symbol', '') # e.g. "BTCUSDT"
|
||
if not symbol:
|
||
continue
|
||
|
||
# 获取当前止盈止损
|
||
coin = symbol.replace('USDT', '')
|
||
tp_sl = self.bitget.get_tp_sl_prices(coin)
|
||
has_tp = tp_sl.get('take_profit') is not None
|
||
has_sl = tp_sl.get('stop_loss') is not None
|
||
|
||
if has_tp and has_sl:
|
||
continue # 都有,跳过
|
||
|
||
# 缺少 TP 或 SL,从信号数据库查找最近信号补救
|
||
latest_signal = self.signal_db.get_latest_signal('crypto', symbol)
|
||
if not latest_signal:
|
||
missing = ('止盈' if not has_tp else '') + ('/' if not has_tp and not has_sl else '') + ('止损' if not has_sl else '')
|
||
logger.warning(f"[Bitget] ⚠️ {symbol} 缺少{missing},且无历史信号可补救")
|
||
continue
|
||
|
||
tp_price = latest_signal.get('take_profit')
|
||
sl_price = latest_signal.get('stop_loss')
|
||
|
||
if not tp_price and not sl_price:
|
||
logger.warning(f"[Bitget] ⚠️ {symbol} 缺少止盈止损,最近信号也无 TP/SL")
|
||
continue
|
||
|
||
# 只补救缺少的
|
||
set_tp = tp_price if not has_tp else None
|
||
set_sl = sl_price if not has_sl else None
|
||
|
||
missing_parts = []
|
||
if not has_tp:
|
||
missing_parts.append(f"TP={set_tp}")
|
||
if not has_sl:
|
||
missing_parts.append(f"SL={set_sl}")
|
||
missing_desc = ' & '.join(missing_parts)
|
||
|
||
logger.warning(f"[Bitget] 🔧 {symbol} 缺少 {missing_desc},从信号补救...")
|
||
|
||
# 用仓位实际大小设置
|
||
size = abs(pos.get('size', 0))
|
||
if size <= 0:
|
||
continue
|
||
|
||
tp_sl_result = self.bitget.set_tp_sl(
|
||
symbol=coin,
|
||
is_long=pos.get('size', 0) > 0,
|
||
size=size,
|
||
tp_price=set_tp,
|
||
sl_price=set_sl,
|
||
)
|
||
|
||
tp_set = tp_sl_result.get('tp_set', False)
|
||
sl_set = tp_sl_result.get('sl_set', False)
|
||
if tp_set or sl_set:
|
||
set_parts = []
|
||
if tp_set:
|
||
set_parts.append(f"TP={set_tp}")
|
||
if sl_set:
|
||
set_parts.append(f"SL={set_sl}")
|
||
logger.info(f"[Bitget] ✅ 补救成功: {symbol} {' & '.join(set_parts)}")
|
||
else:
|
||
logger.warning(f"[Bitget] ⚠️ 补救失败: {tp_sl_result.get('errors')}")
|
||
await self._maybe_alert_tp_sl_incomplete(
|
||
"Bitget",
|
||
f"fallback:{symbol}",
|
||
self._build_pending_tp_sl_task(
|
||
symbol=coin,
|
||
is_long=pos.get('size', 0) > 0,
|
||
size=size,
|
||
tp_price=set_tp,
|
||
sl_price=set_sl,
|
||
retry_count=self.TP_SL_RETRY_ALERT_THRESHOLD,
|
||
),
|
||
str(tp_sl_result.get('errors') or '兜底补设失败'),
|
||
force=True,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"[Bitget] 止盈止损兜底检查异常: {e}")
|
||
|
||
async def _check_hyperliquid_missing_tp_sl(self):
|
||
"""定时检查 Hyperliquid 持仓是否缺少止盈止损,缺少则从最新信号补救"""
|
||
if not self.hyperliquid:
|
||
return
|
||
try:
|
||
positions = self.hyperliquid.get_open_positions()
|
||
if not positions:
|
||
return
|
||
|
||
for pos in positions:
|
||
symbol = pos.get('symbol', '')
|
||
if not symbol:
|
||
continue
|
||
|
||
coin = symbol.replace('USDT', '')
|
||
tp_sl = self.hyperliquid.get_tp_sl_prices(coin)
|
||
has_tp = tp_sl.get('take_profit') is not None
|
||
has_sl = tp_sl.get('stop_loss') is not None
|
||
|
||
if has_tp and has_sl:
|
||
continue
|
||
|
||
latest_signal = self.signal_db.get_latest_signal('crypto', symbol)
|
||
if not latest_signal:
|
||
missing = ('止盈' if not has_tp else '') + ('/' if not has_tp and not has_sl else '') + ('止损' if not has_sl else '')
|
||
logger.warning(f"[Hyperliquid] ⚠️ {symbol} 缺少{missing},且无历史信号可补救")
|
||
continue
|
||
|
||
tp_price = latest_signal.get('take_profit')
|
||
sl_price = latest_signal.get('stop_loss')
|
||
|
||
if not tp_price and not sl_price:
|
||
logger.warning(f"[Hyperliquid] ⚠️ {symbol} 缺少止盈止损,最近信号也无 TP/SL")
|
||
continue
|
||
|
||
set_tp = tp_price if not has_tp else None
|
||
set_sl = sl_price if not has_sl else None
|
||
|
||
missing_parts = []
|
||
if not has_tp:
|
||
missing_parts.append(f"TP={set_tp}")
|
||
if not has_sl:
|
||
missing_parts.append(f"SL={set_sl}")
|
||
logger.warning(f"[Hyperliquid] 🔧 {symbol} 缺少 {' & '.join(missing_parts)},从信号补救...")
|
||
|
||
size = abs(pos.get('size', 0))
|
||
if size <= 0:
|
||
continue
|
||
|
||
tp_sl_result = self.hyperliquid.set_tp_sl(
|
||
symbol=coin,
|
||
is_long=pos.get('size', 0) > 0,
|
||
size=size,
|
||
tp_price=set_tp,
|
||
sl_price=set_sl,
|
||
)
|
||
|
||
tp_set = tp_sl_result.get('tp_set', False)
|
||
sl_set = tp_sl_result.get('sl_set', False)
|
||
if tp_set or sl_set:
|
||
set_parts = []
|
||
if tp_set:
|
||
set_parts.append(f"TP={set_tp}")
|
||
if sl_set:
|
||
set_parts.append(f"SL={set_sl}")
|
||
logger.info(f"[Hyperliquid] ✅ 补救成功: {symbol} {' & '.join(set_parts)}")
|
||
else:
|
||
logger.warning(f"[Hyperliquid] ⚠️ 补救失败: {tp_sl_result.get('errors') or tp_sl_result.get('error')}")
|
||
await self._maybe_alert_tp_sl_incomplete(
|
||
"Hyperliquid",
|
||
f"fallback:{symbol}",
|
||
self._build_pending_tp_sl_task(
|
||
symbol=coin,
|
||
is_long=pos.get('size', 0) > 0,
|
||
size=size,
|
||
tp_price=set_tp,
|
||
sl_price=set_sl,
|
||
retry_count=self.TP_SL_RETRY_ALERT_THRESHOLD,
|
||
),
|
||
str(tp_sl_result.get('errors') or tp_sl_result.get('error') or '兜底补设失败'),
|
||
force=True,
|
||
)
|
||
|
||
except Exception as e:
|
||
logger.error(f"[Hyperliquid] 止盈止损兜底检查异常: {e}")
|
||
|
||
def _calculate_hyperliquid_position_size(self, decision: Dict[str, Any], current_price: float) -> float:
|
||
"""
|
||
计算 Hyperliquid 仓位大小(基于可用保证金和风控限制)
|
||
|
||
Args:
|
||
decision: 交易决策
|
||
current_price: 当前价格
|
||
|
||
Returns:
|
||
可开仓数量(币的数量,如 BTC = 0.01)
|
||
"""
|
||
try:
|
||
# 获取账户状态
|
||
account_state = self.hyperliquid.get_account_state()
|
||
current_balance = account_state["account_value"]
|
||
used_margin = account_state["total_margin_used"]
|
||
available_balance = account_state["available_balance"]
|
||
|
||
# 获取当前所有持仓的总价值
|
||
total_position_value = 0
|
||
positions = self.hyperliquid.get_open_positions()
|
||
for pos in positions:
|
||
size = abs(pos["size"])
|
||
entry_price = pos["entry_price"]
|
||
total_position_value += size * entry_price
|
||
|
||
# 当前总杠杆
|
||
current_total_leverage = total_position_value / current_balance if current_balance > 0 else 0
|
||
|
||
# 获取杠杆配置
|
||
leverage = min(decision.get('leverage', 5), 10) # 最大 10x
|
||
|
||
# 计算最大可开仓金额(考虑多个限制)
|
||
max_by_config = self.hyperliquid.max_single_position # 配置的单笔限制
|
||
max_by_available = available_balance * leverage # 可用保证金 × 杠杆
|
||
max_by_total_leverage = (current_balance * self.hyperliquid.max_total_leverage - total_position_value) # 总杠杆限制
|
||
|
||
# 取最小值作为最大可开仓金额
|
||
max_position_usd = min(max_by_config, max_by_available, max_by_total_leverage)
|
||
|
||
# 风控检查:不能超过可用余额的 50%(保守策略)
|
||
max_position_usd = min(max_position_usd, current_balance * 0.5)
|
||
|
||
# 如果计算出的最大值 <= 0,说明保证金不足
|
||
if max_position_usd <= 0:
|
||
logger.warning(f"⚠️ 可用保证金不足,无法开仓")
|
||
logger.warning(f" 账户价值: ${current_balance:.2f}")
|
||
logger.warning(f" 可用余额: ${available_balance:.2f}")
|
||
logger.warning(f" 总持仓价值: ${total_position_value:.2f}")
|
||
logger.warning(f" 当前总杠杆: {current_total_leverage:.2f}x")
|
||
return 0
|
||
|
||
# 根据当前价格计算数量
|
||
size = max_position_usd / current_price
|
||
|
||
# 按 Hyperliquid 要求的精度截断(szDecimals)
|
||
# ETH=3位, BTC=5位 等,必须截断而非四舍五入,避免超出允许仓位
|
||
sz_decimals = self.hyperliquid.get_sz_decimals(decision.get('symbol', '').replace('USDT', ''))
|
||
factor = 10 ** sz_decimals
|
||
size = math.floor(size * factor) / factor # 截断,不四舍五入
|
||
|
||
# 最小下单量检查
|
||
min_size = 1 / factor
|
||
if size < min_size:
|
||
logger.warning(f"⚠️ 计算仓位 {size} 低于最小下单量 {min_size},取消开仓")
|
||
return 0
|
||
|
||
logger.info(f"💰 仓位计算:")
|
||
logger.info(f" 账户价值: ${current_balance:.2f}")
|
||
logger.info(f" 可用余额: ${available_balance:.2f}")
|
||
logger.info(f" 总持仓价值: ${total_position_value:.2f}")
|
||
logger.info(f" 当前总杠杆: {current_total_leverage:.2f}x")
|
||
logger.info(f" 计划杠杆: {leverage}x")
|
||
logger.info(f" 最大可开仓金额: ${max_position_usd:.2f} (限制: min(配置${max_by_config:.0f}, 可用${max_by_available:.0f}, 杠杆${max_by_total_leverage:.0f}))")
|
||
logger.info(f" 计算数量: {size} (精度: {sz_decimals}位) @ ${current_price:.2f}")
|
||
|
||
return size
|
||
|
||
except Exception as e:
|
||
logger.error(f"计算仓位大小失败: {e}")
|
||
# 发生错误时返回 0,不开仓
|
||
return 0
|
||
|
||
def _calculate_price_change(self, h1_data: pd.DataFrame) -> str:
|
||
"""计算24小时价格变化"""
|
||
if len(h1_data) < 24:
|
||
return "N/A"
|
||
price_now = h1_data.iloc[-1]['close']
|
||
price_24h_ago = h1_data.iloc[-24]['close']
|
||
change = ((price_now - price_24h_ago) / price_24h_ago) * 100
|
||
if change >= 0:
|
||
return f"+{change:.2f}%"
|
||
return f"{change:.2f}%"
|
||
|
||
def _validate_data(self, data: Dict[str, pd.DataFrame]) -> bool:
|
||
"""验证数据完整性"""
|
||
required_intervals = ['5m', '15m', '1h', '4h', '1d']
|
||
for interval in required_intervals:
|
||
if interval not in data or data[interval].empty:
|
||
return False
|
||
if len(data[interval]) < 20:
|
||
return False
|
||
return True
|
||
|
||
def _should_send_signal(self, symbol: str, signal: Dict[str, Any]) -> bool:
|
||
"""判断是否应该发送信号"""
|
||
action = signal.get('action', 'wait')
|
||
if action == 'wait':
|
||
return False
|
||
|
||
confidence = signal.get('confidence', 0)
|
||
# 使用配置文件中的阈值
|
||
threshold = self.settings.crypto_llm_threshold * 100 # 转换为百分比
|
||
if confidence < threshold:
|
||
return False
|
||
|
||
# 检查冷却时间(30分钟内不重复发送相同方向的信号)
|
||
if symbol in self.signal_cooldown:
|
||
cooldown_end = self.signal_cooldown[symbol] + timedelta(minutes=30)
|
||
if datetime.now() < cooldown_end:
|
||
if symbol in self.last_signals:
|
||
if self.last_signals[symbol].get('action') == action:
|
||
logger.debug(f"{symbol} 信号冷却中,跳过")
|
||
return False
|
||
|
||
return True
|
||
|
||
async def _review_and_adjust_positions(
|
||
self,
|
||
symbol: str,
|
||
data: Dict[str, pd.DataFrame]
|
||
):
|
||
"""
|
||
回顾并调整现有持仓(基于市场分析 + 当前持仓状态)
|
||
|
||
每次分析后自动回顾该交易对的所有持仓,让决策器决定是否需要:
|
||
- 调整止损止盈
|
||
- 部分平仓
|
||
- 全部平仓
|
||
"""
|
||
try:
|
||
# 获取该交易对的所有活跃持仓(只看已成交的)
|
||
active_orders = self.paper_trading.get_active_orders()
|
||
positions = [
|
||
order for order in active_orders
|
||
if order.get('symbol') == symbol
|
||
and order.get('status') == 'open'
|
||
and order.get('filled_price') # 只处理已成交的订单
|
||
]
|
||
|
||
if not positions:
|
||
return # 没有持仓需要回顾
|
||
|
||
logger.info(f"\n🔄 【持仓回顾中...】共 {len(positions)} 个持仓")
|
||
|
||
# TODO: 实现持仓回顾功能
|
||
# 1. 获取当前市场信号
|
||
# 2. 将持仓信息传递给决策器
|
||
# 3. 根据决策调整持仓
|
||
|
||
logger.info(" 持仓回顾功能待实现")
|
||
|
||
except Exception as e:
|
||
logger.error(f"持仓回顾失败: {e}", exc_info=True)
|
||
|
||
async def _notify_position_adjustment(
|
||
self,
|
||
symbol: str,
|
||
order_id: str,
|
||
decision: Dict[str, Any],
|
||
result: Dict[str, Any]
|
||
):
|
||
"""发送持仓调整通知"""
|
||
action = decision.get('action')
|
||
reason = decision.get('reason', '')
|
||
|
||
action_map = {
|
||
'ADJUST_SL_TP': '🔄 调整止损止盈',
|
||
'PARTIAL_CLOSE': '📤 部分平仓',
|
||
'FULL_CLOSE': '🚪 全部平仓'
|
||
}
|
||
action_text = action_map.get(action, action)
|
||
|
||
title = f"{action_text} - {symbol}"
|
||
|
||
content_parts = [
|
||
f"📊 **订单**: {order_id[:8]}",
|
||
f"📝 **原因**: {reason}",
|
||
]
|
||
|
||
if action == 'ADJUST_SL_TP':
|
||
changes = result.get('changes', [])
|
||
content_parts.append(f"🔄 **调整内容**: {', '.join(changes)}")
|
||
|
||
elif action in ['PARTIAL_CLOSE', 'FULL_CLOSE']:
|
||
pnl_info = result.get('pnl', {})
|
||
if pnl_info:
|
||
pnl = pnl_info.get('pnl', 0)
|
||
pnl_percent = pnl_info.get('pnl_percent', 0)
|
||
content_parts.append(f"💰 **实现盈亏**: ${pnl:+.2f} ({pnl_percent:+.1f}%)")
|
||
|
||
if action == 'PARTIAL_CLOSE':
|
||
close_percent = decision.get('close_percent', 0)
|
||
remaining = result.get('remaining_quantity', 0)
|
||
content_parts.append(f"📊 **平仓比例**: {close_percent:.0f}%")
|
||
content_parts.append(f"💵 **剩余仓位**: ${remaining:,.0f}")
|
||
|
||
content = "\n".join(content_parts)
|
||
|
||
if self.settings.feishu_enabled:
|
||
await self.feishu_paper.send_card(title, content, "blue")
|
||
if self.settings.telegram_enabled:
|
||
message = f"{title}\n\n{content}"
|
||
await self.telegram.send_message(message)
|
||
if self.settings.dingtalk_enabled:
|
||
await self.dingtalk.send_action_card(title, content)
|
||
|
||
async def analyze_once(self, symbol: str) -> Dict[str, Any]:
|
||
"""单次分析并返回市场信号与平台执行预览"""
|
||
data = self.exchange.get_multi_timeframe_data(symbol)
|
||
|
||
if not self._validate_data(data):
|
||
return {'error': '数据不完整'}
|
||
|
||
current_price = float(data['5m'].iloc[-1]['close'])
|
||
market_signal = await self.market_analyzer.analyze(
|
||
symbol, data,
|
||
symbols=self.symbols
|
||
)
|
||
|
||
signals = market_signal.get('signals', [])
|
||
threshold = self.settings.crypto_llm_threshold * 100
|
||
valid_signals = [
|
||
signal for signal in signals
|
||
if signal.get('action') in {'buy', 'sell'} and signal.get('confidence', 0) >= threshold
|
||
]
|
||
|
||
execution_preview: Dict[str, Any] = {}
|
||
|
||
if self.settings.paper_trading_enabled:
|
||
paper_positions, paper_account, paper_pending = self._get_paper_trading_state()
|
||
paper_signal = self._select_signal_for_platform(valid_signals, 'PaperTrading')
|
||
execution_preview['PaperTrading'] = self._normalize_execution_decision(
|
||
self.execute_signal_with_rules(
|
||
self._build_execution_signal(symbol, paper_signal, current_price),
|
||
'PaperTrading',
|
||
paper_account,
|
||
paper_positions,
|
||
paper_pending,
|
||
),
|
||
paper_positions,
|
||
paper_pending,
|
||
) if paper_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||
|
||
if self.hyperliquid:
|
||
hl_positions, hl_account, hl_pending = self._get_hyperliquid_trading_state()
|
||
hl_signal = self._select_signal_for_platform(valid_signals, 'Hyperliquid')
|
||
execution_preview['Hyperliquid'] = self._normalize_execution_decision(
|
||
self.execute_signal_with_rules(
|
||
self._build_execution_signal(symbol, hl_signal, current_price),
|
||
'Hyperliquid',
|
||
hl_account,
|
||
hl_positions,
|
||
hl_pending,
|
||
),
|
||
hl_positions,
|
||
hl_pending,
|
||
) if hl_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||
|
||
if self.bitget:
|
||
bg_positions, bg_account, bg_pending = self._get_bitget_trading_state()
|
||
bg_signal = self._select_signal_for_platform(valid_signals, 'Bitget')
|
||
execution_preview['Bitget'] = self._normalize_execution_decision(
|
||
self.execute_signal_with_rules(
|
||
self._build_execution_signal(symbol, bg_signal, current_price),
|
||
'Bitget',
|
||
bg_account,
|
||
bg_positions,
|
||
bg_pending,
|
||
),
|
||
bg_positions,
|
||
bg_pending,
|
||
) if bg_signal else {"decision": "HOLD", "action": "IGNORE", "reason": "无适配信号", "reasoning": "无适配信号"}
|
||
|
||
return {
|
||
'market_signal': market_signal,
|
||
'execution_preview': execution_preview,
|
||
}
|
||
|
||
def get_status(self) -> Dict[str, Any]:
|
||
"""获取智能体状态"""
|
||
return {
|
||
'running': self.running,
|
||
'symbols': self.symbols,
|
||
'mode': 'LLM 驱动',
|
||
'platform_halts': self.get_platform_halt_status(),
|
||
'analysis_monitor': self._analysis_monitor,
|
||
'analysis_notifications': self._analysis_notification_state,
|
||
'lane_analysis_state': self._lane_analysis_state,
|
||
'event_analysis_state': self._event_analysis_state,
|
||
'llm_schedule': {
|
||
'scan_interval_minutes': 5,
|
||
'intraday_cooldown_minutes': self.settings.crypto_intraday_llm_cooldown_minutes,
|
||
'trend_cooldown_minutes': self.settings.crypto_trend_llm_cooldown_minutes,
|
||
'force_surge_threshold': self.settings.crypto_force_llm_surge_threshold,
|
||
'force_trade_zone_pct': self.settings.crypto_force_llm_trade_zone_pct,
|
||
'event_analysis_enabled': self.settings.crypto_event_analysis_enabled,
|
||
'event_analysis_window_minutes': self.settings.crypto_event_analysis_window_minutes,
|
||
'event_analysis_price_change_percent': self.settings.crypto_event_analysis_price_change_percent,
|
||
'event_analysis_cooldown_minutes': self.settings.crypto_event_analysis_cooldown_minutes,
|
||
},
|
||
'last_signals': {
|
||
symbol: {
|
||
'type': sig.get('type'),
|
||
'action': sig.get('action'),
|
||
'confidence': sig.get('confidence'),
|
||
'grade': sig.get('grade')
|
||
}
|
||
for symbol, sig in self.last_signals.items()
|
||
},
|
||
'last_execution_preview': self.last_execution_preview,
|
||
'recent_analysis_events': self.get_recent_analysis_events(limit=30),
|
||
}
|
||
|
||
async def _notify_expired_orders_cancelled(self, cancelled_orders: List):
|
||
"""通知超时挂单已取消(模拟盘)"""
|
||
if not cancelled_orders:
|
||
return
|
||
|
||
for order in cancelled_orders:
|
||
symbol = order.get('symbol', 'Unknown')
|
||
message = (
|
||
f"⏰ 挂单超时已取消\n\n"
|
||
f"交易对: {symbol}\n"
|
||
f"订单ID: {order.get('order_id', 'Unknown')}\n"
|
||
f"方向: {order.get('side', 'Unknown')}\n"
|
||
f"挂单时长: {order.get('age_hours', 0):.1f} 小时"
|
||
)
|
||
await self._send_alert_notification(f"⏰ [{symbol}] 挂单超时取消", message)
|
||
|
||
async def _check_pending_order_timeouts(self):
|
||
"""检查各平台的挂单超时"""
|
||
try:
|
||
for platform_name, executor in self.executors.items():
|
||
if self._is_platform_halted(platform_name):
|
||
continue
|
||
|
||
# 获取平台挂单
|
||
if platform_name == 'PaperTrading':
|
||
pending_orders = self.paper_trading.get_open_orders()
|
||
elif platform_name == 'Bitget':
|
||
pending_orders = self.bitget.get_open_orders() if self.bitget else []
|
||
elif platform_name == 'Hyperliquid':
|
||
pending_orders = self.hyperliquid.get_open_orders() if self.hyperliquid else []
|
||
else:
|
||
continue
|
||
|
||
if not pending_orders:
|
||
continue
|
||
|
||
# 检查超时
|
||
timeout_orders = executor.check_pending_order_timeout(pending_orders)
|
||
|
||
# 取消超时订单
|
||
for order_info in timeout_orders:
|
||
order_id = order_info.get('order_id')
|
||
symbol = order_info.get('symbol', '')
|
||
reason = order_info.get('reason', '')
|
||
|
||
logger.info(f" ⏰ [{platform_name}] {symbol} {reason}")
|
||
|
||
result = await executor.execute_cancel(order_id, symbol)
|
||
if result.get('success'):
|
||
logger.info(f" ✅ 已取消超时挂单: {order_id}")
|
||
# 发送通知
|
||
message = (
|
||
f"⏰ 挂单超时自动取消\n\n"
|
||
f"平台: {platform_name}\n"
|
||
f"交易对: {symbol}\n"
|
||
f"订单ID: {order_id}\n"
|
||
f"原因: {reason}"
|
||
)
|
||
await self._send_alert_notification(f"⏰ [{platform_name}] 挂单超时", message)
|
||
else:
|
||
error = result.get('error', '未知错误')
|
||
logger.error(f" ❌ 取消失败: {error}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"检查挂单超时失败: {e}")
|
||
|
||
async def _check_account_level_stop_loss(self) -> tuple[bool, str]:
|
||
"""
|
||
检查账户级止损(所有平台通用)
|
||
|
||
Returns:
|
||
(should_stop, reason) - 是否应该停止交易,以及原因
|
||
"""
|
||
try:
|
||
max_drawdown = self.settings.account_max_drawdown
|
||
alert_threshold = self.settings.account_drawdown_alert
|
||
alerts = []
|
||
|
||
platforms_to_check = self._get_risk_platforms()
|
||
|
||
for platform_name, platform_service in platforms_to_check:
|
||
try:
|
||
if self._is_platform_halted(platform_name):
|
||
halt_info = self._platform_halts.get(platform_name, {})
|
||
logger.warning(
|
||
f"[{platform_name}] 平台已熔断暂停,跳过账户止损检查: "
|
||
f"{halt_info.get('reason', '无原因')}"
|
||
)
|
||
continue
|
||
|
||
# 获取账户状态
|
||
if hasattr(platform_service, 'get_account_state'):
|
||
account_state = platform_service.get_account_state()
|
||
elif hasattr(platform_service, 'get_account_status'):
|
||
account_state = platform_service.get_account_status()
|
||
elif hasattr(platform_service, 'get_balance'):
|
||
account_state = platform_service.get_balance()
|
||
else:
|
||
logger.warning(f"[{platform_name}] 无法获取账户状态")
|
||
continue
|
||
|
||
# 获取当前余额(统一字段名)
|
||
current_balance = (
|
||
account_state.get('current_balance') or
|
||
account_state.get('account_value') or
|
||
account_state.get('balance') or
|
||
0
|
||
)
|
||
|
||
if current_balance <= 0:
|
||
logger.warning(f"[{platform_name}] 当前余额无效: {current_balance}")
|
||
continue
|
||
|
||
# 获取或记录初始余额(使用持久化机制)
|
||
initial_balance = self._get_initial_balance(platform_name, current_balance)
|
||
|
||
# 计算回撤
|
||
drawdown = (initial_balance - current_balance) / initial_balance
|
||
drawdown_pct = drawdown * 100
|
||
|
||
logger.info(f"📊 [{platform_name}] 账户状态: "
|
||
f"初始 ${initial_balance:.2f} → 当前 ${current_balance:.2f} "
|
||
f"(回撤 {drawdown_pct:.2f}%)")
|
||
|
||
# 检查是否触发警告
|
||
if drawdown >= alert_threshold and drawdown < max_drawdown:
|
||
warning_msg = (f"⚠️ [{platform_name}] 账户回撤警告: {drawdown_pct:.2f}% "
|
||
f"(警告线 {alert_threshold*100:.0f}%, 止损线 {max_drawdown*100:.0f}%)")
|
||
logger.warning(warning_msg)
|
||
alerts.append((platform_name, 'warning', warning_msg, drawdown_pct))
|
||
|
||
# 检查是否触发止损
|
||
elif drawdown >= max_drawdown:
|
||
critical_msg = (f"🚨 [{platform_name}] 触发账户级止损: "
|
||
f"回撤 {drawdown_pct:.2f}% >= 止损线 {max_drawdown*100:.0f}%")
|
||
logger.error(critical_msg)
|
||
|
||
# 立即平掉所有持仓
|
||
await self._emergency_close_all_positions(platform_name, platform_service)
|
||
self._mark_platform_halted(
|
||
platform_name,
|
||
reason=critical_msg,
|
||
drawdown_pct=drawdown_pct,
|
||
current_balance=current_balance,
|
||
initial_balance=initial_balance,
|
||
)
|
||
|
||
return True, critical_msg
|
||
|
||
except Exception as e:
|
||
logger.error(f"[{platform_name}] 检查账户止损失败: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
continue
|
||
|
||
# 发送警告通知(如果有)
|
||
if alerts:
|
||
for platform_name, level, msg, drawdown_pct in alerts:
|
||
await self._send_alert_notification(
|
||
f"⚠️ [{platform_name}] 账户回撤警告",
|
||
f"回撤: {drawdown_pct:.2f}%\n"
|
||
f"警告线: {alert_threshold*100:.0f}%\n"
|
||
f"止损线: {max_drawdown*100:.0f}%\n\n"
|
||
f"请密切监控账户风险!"
|
||
)
|
||
|
||
return False, ""
|
||
|
||
except Exception as e:
|
||
logger.error(f"检查账户级止损失败: {e}")
|
||
return False, ""
|
||
|
||
async def _emergency_close_all_positions(self, platform_name: str, platform_service):
|
||
"""
|
||
紧急平掉所有持仓(账户级止损触发时调用)
|
||
|
||
Args:
|
||
platform_name: 平台名称
|
||
platform_service: 平台服务实例
|
||
"""
|
||
try:
|
||
logger.info(f"🚨 [{platform_name}] 执行紧急平仓...")
|
||
|
||
# 获取所有持仓
|
||
if hasattr(platform_service, 'get_all_positions'):
|
||
positions = platform_service.get_all_positions()
|
||
elif hasattr(platform_service, 'get_open_positions'):
|
||
positions = platform_service.get_open_positions()
|
||
else:
|
||
logger.warning(f"[{platform_name}] 无法获取持仓列表")
|
||
return
|
||
|
||
if not positions:
|
||
logger.info(f"[{platform_name}] 无持仓,无需平仓")
|
||
return
|
||
|
||
logger.info(f"[{platform_name}] 需要平仓 {len(positions)} 个持仓")
|
||
|
||
if hasattr(platform_service, 'market_close_all'):
|
||
result = platform_service.market_close_all()
|
||
success_items = result.get('results') or result.get('result') or []
|
||
closed_count = sum(1 for item in success_items if item.get('success', True))
|
||
|
||
await self._send_alert_notification(
|
||
f"🚨 [{platform_name}] 紧急平仓完成",
|
||
f"触发原因: 账户回撤超过 {self.settings.account_max_drawdown*100:.0f}%\n"
|
||
f"平仓数量: {closed_count}/{len(positions)}\n\n"
|
||
f"⚠️ 该平台已暂停执行,请人工检查后手动恢复!"
|
||
)
|
||
|
||
logger.info(f"🚨 [{platform_name}] 紧急平仓完成: {closed_count}/{len(positions)}")
|
||
return
|
||
|
||
# 逐个平仓
|
||
closed_count = 0
|
||
for pos in positions:
|
||
try:
|
||
symbol = pos.get('symbol', pos.get('coin', ''))
|
||
|
||
# 获取平仓方法
|
||
close_method = None
|
||
if hasattr(platform_service, 'market_close_position'):
|
||
close_method = platform_service.market_close_position
|
||
elif hasattr(platform_service, 'close_position'):
|
||
close_method = platform_service.close_position
|
||
else:
|
||
logger.warning(f"[{platform_name}] 无法平仓 {symbol}: 无平仓方法")
|
||
continue
|
||
|
||
# 检查是否是async方法并正确调用
|
||
import asyncio
|
||
if asyncio.iscoroutinefunction(close_method):
|
||
result = await close_method(symbol)
|
||
else:
|
||
result = close_method(symbol)
|
||
|
||
if result and result.get('success', False):
|
||
closed_count += 1
|
||
logger.info(f" ✅ 平仓成功: {symbol}")
|
||
else:
|
||
error_msg = result.get('message', result.get('error', '未知错误')) if result else '无返回结果'
|
||
logger.error(f" ❌ 平仓失败: {symbol} - {error_msg}")
|
||
|
||
except Exception as e:
|
||
logger.error(f" ❌ 平仓异常: {symbol} - {e}")
|
||
|
||
# 发送紧急通知
|
||
await self._send_alert_notification(
|
||
f"🚨 [{platform_name}] 紧急平仓完成",
|
||
f"触发原因: 账户回撤超过 {self.settings.account_max_drawdown*100:.0f}%\n"
|
||
f"平仓数量: {closed_count}/{len(positions)}\n\n"
|
||
f"⚠️ 该平台已暂停执行,请人工检查后手动恢复!"
|
||
)
|
||
|
||
logger.info(f"🚨 [{platform_name}] 紧急平仓完成: {closed_count}/{len(positions)}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"紧急平仓失败: {e}")
|
||
|
||
async def _check_position_management_all_platforms(self):
|
||
"""检查各平台的持仓管理(止盈/止损/移动止损)"""
|
||
try:
|
||
# 获取当前价格和 ATR 波动率
|
||
current_prices = {}
|
||
volatility_data = {}
|
||
for symbol in self.symbols:
|
||
try:
|
||
data = self.exchange.get_multi_timeframe_data(symbol)
|
||
current_prices[symbol] = float(data['5m'].iloc[-1]['close'])
|
||
# 提取 1h ATR 占价格的百分比,用于动态移动止损
|
||
if '1h' in data and 'atr' in data['1h'].columns:
|
||
atr_value = data['1h']['atr'].iloc[-1]
|
||
price_1h = data['1h']['close'].iloc[-1]
|
||
if atr_value and price_1h > 0:
|
||
volatility_data[symbol] = float(atr_value) / float(price_1h)
|
||
except:
|
||
continue
|
||
|
||
for platform_name, executor in self.executors.items():
|
||
if self._is_platform_halted(platform_name):
|
||
continue
|
||
|
||
# 获取平台持仓
|
||
if platform_name == 'PaperTrading':
|
||
positions = self.paper_trading.get_open_positions()
|
||
elif platform_name == 'Bitget':
|
||
positions = self.bitget.get_open_positions() if self.bitget else []
|
||
elif platform_name == 'Hyperliquid':
|
||
positions = self.hyperliquid.get_open_positions() if self.hyperliquid else []
|
||
else:
|
||
continue
|
||
|
||
if not positions:
|
||
continue
|
||
|
||
# 检查持仓管理(传递 ATR 波动率数据)
|
||
actions = executor.check_position_management(positions, current_prices, volatility_data)
|
||
|
||
# 执行建议的操作
|
||
for action_info in actions:
|
||
symbol = action_info.get('symbol')
|
||
action = action_info.get('action')
|
||
reason = action_info.get('reason', '')
|
||
|
||
logger.info(f" 📊 [{platform_name}] {symbol} {reason}")
|
||
|
||
# 执行操作
|
||
if action == 'TAKE_PROFIT':
|
||
# 达到目标盈利,平仓
|
||
normalized_symbol = self._normalize_symbol(symbol)
|
||
# 从持仓列表中找到对应的 order_id
|
||
close_order_ids = [
|
||
p.get('order_id') for p in positions
|
||
if self._normalize_symbol(p.get('symbol', '')) == normalized_symbol and p.get('order_id')
|
||
]
|
||
decision = {
|
||
'decision': 'CLOSE',
|
||
'symbol': normalized_symbol,
|
||
'orders_to_close': close_order_ids,
|
||
'reason': reason
|
||
}
|
||
result = await executor.execute_close(decision, current_prices.get(symbol, 0))
|
||
if result.get('success'):
|
||
logger.info(f" ✅ 自动止盈成功: {symbol}")
|
||
await self._send_alert_notification(
|
||
f"💰 [{platform_name}] 自动止盈",
|
||
f"交易对: {symbol}\n原因: {reason}"
|
||
)
|
||
|
||
elif action == 'TIME_EXIT':
|
||
# 持仓超时,平仓
|
||
normalized_symbol = self._normalize_symbol(symbol)
|
||
close_order_ids = [
|
||
p.get('order_id') for p in positions
|
||
if self._normalize_symbol(p.get('symbol', '')) == normalized_symbol and p.get('order_id')
|
||
]
|
||
decision = {
|
||
'decision': 'CLOSE',
|
||
'symbol': normalized_symbol,
|
||
'orders_to_close': close_order_ids,
|
||
'reason': reason
|
||
}
|
||
result = await executor.execute_close(decision, current_prices.get(symbol, 0))
|
||
if result.get('success'):
|
||
logger.info(f" ✅ 持仓超时平仓成功: {symbol}")
|
||
await self._send_alert_notification(
|
||
f"⏰ [{platform_name}] 持仓超时平仓",
|
||
f"交易对: {symbol}\n原因: {reason}"
|
||
)
|
||
|
||
elif action == 'MOVE_SL':
|
||
# 移动止损
|
||
new_sl = action_info.get('new_sl')
|
||
pnl_pct = action_info.get('pnl_pct', 0) # 从 action_info 获取盈亏百分比
|
||
|
||
if new_sl:
|
||
# 调用执行器的移动止损方法
|
||
move_result = await executor.move_stop_loss(
|
||
symbol=symbol,
|
||
new_stop_loss=new_sl
|
||
)
|
||
|
||
if move_result.get('success'):
|
||
logger.info(f" ✅ 移动止损成功: {symbol} → ${new_sl:.2f}")
|
||
await self._send_alert_notification(
|
||
f"🔒 [{platform_name}] 移动止损",
|
||
f"交易对: {symbol}\n新止损: ${new_sl:.2f}\n原因: {reason}"
|
||
)
|
||
|
||
# 发送飞书通知
|
||
await executor.send_execution_notification(
|
||
operation='POSITION_MANAGEMENT',
|
||
symbol=symbol,
|
||
result={'success': True, 'action': 'MOVE_SL', 'reason': reason},
|
||
details={'new_sl': new_sl, 'pnl_percent': pnl_pct}
|
||
)
|
||
else:
|
||
logger.warning(f" ⚠️ 移动止损失败: {move_result.get('message')}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"检查持仓管理失败: {e}")
|
||
|
||
# ==================== 平台熔断状态 ====================
|
||
|
||
def _get_risk_platforms(self) -> List[tuple[str, Any]]:
|
||
platforms_to_check = []
|
||
if self.paper_trading:
|
||
platforms_to_check.append(('PaperTrading', self.paper_trading))
|
||
if self.bitget:
|
||
platforms_to_check.append(('Bitget', self.bitget))
|
||
if self.hyperliquid:
|
||
platforms_to_check.append(('Hyperliquid', self.hyperliquid))
|
||
return platforms_to_check
|
||
|
||
def _load_platform_halts(self):
|
||
"""从文件加载平台熔断状态。"""
|
||
try:
|
||
import json
|
||
from pathlib import Path
|
||
|
||
file_path = Path("data/platform_halts.json")
|
||
if file_path.exists():
|
||
with open(file_path, 'r') as f:
|
||
self._platform_halts = json.load(f)
|
||
logger.info(f"📂 已加载平台熔断状态: {self._platform_halts}")
|
||
else:
|
||
self._platform_halts = {}
|
||
except Exception as e:
|
||
logger.error(f"加载平台熔断状态失败: {e}")
|
||
self._platform_halts = {}
|
||
|
||
def _save_platform_halts(self):
|
||
"""保存平台熔断状态到文件。"""
|
||
try:
|
||
import json
|
||
from pathlib import Path
|
||
|
||
Path("data").mkdir(exist_ok=True)
|
||
file_path = Path("data/platform_halts.json")
|
||
with open(file_path, 'w') as f:
|
||
json.dump(self._platform_halts, f, indent=2, ensure_ascii=False)
|
||
logger.info(f"💾 已保存平台熔断状态: {self._platform_halts}")
|
||
except Exception as e:
|
||
logger.error(f"保存平台熔断状态失败: {e}")
|
||
|
||
def _is_platform_halted(self, platform_name: str) -> bool:
|
||
info = self._platform_halts.get(platform_name, {})
|
||
return bool(info.get('halted'))
|
||
|
||
def _mark_platform_halted(
|
||
self,
|
||
platform_name: str,
|
||
*,
|
||
reason: str,
|
||
drawdown_pct: float,
|
||
current_balance: float,
|
||
initial_balance: float,
|
||
):
|
||
self._platform_halts[platform_name] = {
|
||
'halted': True,
|
||
'reason': reason,
|
||
'drawdown_pct': round(drawdown_pct, 2),
|
||
'current_balance': round(current_balance, 2),
|
||
'initial_balance': round(initial_balance, 2),
|
||
'halted_at': datetime.now().isoformat(),
|
||
}
|
||
self._save_platform_halts()
|
||
logger.warning(f"🛑 [{platform_name}] 已标记为平台熔断暂停")
|
||
if self.settings.feishu_enabled and self.feishu_error:
|
||
asyncio.create_task(
|
||
self.feishu_error.send_card(
|
||
f"🛑 [{platform_name}] 平台已停机",
|
||
"\n".join([
|
||
f"**原因**: {reason}",
|
||
f"**回撤**: {drawdown_pct:.2f}%",
|
||
f"**当前权益**: ${current_balance:,.2f}",
|
||
f"**初始权益**: ${initial_balance:,.2f}",
|
||
]),
|
||
"red",
|
||
)
|
||
)
|
||
|
||
def get_platform_halt_status(self) -> Dict[str, Any]:
|
||
result = {}
|
||
for platform_name in ['PaperTrading', 'Bitget', 'Hyperliquid']:
|
||
info = self._platform_halts.get(platform_name, {})
|
||
result[platform_name] = {
|
||
'halted': bool(info.get('halted')),
|
||
'reason': info.get('reason', ''),
|
||
'drawdown_pct': info.get('drawdown_pct'),
|
||
'halted_at': info.get('halted_at'),
|
||
'current_balance': info.get('current_balance'),
|
||
'initial_balance': info.get('initial_balance'),
|
||
}
|
||
return result
|
||
|
||
def resume_platform(self, platform_name: str) -> Dict[str, Any]:
|
||
valid_platforms = {'PaperTrading', 'Bitget', 'Hyperliquid'}
|
||
if platform_name not in valid_platforms:
|
||
raise ValueError(f"不支持的平台: {platform_name}")
|
||
|
||
platform_service = {
|
||
'PaperTrading': self.paper_trading,
|
||
'Bitget': self.bitget,
|
||
'Hyperliquid': self.hyperliquid,
|
||
}.get(platform_name)
|
||
|
||
if not platform_service:
|
||
raise ValueError(f"平台未启用: {platform_name}")
|
||
|
||
current_balance = 0.0
|
||
if hasattr(platform_service, 'get_account_state'):
|
||
state = platform_service.get_account_state()
|
||
elif hasattr(platform_service, 'get_account_status'):
|
||
state = platform_service.get_account_status()
|
||
else:
|
||
state = {}
|
||
|
||
current_balance = (
|
||
state.get('current_balance')
|
||
or state.get('account_value')
|
||
or state.get('balance')
|
||
or 0.0
|
||
)
|
||
current_balance = float(current_balance or 0.0)
|
||
if current_balance <= 0:
|
||
raise ValueError(f"{platform_name} 当前余额无效,无法恢复")
|
||
|
||
self._initial_balances[platform_name] = current_balance
|
||
self._save_initial_balances()
|
||
|
||
previous = self._platform_halts.get(platform_name, {})
|
||
self._platform_halts[platform_name] = {
|
||
'halted': False,
|
||
'reason': '',
|
||
'drawdown_pct': 0.0,
|
||
'halted_at': None,
|
||
'current_balance': round(current_balance, 2),
|
||
'initial_balance': round(current_balance, 2),
|
||
'resumed_at': datetime.now().isoformat(),
|
||
'previous_reason': previous.get('reason', ''),
|
||
}
|
||
self._save_platform_halts()
|
||
logger.info(f"✅ [{platform_name}] 已手动恢复,初始权益重置为 ${current_balance:.2f}")
|
||
if self.settings.feishu_enabled and self.feishu_error:
|
||
asyncio.create_task(
|
||
self.feishu_error.send_card(
|
||
f"✅ [{platform_name}] 平台已恢复",
|
||
"\n".join([
|
||
f"**当前权益**: ${current_balance:,.2f}",
|
||
f"**重置初始权益**: ${current_balance:,.2f}",
|
||
f"**恢复时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||
]),
|
||
"green",
|
||
)
|
||
)
|
||
return self._platform_halts[platform_name]
|
||
|
||
# ==================== 初始余额持久化 ====================
|
||
|
||
def _load_initial_balances(self):
|
||
"""从文件加载初始余额"""
|
||
try:
|
||
import json
|
||
from pathlib import Path
|
||
|
||
file_path = Path("data/initial_balances.json")
|
||
if file_path.exists():
|
||
with open(file_path, 'r') as f:
|
||
self._initial_balances = json.load(f)
|
||
logger.info(f"📂 已加载初始余额: {self._initial_balances}")
|
||
else:
|
||
logger.info(f"📂 初始余额文件不存在,将在首次运行时创建")
|
||
self._initial_balances = {}
|
||
|
||
except Exception as e:
|
||
logger.error(f"加载初始余额失败: {e}")
|
||
self._initial_balances = {}
|
||
|
||
def _save_initial_balances(self):
|
||
"""保存初始余额到文件"""
|
||
try:
|
||
import json
|
||
from pathlib import Path
|
||
|
||
# 确保目录存在
|
||
Path("data").mkdir(exist_ok=True)
|
||
|
||
file_path = Path("data/initial_balances.json")
|
||
with open(file_path, 'w') as f:
|
||
json.dump(self._initial_balances, f, indent=2)
|
||
|
||
logger.info(f"💾 已保存初始余额: {self._initial_balances}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"保存初始余额失败: {e}")
|
||
|
||
def _get_initial_balance(self, platform_name: str, current_balance: float) -> float:
|
||
"""
|
||
获取或设置平台的初始余额
|
||
|
||
Args:
|
||
platform_name: 平台名称
|
||
current_balance: 当前余额
|
||
|
||
Returns:
|
||
初始余额
|
||
"""
|
||
if platform_name not in self._initial_balances:
|
||
# 第一次运行,记录当前余额作为初始余额
|
||
self._initial_balances[platform_name] = current_balance
|
||
self._save_initial_balances()
|
||
logger.info(f"✨ [{platform_name}] 记录初始余额: ${current_balance:.2f}")
|
||
|
||
return self._initial_balances[platform_name]
|
||
|
||
async def _send_alert_notification(self, title: str, message: str):
|
||
"""发送告警通知(飞书/钉钉/Telegram)"""
|
||
try:
|
||
# 飞书
|
||
if self.feishu_error:
|
||
await self.feishu_error.send_text(f"{title}\n\n{message}")
|
||
elif self.feishu:
|
||
await self.feishu.send_text(f"{title}\n\n{message}")
|
||
|
||
# 钉钉
|
||
if self.dingtalk:
|
||
await self.dingtalk.send_text(f"{title}\n\n{message}")
|
||
|
||
# Telegram
|
||
if self.telegram:
|
||
await self.telegram.send_message(f"{title}\n\n{message}")
|
||
except Exception as e:
|
||
logger.error(f"发送告警通知失败: {e}")
|
||
|
||
|
||
# 全局单例
|
||
_crypto_agent: Optional['CryptoAgent'] = None
|
||
|
||
|
||
def get_crypto_agent() -> 'CryptoAgent':
|
||
"""获取加密货币智能体单例"""
|
||
# 直接使用类单例,不使用全局变量(避免 reload 时重置)
|
||
return CryptoAgent()
|