stock-ai-agent/backend/app/crypto_agent/crypto_agent.py
2026-04-28 15:48:42 +08:00

5165 lines
239 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
加密货币交易智能体 - 主控制器LLM 驱动版)
"""
import asyncio
import math
from collections import deque, defaultdict
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.crypto_agent.execution_guardian import ExecutionGuardian
from app.crypto_agent.execution_targets import ExecutionTarget, build_default_execution_targets
from app.utils.system_status import get_system_monitor, AgentStatus
from app.utils.signal_text import (
humanize_entry_basis,
humanize_setup_basis,
humanize_setup_type,
)
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%(支持超激进配置)
'min_position_value_balance_ratio': 1.0, # 合约单最低名义仓位 = 1x 账户权益
},
'PaperTrading': {
'min_margin': {}, # 无最小限制
'max_margin_pct': 0.25, # 单笔最大25%(与实盘一致)
'min_position_value_balance_ratio': 1.0,
}
}
PLATFORM_SIGNAL_PRIORITY = {
'PaperTrading': ['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,
},
}
SETUP_EXECUTION_PROFILES = {
'breakout_confirmation': {
'margin_multiplier': 0.75,
'max_margin_pct_cap': 0.12,
'same_direction_position_policy': 'no_add',
'same_direction_pending_policy': 'no_replace',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 3,
'allow_close_opposite_on_small_loss': False,
},
'breakout_pullback': {
'margin_multiplier': 0.95,
'max_margin_pct_cap': 0.18,
'same_direction_position_policy': 'hold',
'same_direction_pending_policy': 'replace_better',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 2,
},
'trend_continuation_pullback': {
'margin_multiplier': 1.0,
'max_margin_pct_cap': 0.18,
'same_direction_position_policy': 'scale_in',
'same_direction_pending_policy': 'replace_better',
'max_same_side_pending': 2,
'opposite_flip_confidence_delta': 2,
},
'deep_pullback_continuation': {
'margin_multiplier': 0.8,
'max_margin_pct_cap': 0.12,
'same_direction_position_policy': 'scale_in_only_if_deep_edge',
'same_direction_pending_policy': 'replace_better',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 4,
},
'range_reversal': {
'margin_multiplier': 0.7,
'max_margin_pct_cap': 0.10,
'same_direction_position_policy': 'no_add',
'same_direction_pending_policy': 'single_order_only',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 5,
'allow_close_opposite_on_small_loss': False,
},
'trend_reversal': {
'margin_multiplier': 0.55,
'max_margin_pct_cap': 0.08,
'same_direction_position_policy': 'no_add',
'same_direction_pending_policy': 'single_order_only',
'max_same_side_pending': 1,
'opposite_flip_confidence_delta': 8,
'allow_close_opposite_on_small_loss': False,
},
'default': {
'margin_multiplier': 1.0,
'max_margin_pct_cap': 0.18,
'same_direction_position_policy': 'scale_in',
'same_direction_pending_policy': 'replace_better',
'max_same_side_pending': 2,
'opposite_flip_confidence_delta': 0,
'allow_close_opposite_on_small_loss': True,
},
}
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()
# Bitget 实盘服务(按账号)
from app.services.bitget_live_trading_service import get_all_bitget_live_services
self.bitget_services = get_all_bitget_live_services()
self.bitget = self.bitget_services.get('default') or next(iter(self.bitget_services.values()), None)
if self.bitget_services:
logger.info(f"🔥 Bitget 实盘交易: 已启用 {len(self.bitget_services)} 个账号 {list(self.bitget_services.keys())}")
else:
logger.info(f"📊 Bitget 实盘交易: 未启用(仅模拟盘)")
# 初始化平台执行器
from app.crypto_agent.executor import PaperTradingExecutor, BitgetExecutor
self.executors = {}
self.bitget_executors: Dict[str, Any] = {}
# 模拟盘执行器
if self.settings.paper_trading_enabled:
self.executors['PaperTrading'] = PaperTradingExecutor()
logger.info(f" 📊 模拟盘执行器: 已初始化")
# Bitget 执行器
if self.bitget_services:
for account_id, service in self.bitget_services.items():
executor = BitgetExecutor(service=service, account_id=account_id)
self.bitget_executors[account_id] = executor
logger.info(f" 🔥 Bitget 执行器: 已初始化 account={account_id}")
self.executors['Bitget'] = self.bitget_executors.get('default') or next(iter(self.bitget_executors.values()), None)
self._execution_target_registry: List[ExecutionTarget] = []
self._register_default_execution_targets()
# 状态管理
self.last_signals: Dict[str, Dict[str, Any]] = {}
self.last_execution_preview: Dict[str, Dict[str, Any]] = {}
self.signal_cooldown: Dict[str, datetime] = {}
self.symbol_trade_cooldown: Dict[str, Dict[str, Any]] = {}
# 账户初始余额持久化(用于计算回撤)
self._initial_balances: Dict[str, float] = {}
self._load_initial_balances()
self._platform_halts: Dict[str, Dict[str, Any]] = {}
self._load_platform_halts()
self._target_execution_controls: Dict[str, Dict[str, Any]] = {}
self._load_target_execution_controls()
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._analysis_funnel_stats: Dict[str, Any] = {
"total_triggers": 0,
"scheduled_triggers": 0,
"event_triggers": 0,
"manual_triggers": 0,
"data_invalid_skips": 0,
"volatility_skips": 0,
"llm_lane_calls": {
"intraday": 0,
"trend": 0,
},
"llm_analyses": 0,
"cache_only_runs": 0,
"pre_regime_trade_signals": 0,
"post_regime_trade_signals": 0,
"regime_filtered_out": 0,
"no_trade_signal_runs": 0,
"threshold_filtered_runs": 0,
"valid_signal_runs": 0,
"valid_signals_total": 0,
"last_updated_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
self.execution_guardian = ExecutionGuardian(self)
# 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损
# key=target_key, value={order_id: {...}}
self._pending_tp_sl_by_target: Dict[str, Dict[str, Dict[str, Any]]] = {}
for account_id in self.bitget_services:
self._pending_tp_sl_by_target[f"Bitget:{account_id}"] = {}
# 配置
self.symbols = self.settings.crypto_symbols.split(',')
for account_id, service in self.bitget_services.items():
sync_result = service.sync_default_leverage(
self.symbols,
leverage=self.settings.bitget_default_leverage
)
logger.info(f"Bitget 默认杠杆同步结果 account={account_id}: {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, # 模拟交易始终启用
"bitget_enabled": bool(self.bitget_services),
"bitget_accounts": list(self.bitget_services.keys()),
"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,
"target_key": (extra or {}).get("target_key") if extra else "",
"account_id": (extra or {}).get("account_id") if extra else "",
"event_type": event_type,
"status": status,
"symbol": symbol or (decision or {}).get("symbol", ""),
"decision": (decision or {}).get("decision"),
"action": (decision or {}).get("action"),
"setup_type": (decision or {}).get("setup_type"),
"setup_basis": (decision or {}).get("setup_basis"),
"entry_basis": (decision or {}).get("entry_basis"),
"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 not self._is_target_execution_enabled(platform_name):
control_info = self._target_execution_controls.get(self._normalize_platform_key(platform_name), {})
control_reason = control_info.get("reason") or "该执行目标已被人工关闭自动交易"
return "自动交易关闭", control_reason
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 "最近一轮分析已完成"
intraday_threshold = self._get_signal_threshold_pct(signal_type='short_term')
trend_threshold = self._get_signal_threshold_pct(signal_type='medium_term')
title = "💓 [分析心跳] 系统运行正常"
content = "\n".join([
f"最近 {self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES} 分钟持续完成市场分析,但暂无达到阈值的可执行信号。",
"",
f"**分析轮次**: {cycle_completed}",
f"**完成分析**: {symbol_completed} 个交易对",
f"**跳过分析**: {symbol_skipped}",
f"**分析异常**: {symbol_errors}",
f"**信号阈值**: 日内 {intraday_threshold:.0f}% / 趋势 {trend_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": self._normalize_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 _get_pending_tp_sl_state(self, target_key: str = "Bitget:default") -> Dict[str, Dict[str, Any]]:
"""获取指定执行目标的待补保护单状态。"""
if not hasattr(self, "_pending_tp_sl_by_target") or not isinstance(self._pending_tp_sl_by_target, dict):
self._pending_tp_sl_by_target = {}
return self._pending_tp_sl_by_target.setdefault(target_key, {})
def _normalize_platform_key(self, platform_name: str) -> str:
"""统一平台/target 标识,兼容旧的单平台别名。"""
normalized = str(platform_name or "").strip()
if normalized == "Bitget":
return self._get_bitget_target_key("default")
if normalized in {"PaperTrading:default", "PaperTrading"}:
return "PaperTrading"
return normalized
def _get_bitget_target_key(self, account_id: str = "default") -> str:
normalized = (account_id or "default").strip() or "default"
return f"Bitget:{normalized}"
def _get_bitget_service(self, account_id: str = "default"):
normalized = (account_id or "default").strip() or "default"
return (getattr(self, 'bitget_services', {}) or {}).get(normalized)
def _iter_bitget_accounts(self) -> List[str]:
return list((getattr(self, 'bitget_services', {}) or {}).keys())
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]:
state = self._get_lane_state(symbol)
force, force_reason = self._detect_force_llm_trigger(symbol, data)
lanes: List[str] = ["intraday", "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"]
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):
normalized_symbol = self._normalize_symbol(task.get("symbol", ""))
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=normalized_symbol,
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}] 保护单不完整 - {normalized_symbol}",
"\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 _bump_analysis_stat(self, key: str, amount: int = 1):
self._analysis_funnel_stats[key] = int(self._analysis_funnel_stats.get(key, 0) or 0) + amount
self._analysis_funnel_stats["last_updated_at"] = datetime.now().isoformat()
def _bump_lane_call(self, lane: str, amount: int = 1):
lane_calls = self._analysis_funnel_stats.setdefault("llm_lane_calls", {})
lane_calls[lane] = int(lane_calls.get(lane, 0) or 0) + amount
self._analysis_funnel_stats["last_updated_at"] = datetime.now().isoformat()
def _summarize_recent_analysis_funnel(self, hours: int = 24) -> Dict[str, Any]:
cutoff = datetime.now() - timedelta(hours=hours)
events = []
for event in self._analysis_events:
timestamp = self._parse_iso_datetime(event.get("timestamp"))
if timestamp and timestamp >= cutoff:
events.append(event)
summary: Dict[str, Any] = {
"window_hours": hours,
"total_events": len(events),
"triggered_symbols": 0,
"llm_runs": 0,
"cache_only_runs": 0,
"data_invalid_skips": 0,
"volatility_skips": 0,
"no_trade_signal_runs": 0,
"threshold_filtered_runs": 0,
"valid_signal_runs": 0,
"valid_signals_total": 0,
"symbols": {},
"lane_calls": {
"intraday": 0,
"trend": 0,
},
"lane_signal_counts": {
"short_term_pre": 0,
"medium_term_pre": 0,
"short_term_post": 0,
"medium_term_post": 0,
},
"blocked_reason_counts": {},
}
symbol_stats: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
"triggers": 0,
"llm_runs": 0,
"cache_only_runs": 0,
"data_invalid_skips": 0,
"volatility_skips": 0,
"no_trade_signal_runs": 0,
"threshold_filtered_runs": 0,
"valid_signal_runs": 0,
"valid_signals_total": 0,
"last_status": None,
"last_detail": None,
"last_event_at": None,
"lane_calls": {
"intraday": 0,
"trend": 0,
},
"lane_signal_counts": {
"short_term_pre": 0,
"medium_term_pre": 0,
"short_term_post": 0,
"medium_term_post": 0,
},
"blocked_reason_counts": {},
})
blocked_reason_counts: Dict[str, int] = defaultdict(int)
for event in reversed(events):
event_type = event.get("event_type")
symbol = event.get("symbol") or ""
stats = symbol_stats[symbol] if symbol else None
if event_type == "symbol_analysis_started":
summary["triggered_symbols"] += 1
if stats is not None:
stats["triggers"] += 1
if event_type == "llm_lane_plan":
cache_only = bool(event.get("cache_only"))
lanes_to_run = event.get("lanes_to_run") or []
if cache_only:
summary["cache_only_runs"] += 1
if stats is not None:
stats["cache_only_runs"] += 1
else:
summary["llm_runs"] += 1
if stats is not None:
stats["llm_runs"] += 1
for lane in lanes_to_run:
if lane in summary["lane_calls"]:
summary["lane_calls"][lane] += 1
if stats is not None and lane in stats["lane_calls"]:
stats["lane_calls"][lane] += 1
if event_type == "symbol_analysis_skipped":
detail = str(event.get("detail") or "")
if "数据不完整" in detail:
summary["data_invalid_skips"] += 1
if stats is not None:
stats["data_invalid_skips"] += 1
if "波动率过低" in detail:
summary["volatility_skips"] += 1
if stats is not None:
stats["volatility_skips"] += 1
if event_type == "symbol_analysis_completed":
trade_signals = int(event.get("trade_signals", 0) or 0)
valid_signals = int(event.get("valid_signals", 0) or 0)
detail = str(event.get("detail") or "")
pre_lane_counts = event.get("pre_regime_lane_signal_counts") or {}
post_lane_counts = event.get("post_regime_lane_signal_counts") or {}
summary["lane_signal_counts"]["short_term_pre"] += int(pre_lane_counts.get("short_term", 0) or 0)
summary["lane_signal_counts"]["medium_term_pre"] += int(pre_lane_counts.get("medium_term", 0) or 0)
summary["lane_signal_counts"]["short_term_post"] += int(post_lane_counts.get("short_term", 0) or 0)
summary["lane_signal_counts"]["medium_term_post"] += int(post_lane_counts.get("medium_term", 0) or 0)
if stats is not None:
stats["lane_signal_counts"]["short_term_pre"] += int(pre_lane_counts.get("short_term", 0) or 0)
stats["lane_signal_counts"]["medium_term_pre"] += int(pre_lane_counts.get("medium_term", 0) or 0)
stats["lane_signal_counts"]["short_term_post"] += int(post_lane_counts.get("short_term", 0) or 0)
stats["lane_signal_counts"]["medium_term_post"] += int(post_lane_counts.get("medium_term", 0) or 0)
event_reason_counts = event.get("blocked_reason_counts") or {}
for reason_key, count in event_reason_counts.items():
blocked_reason_counts[str(reason_key)] += int(count or 0)
if stats is not None:
symbol_reason_counts = stats.setdefault("blocked_reason_counts", {})
symbol_reason_counts[str(reason_key)] = int(symbol_reason_counts.get(str(reason_key), 0) or 0) + int(count or 0)
if trade_signals == 0:
summary["no_trade_signal_runs"] += 1
if stats is not None:
stats["no_trade_signal_runs"] += 1
elif valid_signals == 0:
summary["threshold_filtered_runs"] += 1
if stats is not None:
stats["threshold_filtered_runs"] += 1
else:
summary["valid_signal_runs"] += 1
summary["valid_signals_total"] += valid_signals
if stats is not None:
stats["valid_signal_runs"] += 1
stats["valid_signals_total"] += valid_signals
if stats is not None:
stats["last_status"] = event.get("status")
stats["last_detail"] = detail
stats["last_event_at"] = event.get("timestamp")
summary["symbols"] = dict(sorted(
(
(symbol, payload)
for symbol, payload in symbol_stats.items()
if symbol
),
key=lambda item: (
-(item[1]["valid_signal_runs"] or 0),
-(item[1]["triggers"] or 0),
item[0],
)
))
summary["blocked_reason_counts"] = dict(sorted(blocked_reason_counts.items(), key=lambda item: (-item[1], item[0])))
return summary
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('notional', 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 执行**: 每轮双 lane 直接分析,不做冷却缓存",
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.execution_guardian.run_cycle()
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:
self._bump_analysis_stat("total_triggers")
if trigger_source == "schedule":
self._bump_analysis_stat("scheduled_triggers")
elif trigger_source == "event":
self._bump_analysis_stat("event_triggers")
else:
self._bump_analysis_stat("manual_triggers")
# 更新活动时间
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._bump_analysis_stat("data_invalid_skips")
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._bump_analysis_stat("volatility_skips")
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}")
self._bump_analysis_stat("llm_analyses")
for lane in lanes_to_run:
self._bump_lane_call(lane)
self._record_analysis_event(
"llm_lane_plan",
symbol=symbol,
status="info",
detail=force_reason or f"本轮执行 lane: {', '.join(lanes_to_run)}",
extra={
"lanes_to_run": lanes_to_run,
"cache_only": False,
"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
}
regime_profile = market_signal.get('regime_profile') or {}
# 过滤掉 wait 信号,只保留 buy/sell 信号
signals = market_signal.get('signals', [])
trade_signals = [s for s in signals if s.get('action') in ['buy', 'sell']]
pre_regime_trade_signals = int(market_signal.get('pre_regime_trade_signal_count', len(trade_signals)) or 0)
self._bump_analysis_stat("pre_regime_trade_signals", pre_regime_trade_signals)
self._bump_analysis_stat("post_regime_trade_signals", len(trade_signals))
filtered_out = max(0, pre_regime_trade_signals - len(trade_signals))
if filtered_out:
self._bump_analysis_stat("regime_filtered_out", filtered_out)
if not trade_signals:
self._bump_analysis_stat("no_trade_signal_runs")
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,
"blocked_reason_counts": market_signal.get("blocked_reason_counts") or {},
"pre_regime_lane_signal_counts": market_signal.get("pre_regime_lane_signal_counts") or {},
"post_regime_lane_signal_counts": market_signal.get("post_regime_lane_signal_counts") or {},
},
)
blocked_reasons = market_signal.get('blocked_reasons') or []
if blocked_reasons:
logger.info(f"\n⏸️ 结论: 当前市场状态不允许交易 | {''.join(blocked_reasons[:2])}")
else:
logger.info(f"\n⏸️ 结论: 无交易信号(仅有观望建议),继续观望")
return
# 检查是否有达到阈值的交易信号
valid_signals = self._filter_valid_trade_signals(trade_signals)
if not valid_signals:
self._bump_analysis_stat("threshold_filtered_runs")
self._analysis_monitor["last_analysis_completed_at"] = datetime.now().isoformat()
self._analysis_monitor["last_analysis_status"] = "completed"
thresholds_text = f"日内 {self._get_signal_threshold_pct(signal_type='short_term'):.0f}% / 趋势 {self._get_signal_threshold_pct(signal_type='medium_term'):.0f}%"
self._analysis_monitor["last_analysis_detail"] = f"完成分析,但无信号达到阈值 {thresholds_text}"
self._record_analysis_event(
"symbol_analysis_completed",
symbol=symbol,
status="success",
detail=f"完成分析,但无信号达到阈值 {thresholds_text}",
extra={
"trade_signals": len(trade_signals),
"valid_signals": 0,
"blocked_reason_counts": market_signal.get("blocked_reason_counts") or {},
"pre_regime_lane_signal_counts": market_signal.get("pre_regime_lane_signal_counts") or {},
"post_regime_lane_signal_counts": market_signal.get("post_regime_lane_signal_counts") or {},
},
)
logger.info(f"\n⏸️ 结论: 无交易信号达到分周期阈值(日内 {self._get_signal_threshold_pct(signal_type='short_term'):.0f}% / 趋势 {self._get_signal_threshold_pct(signal_type='medium_term'):.0f}%),继续观望")
return
self._bump_analysis_stat("valid_signal_runs")
self._bump_analysis_stat("valid_signals_total", len(valid_signals))
logger.info(f"\n✅ 发现 {len(valid_signals)} 个有效交易信号(按周期阈值过滤)")
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'),
regime_profile=regime_profile,
)
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 Bitget 实盘处理(按账号)
bitget_decisions: Dict[str, Dict[str, Any]] = {}
if self.bitget_services:
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'),
regime_profile=regime_profile,
)
for account_id in self._iter_bitget_accounts():
logger.info(f"\n🔥 【Bitget:{account_id}")
bg_positions, bg_account, bg_pending = self._get_bitget_trading_state(account_id)
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
)
bg_decision['account_id'] = account_id
bg_decision['target_key'] = self._get_bitget_target_key(account_id)
else:
logger.info(" 无可执行信号")
bg_decision = {
"decision": "HOLD",
"action": "IGNORE",
"reason": "无适配信号",
"reasoning": "无适配信号",
"account_id": account_id,
"target_key": self._get_bitget_target_key(account_id),
}
bitget_decisions[account_id] = bg_decision
else:
logger.info(f"⏸️ Bitget 实盘交易未启用")
self.last_execution_preview[symbol] = {
'timestamp': datetime.now().isoformat(),
'current_price': current_price,
'paper': paper_decision,
'bitget': bitget_decisions.get('default') or next(iter(bitget_decisions.values()), {"action": "IGNORE", "reason": "未启用"}),
'bitget_accounts': bitget_decisions,
}
# ============================================================
# 第三阶段:执行交易动作(各平台独立)
# ============================================================
await self._execute_decisions(paper_decision, bitget_decisions, 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),
"blocked_reason_counts": market_signal.get("blocked_reason_counts") or {},
"pre_regime_lane_signal_counts": market_signal.get("pre_regime_lane_signal_counts") or {},
"post_regime_lane_signal_counts": market_signal.get("post_regime_lane_signal_counts") or {},
},
)
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('notional', 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('notional', order.get('quantity', 0)),
'notional': order.get('notional', 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 _normalize_symbol(self, symbol: str) -> str:
"""统一交易对格式为 BTCUSDT"""
if not symbol:
return symbol
text = str(symbol).strip().upper()
if '/' in text:
text = text.split('/')[0]
if ':' in text:
text = text.split(':')[0]
text = text.replace('-', '').replace('_', '')
if text.endswith('USDTUSDT'):
text = text[:-4]
return text if text.endswith('USDT') else f"{text}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 register_execution_target(self, target: ExecutionTarget):
"""注册执行监管目标。"""
self._execution_target_registry = [
item for item in self._execution_target_registry
if item.target_key != target.target_key
]
self._execution_target_registry.append(target)
def _register_default_execution_targets(self):
"""注册默认执行监管目标。"""
self._execution_target_registry = []
for target in build_default_execution_targets(self):
if target.platform == "Bitget" and not target.pending_tpsl_state_key:
target.pending_tpsl_state_key = target.target_key
self.register_execution_target(target)
def get_execution_targets(self) -> List[ExecutionTarget]:
"""返回当前启用的执行监管目标列表。"""
return list(getattr(self, "_execution_target_registry", []))
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
def _get_setup_execution_profile(self, signal: Dict[str, Any]) -> Dict[str, Any]:
setup_type = signal.get('setup_type') or 'default'
profile = dict(self.SETUP_EXECUTION_PROFILES['default'])
profile.update(self.SETUP_EXECUTION_PROFILES.get(setup_type, {}))
profile['setup_type'] = setup_type
return profile
def _get_signal_threshold_pct(self, signal: Optional[Dict[str, Any]] = None, signal_type: Optional[str] = None) -> float:
resolved_type = signal_type or (signal or {}).get('timeframe') or (signal or {}).get('type') or 'medium_term'
if resolved_type == 'short_term':
threshold = getattr(self.settings, 'crypto_intraday_signal_threshold', None)
elif resolved_type == 'medium_term':
threshold = getattr(self.settings, 'crypto_trend_signal_threshold', None)
else:
threshold = None
if threshold is None:
threshold = getattr(self.settings, 'crypto_llm_threshold', 0.70)
return float(threshold) * 100
def _filter_valid_trade_signals(self, signals: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return [
signal for signal in (signals or [])
if signal.get('action') in {'buy', 'sell'}
and float(signal.get('confidence', 0) or 0) >= self._get_signal_threshold_pct(signal=signal)
]
async def _execute_decisions(self, paper_decision: Dict[str, Any],
bitget_decisions: Dict[str, Dict[str, Any]],
market_signal: Dict[str, Any], current_price: float):
"""执行交易决策(模拟盘 + Bitget 独立)"""
# 保存本轮所有达到阈值的可交易信号,避免分流后只落一条信号
symbol = market_signal.get('symbol')
signals = market_signal.get('signals', [])
valid_signals = self._filter_valid_trade_signals(signals)
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_target_execution_enabled('PaperTrading'):
self._record_execution_event(
"PaperTrading",
"execution_disabled_skip",
symbol=paper_decision.get("symbol", market_signal.get("symbol", "")),
decision=paper_decision,
reason="自动交易已关闭,跳过执行",
status="warning",
)
elif 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",
)
# ============================================================
# 执行 Bitget 决策
# ============================================================
bitget_summary_decisions: Dict[str, Dict[str, Any]] = {}
for account_id, bitget_decision in (bitget_decisions or {}).items():
target_key = self._get_bitget_target_key(account_id)
if bitget_decision and self._get_bitget_service(account_id) and not self._is_target_execution_enabled(target_key):
self._record_execution_event(
target_key,
"execution_disabled_skip",
symbol=bitget_decision.get("symbol", market_signal.get("symbol", "")),
decision=bitget_decision,
reason="自动交易已关闭,跳过执行",
status="warning",
extra={"account_id": account_id},
)
elif bitget_decision and self._get_bitget_service(account_id) and not self._is_platform_halted(target_key):
await self._execute_bitget_decisions(bitget_decision, market_signal, current_price, account_id=account_id)
elif bitget_decision and self._get_bitget_service(account_id) and self._is_platform_halted(target_key):
self._record_execution_event(
target_key,
"platform_halted_skip",
symbol=bitget_decision.get("symbol", market_signal.get("symbol", "")),
decision=bitget_decision,
reason="平台已停机,跳过执行",
status="warning",
extra={"account_id": account_id},
)
bitget_summary_decisions[target_key] = bitget_decision
await self._notify_execution_summary_if_needed(
market_signal=market_signal,
current_price=current_price,
decisions={
"PaperTrading": paper_decision,
**bitget_summary_decisions,
},
)
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,
"target_key": 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',
regime_profile: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""根据平台偏好和市场状态选择最适合执行的信号"""
if not signals:
return None
regime_profile = regime_profile or {}
allowed_lanes = set(regime_profile.get('allowed_lanes') or [])
if allowed_lanes:
signals = [
signal for signal in signals
if (signal.get('timeframe') or signal.get('type') or 'unknown') in allowed_lanes
]
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)
preferred_lanes = regime_profile.get('preferred_lanes') or []
if preferred_lanes:
lane_priority = [
lane for lane in preferred_lanes
if lane in {sig.get('timeframe') or sig.get('type') or 'unknown' for sig in adjusted_signals}
]
else:
lane_priority = self.PLATFORM_SIGNAL_PRIORITY.get(platform_name, ['short_term', 'medium_term'])
# 逆势信号大幅降权(安全网,主过滤在 _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
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,
'setup_type': signal.get('setup_type', 'unknown'),
'setup_basis': signal.get('setup_basis', ''),
'entry_basis': signal.get('entry_basis', ''),
'volume_price_context': signal.get('volume_price_context', {}),
'position_size': position_size,
'current_price': current_price,
'market_state': market_signal.get('market_state', '中性') if market_signal else '中性',
'regime': range_metrics.get('regime', ''),
'regime_profile': market_signal.get('regime_profile', {}) if market_signal else {},
'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'
rule = dict(self.SIGNAL_EXECUTION_RULES.get(signal_type, self.SIGNAL_EXECUTION_RULES['medium_term']))
profile = self._get_setup_execution_profile(signal)
if 'opposite_flip_confidence_delta' in profile:
rule['flip_confidence'] = min(
99,
rule.get('flip_confidence', 85) + int(profile.get('opposite_flip_confidence_delta', 0))
)
return rule
def _check_setup_execution_constraints(self, signal: Dict[str, Any]) -> tuple[bool, str]:
setup_type = signal.get('setup_type', 'unknown')
entry_type = signal.get('entry_type', 'market')
market_location = signal.get('market_location') or {}
volume_context = signal.get('volume_price_context') or {}
breakout_quality = volume_context.get('breakout_quality') or signal.get('breakout_quality')
pullback_quality = volume_context.get('pullback_quality') or signal.get('pullback_quality')
rejection_signal = volume_context.get('rejection_signal') or signal.get('rejection_signal')
exhaustion_risk = volume_context.get('exhaustion_risk') or signal.get('exhaustion_risk')
location_tag = market_location.get('location_tag', 'unknown')
if setup_type == 'breakout_confirmation':
if entry_type != 'market':
return False, "突破确认 setup 只能用 market 执行"
if breakout_quality not in {'acceptance_breakout_up', 'acceptance_breakout_down'}:
return False, "突破确认缺少接受型量价证据"
if setup_type in {'trend_continuation_pullback', 'deep_pullback_continuation', 'breakout_pullback'}:
if entry_type != 'limit':
return False, f"{setup_type} 应使用 limit 等待回踩/反抽"
if location_tag == 'far_from_trade_zone':
return False, f"{setup_type} 当前远离有效回踩交易区"
if pullback_quality != 'healthy_pullback':
return True, f"{setup_type} 缺少健康缩量回调证据,降级执行"
if location_tag == 'middle_of_range':
return True, f"{setup_type} 当前位于区间中部,降级执行"
if setup_type == 'range_reversal':
if location_tag == 'far_from_trade_zone':
return False, "区间反转 setup 远离关键交易区"
if location_tag not in {'near_range_support', 'near_range_resistance'}:
return True, "区间反转 setup 不在区间边界,降级执行"
if rejection_signal not in {'bullish_rejection', 'bearish_rejection'} and entry_type == 'market':
return False, "区间反转现价执行缺少明确拒绝信号"
if setup_type == 'trend_reversal':
if rejection_signal not in {'bullish_rejection', 'bearish_rejection'}:
return True, "趋势反转 setup 缺少明确拒绝信号,降级执行"
if exhaustion_risk in {'upside_climax', 'downside_climax'} and setup_type != 'trend_reversal':
return False, "当前量价处于高潮风险,非反转 setup 不执行"
return True, "setup 约束通过"
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 信号考虑替换挂单"
profile = self._get_setup_execution_profile(signal)
pending_policy = profile.get('same_direction_pending_policy', 'replace_better')
if pending_policy == 'no_replace':
return False, "当前 setup 不主动替换挂单"
if pending_policy == 'single_order_only':
return False, "当前 setup 仅保留单个边界挂单"
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:
signals = market_signal.get('signals', [])
valid_signals = self._filter_valid_trade_signals(signals)
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 ''
setup_type = best_signal.get('setup_type', 'unknown')
setup_basis = best_signal.get('setup_basis', '')
entry_basis = best_signal.get('entry_basis', '')
setup_type_text = humanize_setup_type(setup_type)
setup_basis_text = humanize_setup_basis(setup_basis)
entry_basis_text = humanize_entry_basis(entry_basis)
# 等级(基于信心度映射)- 与 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 setup_type and setup_type != 'unknown':
content_parts.append(f"🧩 **交易形态**: {setup_type_text}")
if setup_basis:
content_parts.append(f"📌 **形态依据**: {setup_basis_text}")
if entry_basis:
content_parts.append(f"🎯 **入场依据**: {entry_basis_text}")
content_parts.append("")
# 入场价格显示
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 = "", order_status: str = None,
execution_result: Optional[Dict[str, Any]] = None):
"""发送交易执行通知(第三阶段)
order_status: 限价单实际状态 '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', '')
account_id = decision.get('account_id', 'default')
target_key = decision.get('target_key', '')
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)
setup_type = best_signal.get('setup_type', 'unknown') if best_signal else 'unknown'
setup_basis = best_signal.get('setup_basis', '') if best_signal else ''
entry_basis = best_signal.get('entry_basis', '') if best_signal else ''
setup_type_text = humanize_setup_type(setup_type)
setup_basis_text = humanize_setup_basis(setup_basis)
entry_basis_text = humanize_entry_basis(entry_basis)
# 对限价单:用实际订单状态决定显示
# resting=真的在挂单中, filled=已立即成交, None=市价单或未知
if order_status == 'resting':
entry_type_text = '挂单'
entry_type_icon = ''
elif 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, '🌱 轻仓')
# 构建卡片标题:限价单区分实际状态
if decision_type == 'OPEN':
if order_status == 'resting':
decision_title = '挂单中'
elif 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 order_status == 'resting':
decision_title = '加仓挂单中'
elif 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"
# 构建卡片内容
execution_result = execution_result or {}
target_display = target_key or (prefix.strip() if prefix else "PaperTrading")
raw_margin = execution_result.get('margin', quantity if quantity != 'N/A' else 0)
margin = float(raw_margin) if isinstance(raw_margin, (int, float)) else 0
leverage = execution_result.get('leverage')
if leverage is None:
if str(target_display).startswith('Bitget:'):
leverage = decision.get('leverage') or self.settings.bitget_default_leverage
else:
leverage = getattr(self.paper_trading, 'leverage', 1)
raw_position_value = execution_result.get('actual_position_value')
if isinstance(raw_position_value, (int, float)) and raw_position_value > 0:
position_value = raw_position_value
else:
position_value = margin * leverage if isinstance(margin, (int, float)) and isinstance(leverage, (int, float)) else 'N/A'
position_value_display = f"${position_value:,.2f}" if isinstance(position_value, (int, float)) else "N/A"
contracts = execution_result.get('contracts')
# 根据入场方式显示不同的价格信息
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"🏷️ **执行目标**: {target_display}",
f"{action_icon} **操作**: {decision_text} ({action_text})",
f"🧾 **账号**: {account_id}",
f"🧭 **信号类型**: {timeframe_text}",
f"{entry_type_icon} **入场方式**: {entry_type_text}",
f"{position_display.replace(' ', ': **')} | 📈 信心度: **{confidence}%**",
f"",
]
if setup_type and setup_type != 'unknown':
content_parts.extend([
f"🧩 **交易形态**: {setup_type_text}",
f"📌 **形态依据**: {setup_basis_text}" if setup_basis else None,
f"🎯 **入场依据**: {entry_basis_text}" if entry_basis else None,
f"",
])
content_parts.extend([
f"💰 **名义仓位**: {position_value_display}",
f"🪙 **保证金 / 杠杆**: ${margin:,.2f} / {leverage}x" if isinstance(margin, (int, float)) and isinstance(leverage, (int, float)) else f"🪙 **保证金**: ${margin:,.2f}",
price_display,
])
content_parts = [part for part in content_parts if part is not None]
if isinstance(contracts, (int, float)) and contracts:
content_parts.append(f"📦 **合约张数**: {contracts}")
if stop_loss:
content_parts.append(f"🛑 **止损价**: ${stop_loss}")
if take_profit:
content_parts.append(f"🎯 **止盈价**: ${take_profit}")
# 决策理由和风险分析(挂单中不显示,等成交后再显示)
is_pending = (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} account={account_id} target={target_key}")
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_pricemarket单使用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
async def _notify_bitget_error(self, symbol: str, operation: str, error: str, account_id: str = "default", target_key: str = ""):
"""发送 Bitget 操作失败的飞书/钉钉/Telegram 通知"""
normalized_account_id = (account_id or "default").strip() or "default"
resolved_target_key = target_key or self._get_bitget_target_key(normalized_account_id)
title = f"❌ [{resolved_target_key}] 操作失败 - {symbol}"
content = "\n".join([
f"🏷️ **执行目标**: {resolved_target_key}",
f"🧾 **账号**: {normalized_account_id}",
f"🔴 **操作**: {operation}",
f"⚠️ **错误**: {error}",
f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
])
logger.error(f"[{resolved_target_key}] {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, account_id: str = "default") -> tuple:
"""
获取 Bitget 实盘交易状态(持仓和账户)
Returns:
(positions, account, pending_orders)
"""
bitget_service = self._get_bitget_service(account_id)
if not bitget_service:
return [], {
'current_balance': 0,
'initial_balance': None,
'used_margin': 0,
'available_balance': 0,
'available': 0,
'order_leverage': self.settings.bitget_default_leverage,
'total_position_value': 0,
'max_total_leverage': self.settings.bitget_max_total_leverage,
'current_total_leverage': 0,
'account_id': account_id,
}, []
# 1. 余额(独立 try确保余额始终可用
try:
bg_state = bitget_service.get_account_state()
except Exception as e:
logger.error(f"获取 Bitget 余额失败 account={account_id}: {e}")
bg_state = {"account_value": 0, "total_margin_used": 0, "available_balance": 0}
logger.info(
f"[Bitget:{account_id}] 余额: account_value=${bg_state['account_value']:.2f}, "
f"available=${bg_state['available_balance']:.2f}"
)
# 2. 持仓(独立 try
position_list = []
try:
for pos in bitget_service.get_open_positions():
coin = pos["coin"]
size = pos["size"]
if size != 0:
tp_sl = {}
try:
tp_sl = bitget_service.get_tp_sl_prices(coin)
except Exception as e:
logger.warning(f"获取 {coin} TP/SL 失败(不影响交易) account={account_id}: {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 持仓失败 account={account_id}: {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': bitget_service.initial_balance,
'used_margin': bg_state["total_margin_used"],
'available_balance': bg_state["available_balance"],
'available': bg_state["available_balance"], # 决策器期望的键名
'order_leverage': self.settings.bitget_default_leverage,
'total_position_value': total_position_value,
'max_total_leverage': bitget_service.max_total_leverage,
'account_id': account_id,
}
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 = bitget_service.get_open_orders()
for order in all_orders:
pending_orders.append({
'order_id': order.get('order_id'),
'symbol': order.get('symbol'),
'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'),
'account_id': account_id,
})
except Exception as e:
logger.error(f"获取 Bitget 挂单失败 account={account_id}: {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)
min_position_value_balance_ratio = float(rules.get('min_position_value_balance_ratio', 0) or 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)
setup_profile = self._get_setup_execution_profile(signal)
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,
)
setup_margin_multiplier = float(setup_profile.get('margin_multiplier', 1.0) or 1.0)
if setup_margin_multiplier != 1.0:
target_margin_pct *= setup_margin_multiplier
# 市场状态仓位调整:震荡市降低仓位,避免来回被止损
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
setup_margin_pct_cap = setup_profile.get('max_margin_pct_cap')
effective_max_margin_pct = min(max_margin_pct, setup_margin_pct_cap) if isinstance(setup_margin_pct_cap, (int, float)) else max_margin_pct
min_position_value = balance * min_position_value_balance_ratio if min_position_value_balance_ratio > 0 else 0.0
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=effective_max_margin_pct,
min_margin=min_margin,
min_position_value=min_position_value,
min_effective_leverage=min_effective_leverage,
)
if margin <= 0:
return 0, budget_reason
return margin, (
f"{sizing_reason} | setup={setup_profile.get('setup_type')} x{setup_margin_multiplier:.2f} | 平台: {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)
setup_profile = self._get_setup_execution_profile(signal)
position_policy = setup_profile.get('same_direction_position_policy', 'scale_in')
pending_policy = setup_profile.get('same_direction_pending_policy', 'replace_better')
max_same_side_pending = int(setup_profile.get('max_same_side_pending', 2) or 2)
# 检查同向持仓
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", "同向持仓已进入保本/保护态,不再主动加仓"
if position_policy == 'no_add':
return "HOLD", f"{setup_profile.get('setup_type')} setup 避免同向叠加仓位"
if position_policy == 'hold':
return "HOLD", f"{setup_profile.get('setup_type')} setup 有同向持仓时优先持有,不追加"
# 规则1: 价格距离足够 + 持仓已有浮盈 + 新价格更优 → 加仓
if better_price and price_diff_pct >= rule['min_add_price_gap_pct'] and pnl_pct >= rule['min_add_profit_pct']:
if position_policy == 'scale_in_only_if_deep_edge':
location_tag = (signal.get('market_location') or {}).get('location_tag')
if location_tag not in {'near_long_zone', 'near_short_zone', 'near_range_support', 'near_range_resistance'}:
return "HOLD", "深回踩 continuation 仅在关键交易区边缘允许加仓"
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)
)
if pending_policy == 'single_order_only':
return "HOLD", f"{setup_profile.get('setup_type')} setup 已有同向挂单,保持单一挂单"
# 规则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) < max_same_side_pending and pending_policy != 'no_replace' and signal_is_better:
return "OPEN", f"同向挂单价格差{price_diff_pct:.1f}% >= 2%,可开新单"
else:
return "IGNORE", f"同向挂单已达 setup 上限 {max_same_side_pending} 个,忽略"
# 无同向订单 → 正常开仓
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)
setup_profile = self._get_setup_execution_profile(signal)
allow_close_opposite_on_small_loss = bool(setup_profile.get('allow_close_opposite_on_small_loss', True))
# 检查反向持仓
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:
if not allow_close_opposite_on_small_loss:
return "WAIT", f"{setup_profile.get('setup_type')} setup 对反向小亏损仓位保持克制,等待进一步确认"
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 实盘暂不查询历史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 _get_symbol_cooldown_key(self, platform_name: str, symbol: str) -> str:
return f"{platform_name}:{self._normalize_symbol(symbol)}"
def _check_symbol_losing_streak(self, platform_name: str, symbol: str, max_lookback: int = 4) -> Dict[str, Any]:
"""
检查单个交易对近期连续亏损情况。
当前仅对模拟盘启用,实盘后续应由独立执行监管器基于成交回报维护。
"""
normalized_symbol = self._normalize_symbol(symbol)
recent_orders = []
if platform_name == 'PaperTrading' and self.paper_trading:
recent_orders = self.paper_trading.get_order_history(symbol=normalized_symbol, limit=max_lookback)
if not recent_orders:
return {
'symbol': normalized_symbol,
'losing_streak': 0,
'cooldown_hours': 0,
'cooldown_until': None,
'should_cool_down': False,
'reason': '',
}
losing_streak = 0
last_loss_order = None
for order in recent_orders:
pnl = order.get('pnl_amount', 0) or 0
if pnl < 0:
losing_streak += 1
if last_loss_order is None:
last_loss_order = order
else:
break
cooldown_hours = 0
if losing_streak >= 3:
cooldown_hours = 6
elif losing_streak >= 2:
cooldown_hours = 2
cooldown_until = None
if cooldown_hours > 0 and last_loss_order:
closed_at = last_loss_order.get('closed_at') or last_loss_order.get('updated_at') or last_loss_order.get('created_at')
if closed_at:
try:
closed_at_dt = datetime.fromisoformat(str(closed_at).replace('Z', '+00:00'))
cooldown_until = closed_at_dt + timedelta(hours=cooldown_hours)
except ValueError:
cooldown_until = None
return {
'symbol': normalized_symbol,
'losing_streak': losing_streak,
'cooldown_hours': cooldown_hours,
'cooldown_until': cooldown_until,
'should_cool_down': bool(cooldown_until and datetime.now(cooldown_until.tzinfo) < cooldown_until),
'reason': (
f"{normalized_symbol} 最近连续亏损 {losing_streak} 次,暂停新开仓 {cooldown_hours} 小时"
if cooldown_until and cooldown_hours > 0 else ''
),
}
def _refresh_symbol_trade_cooldown(self, platform_name: str, symbol: str) -> Dict[str, Any]:
info = self._check_symbol_losing_streak(platform_name, symbol)
key = self._get_symbol_cooldown_key(platform_name, symbol)
if info.get('should_cool_down'):
self.symbol_trade_cooldown[key] = info
else:
self.symbol_trade_cooldown.pop(key, None)
return info
def _get_symbol_trade_cooldown(self, platform_name: str, symbol: str) -> Optional[Dict[str, Any]]:
key = self._get_symbol_cooldown_key(platform_name, symbol)
cached = self.symbol_trade_cooldown.get(key)
if cached and cached.get('cooldown_until'):
cooldown_until = cached['cooldown_until']
now = datetime.now(cooldown_until.tzinfo) if getattr(cooldown_until, 'tzinfo', None) else datetime.now()
if now < cooldown_until:
return cached
self.symbol_trade_cooldown.pop(key, None)
return self._refresh_symbol_trade_cooldown(platform_name, symbol)
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. 杠杆限制检查
regime_profile = signal.get('regime_profile') or {}
tradability = regime_profile.get('tradability')
if tradability == 'avoid':
blocked_reasons = regime_profile.get('no_trade_reasons') or ['当前市场状态不允许交易']
return False, f"市场状态过滤: {''.join(blocked_reasons[:2])}"
setup_passed, setup_reason = self._check_setup_execution_constraints(signal)
if not setup_passed:
return False, f"setup 过滤: {setup_reason}"
if setup_reason != "setup 约束通过":
signal["_setup_execution_warning"] = setup_reason
logger.info(f"[{platform_name}] ⚠️ setup 降级执行: {setup_reason}")
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}%(做空拥挤),拒绝做空"
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')
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,
account_id: str = "default"):
"""执行 Bitget 决策(使用执行器)"""
try:
decision_type = decision.get('decision', 'HOLD')
symbol = decision.get('symbol', 'UNKNOWN')
next_decision = decision.get('next_decision')
target_key = self._get_bitget_target_key(account_id)
if decision_type == 'HOLD':
hold_reason = decision.get('reason', decision.get('reasoning', '观望'))
logger.info(f" {target_key} 决策: {hold_reason}")
self._record_execution_event(target_key, "hold", decision=decision, reason=hold_reason, status="hold", extra={"account_id": account_id})
# 仅记录日志,不发飞书通知(避免消息过多)
return
# 使用执行器
executor = (self.bitget_executors or {}).get(account_id)
if not executor:
logger.warning(f" ⚠️ {target_key} 执行器未初始化")
return
# 执行开仓/加仓
if decision_type in ['OPEN', 'ADD']:
logger.info(f" 准备执行 {target_key} 交易...")
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"{target_key} 交易成功: {order_id} ({order_status})")
decision['_execution_succeeded'] = True
self._record_execution_event(
target_key, "open_success", decision=decision, status="success",
reason=decision.get('reason', decision.get('reasoning', '')),
extra={"order_id": order_id, "order_status": order_status, "account_id": account_id},
)
# 发送通知
await self._send_signal_notification(
market_signal, decision, current_price,
prefix=f"[{target_key}]",
order_status=order_status,
execution_result=result,
)
# TP/SL 警告
if result.get('tp_sl_warning'):
await self._notify_bitget_error(symbol, "设置止盈止损", result['tp_sl_warning'], account_id=account_id, target_key=target_key)
# 记录待设置的 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 {}
pending_state = self._get_pending_tp_sl_state(target_key)
pending_state[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 ({target_key}, oid={order_id})")
else:
error = result.get('error', result.get('message', '未知错误'))
logger.error(f"{target_key} 交易失败: {error}")
self._record_execution_event(target_key, "open_failed", decision=decision, reason=error, status="error", extra={"account_id": account_id})
await self._notify_bitget_error(symbol, decision_type, error, account_id=account_id, target_key=target_key)
# 执行平仓
elif decision_type == 'CLOSE':
logger.info(f" 准备 {target_key} 平仓...")
result = await executor.execute_close(decision, current_price)
if result.get('success'):
logger.info(f"{target_key} 平仓成功")
decision['_execution_succeeded'] = True
self._record_execution_event(target_key, "close_success", decision=decision, status="success", extra={"account_id": account_id})
if next_decision:
await self._execute_bitget_decisions(next_decision, market_signal, current_price, account_id=account_id)
else:
error = result.get('error', '未知错误')
logger.error(f"{target_key} 平仓失败: {error}")
self._record_execution_event(target_key, "close_failed", decision=decision, reason=error, status="error", extra={"account_id": account_id})
await self._notify_bitget_error(symbol, "平仓", error, account_id=account_id, target_key=target_key)
# 执行撤单
elif decision_type == 'CANCEL_PENDING':
logger.info(f" 准备取消 {target_key} 挂单...")
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._get_pending_tp_sl_state(target_key).pop(order_id, None)
if success_count > 0:
logger.info(f"{target_key} 取消成功: {success_count} 个挂单")
decision['_execution_succeeded'] = True
self._record_execution_event(
target_key, "cancel_success", decision=decision, status="success",
extra={"cancelled_count": success_count, "account_id": account_id},
)
if next_decision:
await self._execute_bitget_decisions(next_decision, market_signal, current_price, account_id=account_id)
else:
error = "没有成功取消任何挂单"
logger.error(f"{target_key} 取消失败: {error}")
self._record_execution_event(target_key, "cancel_failed", decision=decision, reason=error, status="error", extra={"account_id": account_id})
await self._notify_bitget_error(symbol, "取消挂单", error, account_id=account_id, target_key=target_key)
else:
logger.warning(f" ⚠️ {target_key} 暂不支持的执行动作: {decision_type}")
self._record_execution_event(target_key, "unsupported_decision", decision=decision, reason=f"暂不支持的执行动作: {decision_type}", status="warning", extra={"account_id": account_id})
except Exception as e:
logger.error(f"{self._get_bitget_target_key(account_id)} 执行异常: {e}")
self._record_execution_event(self._get_bitget_target_key(account_id), "exception", decision=decision, reason=str(e), status="error", extra={"account_id": account_id})
await self._notify_bitget_error(symbol, decision.get('decision', 'UNKNOWN'), str(e), account_id=account_id, target_key=self._get_bitget_target_key(account_id))
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._get_signal_threshold_pct(signal=signal)
if confidence < threshold:
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', [])
valid_signals = self._filter_valid_trade_signals(signals)
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.bitget_services:
bg_signal = self._select_signal_for_platform(valid_signals, 'Bitget')
bitget_preview_accounts: Dict[str, Any] = {}
for account_id in self._iter_bitget_accounts():
bg_positions, bg_account, bg_pending = self._get_bitget_trading_state(account_id)
preview = 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": "无适配信号"}
preview['account_id'] = account_id
preview['target_key'] = self._get_bitget_target_key(account_id)
bitget_preview_accounts[account_id] = preview
execution_preview['Bitget'] = bitget_preview_accounts.get('default') or next(iter(bitget_preview_accounts.values()), {"action": "IGNORE", "reason": "未启用"})
execution_preview['BitgetAccounts'] = bitget_preview_accounts
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(),
'target_execution_controls': self.get_target_execution_status(),
'analysis_monitor': self._analysis_monitor,
'analysis_notifications': self._analysis_notification_state,
'analysis_funnel_stats': self._analysis_funnel_stats,
'analysis_funnel_24h': self._summarize_recent_analysis_funnel(hours=24),
'lane_analysis_state': self._lane_analysis_state,
'event_analysis_state': self._event_analysis_state,
'execution_guardian': self.execution_guardian.get_status(),
'llm_schedule': {
'scan_interval_minutes': 5,
'intraday_cooldown_minutes': 0,
'trend_cooldown_minutes': 0,
'intraday_signal_threshold': self._get_signal_threshold_pct(signal_type='short_term'),
'trend_signal_threshold': self._get_signal_threshold_pct(signal_type='medium_term'),
'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):
"""兼容旧入口,统一交由执行监管器处理。"""
logger.info("挂单超时检查已切换到 ExecutionGuardian旧入口仅作兼容转发")
await self.execution_guardian.run_cycle()
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):
"""兼容旧入口,统一交由执行监管器处理。"""
logger.info("持仓管理检查已切换到 ExecutionGuardian旧入口仅作兼容转发")
await self.execution_guardian.run_cycle()
# ==================== 平台熔断状态 ====================
def _get_risk_platforms(self) -> List[tuple[str, Any]]:
platforms_to_check = []
if self.paper_trading:
platforms_to_check.append(('PaperTrading', self.paper_trading))
for account_id, service in (getattr(self, 'bitget_services', {}) or {}).items():
platforms_to_check.append((self._get_bitget_target_key(account_id), service))
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 _load_target_execution_controls(self):
"""从文件加载目标级自动交易控制状态。"""
try:
import json
from pathlib import Path
file_path = Path("data/target_execution_controls.json")
if file_path.exists():
with open(file_path, 'r') as f:
self._target_execution_controls = json.load(f)
logger.info(f"📂 已加载目标执行控制状态: {self._target_execution_controls}")
else:
self._target_execution_controls = {}
except Exception as e:
logger.error(f"加载目标执行控制状态失败: {e}")
self._target_execution_controls = {}
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 _save_target_execution_controls(self):
"""保存目标级自动交易控制状态到文件。"""
try:
import json
from pathlib import Path
Path("data").mkdir(exist_ok=True)
file_path = Path("data/target_execution_controls.json")
with open(file_path, 'w') as f:
json.dump(self._target_execution_controls, f, indent=2, ensure_ascii=False)
logger.info(f"💾 已保存目标执行控制状态: {self._target_execution_controls}")
except Exception as e:
logger.error(f"保存目标执行控制状态失败: {e}")
def _is_platform_halted(self, platform_name: str) -> bool:
info = self._platform_halts.get(self._normalize_platform_key(platform_name), {})
return bool(info.get('halted'))
def _default_target_execution_enabled(self, target_key: str) -> bool:
normalized_target_key = self._normalize_platform_key(target_key)
if normalized_target_key == 'PaperTrading':
return bool(getattr(self.settings, 'paper_trading_enabled', True))
if normalized_target_key.startswith('Bitget:'):
return bool(getattr(self.settings, 'bitget_trading_enabled', False))
return True
def _is_target_execution_enabled(self, target_key: str) -> bool:
normalized_target_key = self._normalize_platform_key(target_key)
info = self._target_execution_controls.get(normalized_target_key, {})
if 'enabled' in info:
return bool(info.get('enabled'))
return self._default_target_execution_enabled(normalized_target_key)
def get_target_execution_status(self) -> Dict[str, Any]:
result = {}
known_targets = ['PaperTrading', *[self._get_bitget_target_key(account_id) for account_id in self._iter_bitget_accounts()]]
for target_key in known_targets:
normalized_target_key = self._normalize_platform_key(target_key)
info = self._target_execution_controls.get(normalized_target_key, {})
default_enabled = self._default_target_execution_enabled(normalized_target_key)
result[normalized_target_key] = {
'enabled': bool(info.get('enabled')) if 'enabled' in info else default_enabled,
'source': 'manual' if 'enabled' in info else 'default',
'default_enabled': default_enabled,
'reason': info.get('reason', ''),
'updated_at': info.get('updated_at'),
}
return result
def set_target_execution_enabled(self, target_key: str, enabled: bool, reason: str = "") -> Dict[str, Any]:
valid_targets = {'PaperTrading', *[self._get_bitget_target_key(account_id) for account_id in self._iter_bitget_accounts()], 'Bitget'}
if target_key not in valid_targets:
raise ValueError(f"不支持的执行目标: {target_key}")
normalized_target_key = self._normalize_platform_key(target_key)
self._target_execution_controls[normalized_target_key] = {
'enabled': bool(enabled),
'reason': (reason or '').strip(),
'updated_at': datetime.now().isoformat(),
}
self._save_target_execution_controls()
logger.info(f"🎛️ [{normalized_target_key}] 自动交易已{'开启' if enabled else '关闭'}")
return self.get_target_execution_status().get(normalized_target_key, {})
def _mark_platform_halted(
self,
platform_name: str,
*,
reason: str,
drawdown_pct: float,
current_balance: float,
initial_balance: float,
):
platform_key = self._normalize_platform_key(platform_name)
self._platform_halts[platform_key] = {
'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_key}] 已标记为平台熔断暂停")
if self.settings.feishu_enabled and self.feishu_error:
asyncio.create_task(
self.feishu_error.send_card(
f"🛑 [{platform_key}] 平台已停机",
"\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 = {}
known_platforms = ['PaperTrading', *[self._get_bitget_target_key(account_id) for account_id in self._iter_bitget_accounts()]]
for platform_name in known_platforms:
info = self._platform_halts.get(self._normalize_platform_key(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', *[self._get_bitget_target_key(account_id) for account_id in self._iter_bitget_accounts()], 'Bitget'}
if platform_name not in valid_platforms:
raise ValueError(f"不支持的平台: {platform_name}")
normalized_platform_name = self._normalize_platform_key(platform_name)
platform_service = {
'PaperTrading': self.paper_trading,
**{self._get_bitget_target_key(account_id): service for account_id, service in (self.bitget_services or {}).items()},
}.get(normalized_platform_name)
if not platform_service:
raise ValueError(f"平台未启用: {normalized_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"{normalized_platform_name} 当前余额无效,无法恢复")
self._initial_balances[normalized_platform_name] = current_balance
self._save_initial_balances()
previous = self._platform_halts.get(normalized_platform_name, {})
self._platform_halts[normalized_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"✅ [{normalized_platform_name}] 已手动恢复,初始权益重置为 ${current_balance:.2f}")
if self.settings.feishu_enabled and self.feishu_error:
asyncio.create_task(
self.feishu_error.send_card(
f"✅ [{normalized_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[normalized_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()