This commit is contained in:
aaron 2026-04-22 19:56:28 +08:00
parent f303e19a46
commit b1fcb6d29e
8 changed files with 853 additions and 148 deletions

View File

@ -11,7 +11,11 @@ import pandas as pd
from app.utils.logger import logger from app.utils.logger import logger
from app.config import get_settings from app.config import get_settings
from app.services.bitget_service import bitget_service from app.services.bitget_service import bitget_service
from app.services.feishu_service import get_feishu_service, get_feishu_paper_trading_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.telegram_service import get_telegram_service
from app.services.dingtalk_service import get_dingtalk_service from app.services.dingtalk_service import get_dingtalk_service
from app.services.paper_trading_service import get_paper_trading_service from app.services.paper_trading_service import get_paper_trading_service
@ -96,6 +100,12 @@ class CryptoAgent:
'long_term': 2.5, 'long_term': 2.5,
} }
SIGNAL_MIN_EFFECTIVE_LEVERAGE = {
'short_term': 4.0,
'medium_term': 2.0,
'long_term': 2.0,
}
SIGNAL_EXECUTION_RULES = { SIGNAL_EXECUTION_RULES = {
'short_term': { 'short_term': {
'min_add_price_gap_pct': 1.0, 'min_add_price_gap_pct': 1.0,
@ -123,6 +133,11 @@ class CryptoAgent:
}, },
} }
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): def __new__(cls, *args, **kwargs):
"""单例模式 - 确保只有一个实例""" """单例模式 - 确保只有一个实例"""
if cls._instance is None: if cls._instance is None:
@ -140,6 +155,7 @@ class CryptoAgent:
self.exchange = bitget_service # 交易所服务 self.exchange = bitget_service # 交易所服务
self.feishu = get_feishu_service() # 通用飞书服务crypto等 self.feishu = get_feishu_service() # 通用飞书服务crypto等
self.feishu_paper = get_feishu_paper_trading_service() # 模拟交易专用飞书服务 self.feishu_paper = get_feishu_paper_trading_service() # 模拟交易专用飞书服务
self.feishu_error = get_feishu_error_service() # 异常/风控专用飞书服务
self.telegram = get_telegram_service() self.telegram = get_telegram_service()
self.dingtalk = get_dingtalk_service() # 添加钉钉服务 self.dingtalk = get_dingtalk_service() # 添加钉钉服务
@ -217,6 +233,11 @@ class CryptoAgent:
"last_analysis_detail": "", "last_analysis_detail": "",
"next_scheduled_run_at": None, "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,
}
# 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损 # 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损
# key=order_id, value={symbol, is_long, size/contracts, tp_price, sl_price} # key=order_id, value={symbol, is_long, size/contracts, tp_price, sl_price}
@ -282,6 +303,205 @@ class CryptoAgent:
def _touch_analysis_heartbeat(self): def _touch_analysis_heartbeat(self):
self._analysis_monitor["last_heartbeat_at"] = datetime.now().isoformat() self._analysis_monitor["last_heartbeat_at"] = datetime.now().isoformat()
def _parse_iso_datetime(self, value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(str(value))
except (TypeError, ValueError):
return None
def _classify_execution_block_reason(self,
platform_name: str,
decision: Optional[Dict[str, Any]]) -> tuple[str, str]:
if self._is_platform_halted(platform_name):
halt_info = self._platform_halts.get(platform_name, {})
halt_reason = halt_info.get("reason") or "平台已触发风控停机"
return "平台停机", halt_reason
decision = decision or {}
decision_type = str(decision.get("decision", "")).upper()
reason = str(decision.get("reason") or decision.get("reasoning") or "未返回具体原因").strip()
if any(keyword in reason for keyword in ["无适配信号", "未匹配到信号", "未选中信号"]):
return "无适配信号", "该平台本轮没有匹配到可执行信号"
if any(keyword in reason for keyword in ["账户余额无效", "余额不足", "可用余额不足", "保证金不足", "账户权益不足"]):
return "资金不足", reason
if any(keyword in reason for keyword in ["有效杠杆", "最小保证金", "最小下单", "合约张数", "名义仓位", "下单数量不足", "仓位价值过小"]):
return "仓位不达标", reason
if any(keyword in reason for keyword in ["同向持仓", "已有持仓", "无需重复开仓", "已有同向仓位"]):
return "已有同向仓位", reason
if any(keyword in reason for keyword in ["挂单等待", "挂单中", "待成交", "已有挂单", "等待成交"]):
return "等待挂单成交", reason
if any(keyword in reason for keyword in ["撤销反向挂单", "先撤销", "取消挂单", "撤单后再"]):
return "挂单切换中", reason
if any(keyword in reason for keyword in ["风控", "回撤", "熔断", "风险控制", "止损停机"]):
return "风控拦截", reason
if any(keyword in reason for keyword in ["未启用", "未初始化", "不可用"]):
return "平台不可用", reason
if decision_type in {"CLOSE", "REDUCE"}:
return "平仓未执行", reason
if decision_type == "CANCEL_PENDING":
return "撤单未执行", reason
if decision_type in {"OPEN", "ADD"}:
return "未满足执行条件", reason
return "未执行", reason
async def _maybe_send_analysis_heartbeat(self):
if not self.settings.feishu_enabled:
return
now = datetime.now()
interval = timedelta(minutes=self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES)
state = self._analysis_notification_state
last_heartbeat_notified_at = self._parse_iso_datetime(state.get("last_heartbeat_notified_at"))
if last_heartbeat_notified_at and now - last_heartbeat_notified_at < interval:
return
last_signal_at = self._parse_iso_datetime(state.get("last_signal_at"))
if last_signal_at and now - last_signal_at < interval:
return
window_start = now - interval
recent_events = [
event for event in self._analysis_events
if self._parse_iso_datetime(event.get("timestamp")) and self._parse_iso_datetime(event.get("timestamp")) >= window_start
]
if not recent_events:
return
cycle_completed = sum(1 for event in recent_events if event.get("event_type") == "cycle_completed")
symbol_completed = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_completed")
symbol_skipped = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_skipped")
symbol_errors = sum(1 for event in recent_events if event.get("event_type") == "symbol_analysis_error")
valid_signal_total = sum(int(event.get("valid_signals", 0) or 0) for event in recent_events)
if cycle_completed <= 0 or valid_signal_total > 0:
return
last_cycle_completed_at = self._parse_iso_datetime(self._analysis_monitor.get("last_cycle_completed_at"))
if not last_cycle_completed_at or now - last_cycle_completed_at > interval:
return
last_symbol = self._analysis_monitor.get("last_analysis_symbol") or "-"
last_status = self._analysis_monitor.get("last_analysis_status") or "unknown"
last_detail = self._analysis_monitor.get("last_analysis_detail") or "最近一轮分析已完成"
threshold = self.settings.crypto_llm_threshold * 100
title = "💓 [分析心跳] 系统运行正常"
content = "\n".join([
f"最近 {self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES} 分钟持续完成市场分析,但暂无达到阈值的可执行信号。",
"",
f"**分析轮次**: {cycle_completed}",
f"**完成分析**: {symbol_completed} 个交易对",
f"**跳过分析**: {symbol_skipped}",
f"**分析异常**: {symbol_errors}",
f"**信号阈值**: {threshold:.0f}%",
f"**最近分析对象**: {last_symbol}",
f"**最近状态**: {last_status}",
f"**最近说明**: {last_detail}",
f"**最近完成时间**: {last_cycle_completed_at.strftime('%Y-%m-%d %H:%M:%S')}",
])
await self.feishu.send_card(title, content, "blue")
state["last_heartbeat_notified_at"] = now.isoformat()
self._record_analysis_event(
"heartbeat_notified",
status="info",
detail=f"已发送分析心跳通知,最近 {self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES} 分钟无有效信号",
extra={
"window_minutes": self.ANALYSIS_HEARTBEAT_INTERVAL_MINUTES,
"last_signal_at": state.get("last_signal_at"),
"last_signal_symbol": state.get("last_signal_symbol"),
},
)
def _build_pending_tp_sl_task(self,
symbol: str,
is_long: bool,
size: float,
tp_price: Optional[float],
sl_price: Optional[float],
order_status: Optional[str] = None,
has_real_order_id: bool = True,
retry_count: int = 0,
first_seen_at: Optional[str] = None,
last_alert_at: Optional[str] = None) -> Dict[str, Any]:
return {
"symbol": symbol,
"is_long": is_long,
"size": size,
"tp_price": tp_price,
"sl_price": sl_price,
"order_status": order_status,
"has_real_order_id": has_real_order_id,
"retry_count": retry_count,
"first_seen_at": first_seen_at or datetime.now().isoformat(),
"last_alert_at": last_alert_at,
}
async def _maybe_alert_tp_sl_incomplete(self,
platform: str,
tracking_key: str,
task: Dict[str, Any],
reason: str,
force: bool = False):
now = datetime.now()
last_alert_at_raw = task.get("last_alert_at")
last_alert_at = None
if last_alert_at_raw:
try:
last_alert_at = datetime.fromisoformat(str(last_alert_at_raw))
except ValueError:
last_alert_at = None
should_alert = force
if not should_alert and task.get("retry_count", 0) >= self.TP_SL_RETRY_ALERT_THRESHOLD:
should_alert = (
last_alert_at is None or
now - last_alert_at >= timedelta(minutes=self.TP_SL_ALERT_COOLDOWN_MINUTES)
)
severity = "error" if task.get("retry_count", 0) >= self.TP_SL_MAX_RETRY_BEFORE_ERROR else "warning"
self._record_execution_event(
platform,
"tp_sl_incomplete",
symbol=f"{task.get('symbol', '')}USDT",
reason=reason,
status=severity,
extra={
"tracking_key": tracking_key,
"retry_count": task.get("retry_count", 0),
"missing_take_profit": task.get("tp_price") is not None,
"missing_stop_loss": task.get("sl_price") is not None,
},
)
if should_alert:
missing_parts = []
if task.get("tp_price") is not None:
missing_parts.append(f"TP={task.get('tp_price')}")
if task.get("sl_price") is not None:
missing_parts.append(f"SL={task.get('sl_price')}")
missing_desc = " / ".join(missing_parts) or "保护单缺失"
await self._send_alert_notification(
f"⚠️ [{platform}] 保护单不完整 - {task.get('symbol', '')}USDT",
"\n".join([
f"追踪ID: {tracking_key}",
f"缺失项目: {missing_desc}",
f"重试次数: {task.get('retry_count', 0)}",
f"原因: {reason}",
])
)
task["last_alert_at"] = now.isoformat()
def _record_analysis_event(self, def _record_analysis_event(self,
event_type: str, event_type: str,
symbol: str = "", symbol: str = "",
@ -620,6 +840,7 @@ class CryptoAgent:
# 检查实盘挂单是否已成交,补设止盈止损 # 检查实盘挂单是否已成交,补设止盈止损
if self.hyperliquid: if self.hyperliquid:
await self._check_and_set_pending_tp_sl_hyperliquid() await self._check_and_set_pending_tp_sl_hyperliquid()
await self._check_hyperliquid_missing_tp_sl()
if self.bitget: if self.bitget:
await self._check_and_set_pending_tp_sl_bitget() await self._check_and_set_pending_tp_sl_bitget()
await self._check_bitget_missing_tp_sl() # 兜底:检查缺少的 TP/SL 并补救 await self._check_bitget_missing_tp_sl() # 兜底:检查缺少的 TP/SL 并补救
@ -638,6 +859,7 @@ class CryptoAgent:
status="success", status="success",
detail=f"本轮分析完成,共扫描 {len(self.symbols)} 个交易对", detail=f"本轮分析完成,共扫描 {len(self.symbols)} 个交易对",
) )
await self._maybe_send_analysis_heartbeat()
logger.info("\n" + "" * 60) logger.info("\n" + "" * 60)
logger.info(f"✅ 本轮分析完成,共分析 {len(self.symbols)} 个交易对") logger.info(f"✅ 本轮分析完成,共分析 {len(self.symbols)} 个交易对")
logger.info("" * 60 + "\n") logger.info("" * 60 + "\n")
@ -1375,6 +1597,100 @@ class CryptoAgent:
status="warning", status="warning",
) )
await self._notify_execution_summary_if_needed(
market_signal=market_signal,
current_price=current_price,
decisions={
"PaperTrading": paper_decision,
"Hyperliquid": hyperliquid_decision,
"Bitget": bitget_decision,
},
)
async def _notify_execution_summary_if_needed(
self,
market_signal: Dict[str, Any],
current_price: float,
decisions: Dict[str, Dict[str, Any]],
):
"""当存在可交易信号,但本轮所有平台都未真正执行时,仅发送一条汇总通知。"""
actionable: Dict[str, Dict[str, Any]] = {}
executed = False
for platform_name, decision in (decisions or {}).items():
if not decision:
continue
if decision.get('_execution_succeeded'):
executed = True
if decision.get('decision') in {'OPEN', 'ADD', 'CLOSE', 'CANCEL_PENDING'}:
actionable[platform_name] = decision
if executed or not actionable:
return
symbol = market_signal.get('symbol', '')
signal = self._get_best_signal_from_market(market_signal)
if not signal:
return
confidence = signal.get('confidence', 0)
entry_type = signal.get('entry_type', 'market')
entry_price = signal.get('entry_price', current_price)
signal_timeframe = signal.get('timeframe', signal.get('type', 'unknown'))
timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe)
action = signal.get('action', 'wait')
action_text = {'buy': '做多', 'sell': '做空', 'wait': '观望'}.get(action, action)
title = f"[执行汇总] {symbol} 信号未落单"
content_parts = [
f"**信号**: {action_text} | {timeframe_text} | 📈 **{confidence}%**",
"",
f"**入场方式**: {entry_type}",
f"**建议入场价**: ${entry_price:,.2f}" if isinstance(entry_price, (int, float)) else f"**建议入场价**: {entry_price}",
f"**当前价格**: ${current_price:,.2f}",
"",
"**平台结果**:",
]
blocked_platforms: List[Dict[str, Any]] = []
for platform_name, decision in actionable.items():
tag, detail = self._classify_execution_block_reason(platform_name, decision)
content_parts.append(f"- {platform_name}: **{tag}** | {detail}")
blocked_platforms.append({
"platform": platform_name,
"tag": tag,
"detail": detail,
"decision": decision.get("decision"),
})
content = "\n".join(content_parts)
self._record_execution_event(
"SYSTEM",
"execution_blocked_summary",
symbol=symbol,
reason=f"{action_text} {timeframe_text} 信号未落单",
status="warning",
extra={
"signal_action": action,
"signal_action_text": action_text,
"signal_timeframe": signal_timeframe,
"signal_timeframe_text": timeframe_text,
"confidence": confidence,
"entry_type": entry_type,
"entry_price": entry_price,
"current_price": current_price,
"blocked_platforms": blocked_platforms,
},
)
if self.settings.feishu_enabled:
await self.feishu.send_card(title, content, "orange")
if self.settings.telegram_enabled:
await self.telegram.send_message(f"{title}\n\n{content}")
if self.settings.dingtalk_enabled:
await self.dingtalk.send_action_card(title, content)
async def _execute_paper_decisions(self, decision: Dict[str, Any], async def _execute_paper_decisions(self, decision: Dict[str, Any],
market_signal: Dict[str, Any], market_signal: Dict[str, Any],
current_price: float): current_price: float):
@ -1405,12 +1721,12 @@ class CryptoAgent:
if result.get('success'): if result.get('success'):
order_id = result.get('order_id', 'unknown') order_id = result.get('order_id', 'unknown')
logger.info(f" ✅ 交易成功: 订单ID {order_id}") logger.info(f" ✅ 交易成功: 订单ID {order_id}")
decision['_execution_succeeded'] = True
self._record_execution_event( self._record_execution_event(
"PaperTrading", "open_success", decision=decision, status="success", "PaperTrading", "open_success", decision=decision, status="success",
reason=decision.get('reason', decision.get('reasoning', '')), reason=decision.get('reason', decision.get('reasoning', '')),
extra={"order_id": order_id}, extra={"order_id": order_id},
) )
await self._send_signal_notification(market_signal, decision, current_price)
# TP/SL 警告 # TP/SL 警告
if result.get('tp_sl_warning'): if result.get('tp_sl_warning'):
@ -1427,8 +1743,8 @@ class CryptoAgent:
if result.get('success'): if result.get('success'):
logger.info(f" ✅ 平仓成功") logger.info(f" ✅ 平仓成功")
decision['_execution_succeeded'] = True
self._record_execution_event("PaperTrading", "close_success", decision=decision, status="success") self._record_execution_event("PaperTrading", "close_success", decision=decision, status="success")
await self._send_signal_notification(market_signal, decision, current_price)
if next_decision: if next_decision:
await self._execute_paper_decisions(next_decision, market_signal, current_price) await self._execute_paper_decisions(next_decision, market_signal, current_price)
else: else:
@ -1450,11 +1766,11 @@ class CryptoAgent:
if success_count > 0: if success_count > 0:
logger.info(f" ✅ 成功取消 {success_count} 个挂单") logger.info(f" ✅ 成功取消 {success_count} 个挂单")
decision['_execution_succeeded'] = True
self._record_execution_event( self._record_execution_event(
"PaperTrading", "cancel_success", decision=decision, status="success", "PaperTrading", "cancel_success", decision=decision, status="success",
extra={"cancelled_count": success_count}, extra={"cancelled_count": success_count},
) )
await self._send_signal_notification(market_signal, decision, current_price)
if next_decision: if next_decision:
await self._execute_paper_decisions(next_decision, market_signal, current_price) await self._execute_paper_decisions(next_decision, market_signal, current_price)
else: else:
@ -1908,6 +2224,8 @@ class CryptoAgent:
# 根据配置发送通知 - [信号] 发送到 crypto webhook # 根据配置发送通知 - [信号] 发送到 crypto webhook
if self.settings.feishu_enabled: if self.settings.feishu_enabled:
await self.feishu.send_card(title, content, color) 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: if self.settings.telegram_enabled:
# Telegram 使用文本格式 # Telegram 使用文本格式
message = f"{title}\n\n{content}" message = f"{title}\n\n{content}"
@ -2583,8 +2901,8 @@ class CryptoAgent:
f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
]) ])
logger.error(f"[Hyperliquid] {operation} 失败 | {symbol} | {error}") logger.error(f"[Hyperliquid] {operation} 失败 | {symbol} | {error}")
if self.settings.feishu_enabled: if self.settings.feishu_enabled and self.feishu_error:
await self.feishu.send_card(title, content, "red") await self.feishu_error.send_card(title, content, "red")
if self.settings.telegram_enabled: if self.settings.telegram_enabled:
await self.telegram.send_message(f"{title}\n\n{content}") await self.telegram.send_message(f"{title}\n\n{content}")
if self.settings.dingtalk_enabled: if self.settings.dingtalk_enabled:
@ -2599,8 +2917,8 @@ class CryptoAgent:
f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
]) ])
logger.error(f"[Bitget] {operation} 失败 | {symbol} | {error}") logger.error(f"[Bitget] {operation} 失败 | {symbol} | {error}")
if self.settings.feishu_enabled: if self.settings.feishu_enabled and self.feishu_error:
await self.feishu.send_card(title, content, "red") await self.feishu_error.send_card(title, content, "red")
if self.settings.telegram_enabled: if self.settings.telegram_enabled:
await self.telegram.send_message(f"{title}\n\n{content}") await self.telegram.send_message(f"{title}\n\n{content}")
if self.settings.dingtalk_enabled: if self.settings.dingtalk_enabled:
@ -2735,6 +3053,7 @@ class CryptoAgent:
current_leverage = account.get('current_total_leverage', 0) current_leverage = account.get('current_total_leverage', 0)
max_leverage = account.get('max_total_leverage', 10) max_leverage = account.get('max_total_leverage', 10)
order_leverage = account.get('order_leverage', 10) order_leverage = account.get('order_leverage', 10)
min_effective_leverage = self.SIGNAL_MIN_EFFECTIVE_LEVERAGE.get(signal_type, 2.0)
target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct( target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct(
position_size=position_size, position_size=position_size,
@ -2773,6 +3092,7 @@ class CryptoAgent:
target_margin_pct=target_margin_pct, target_margin_pct=target_margin_pct,
max_margin_pct=max_margin_pct, max_margin_pct=max_margin_pct,
min_margin=min_margin, min_margin=min_margin,
min_effective_leverage=min_effective_leverage,
) )
if margin <= 0: if margin <= 0:
@ -2780,6 +3100,7 @@ class CryptoAgent:
return margin, ( return margin, (
f"{sizing_reason} | 平台: {platform_name} | " f"{sizing_reason} | 平台: {platform_name} | "
f"最小有效杠杆 {min_effective_leverage:.1f}x | "
f"限制后保证金 ${margin:.2f} ({budget_reason})" f"限制后保证金 ${margin:.2f} ({budget_reason})"
) )
@ -3258,6 +3579,7 @@ class CryptoAgent:
order_id = result.get('order_id', 'unknown') order_id = result.get('order_id', 'unknown')
order_status = result.get('order_status', 'filled') order_status = result.get('order_status', 'filled')
logger.info(f" ✅ Bitget 交易成功: {order_id} ({order_status})") logger.info(f" ✅ Bitget 交易成功: {order_id} ({order_status})")
decision['_execution_succeeded'] = True
self._record_execution_event( self._record_execution_event(
"Bitget", "open_success", decision=decision, status="success", "Bitget", "open_success", decision=decision, status="success",
reason=decision.get('reason', decision.get('reasoning', '')), reason=decision.get('reason', decision.get('reasoning', '')),
@ -3279,12 +3601,15 @@ class CryptoAgent:
if result.get('pending_tp_sl'): if result.get('pending_tp_sl'):
order_id = result.get('order_id') order_id = result.get('order_id')
if order_id: if order_id:
self._bg_pending_tp_sl[order_id] = { signal_action = decision.get('signal_action', decision.get('action'))
'symbol': symbol, pending_tp_sl = result.get('pending_tp_sl') or {}
'is_long': decision.get('action') == 'buy', self._bg_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
'contracts': result.get('contracts', 0), symbol=symbol,
**result['pending_tp_sl'] is_long=signal_action == 'buy',
} size=result.get('contracts', 0),
tp_price=pending_tp_sl.get('tp_price'),
sl_price=pending_tp_sl.get('sl_price'),
)
logger.info(f" 📌 已记录挂单 TP/SL (oid={order_id})") logger.info(f" 📌 已记录挂单 TP/SL (oid={order_id})")
else: else:
error = result.get('error', result.get('message', '未知错误')) error = result.get('error', result.get('message', '未知错误'))
@ -3299,8 +3624,8 @@ class CryptoAgent:
if result.get('success'): if result.get('success'):
logger.info(f" ✅ Bitget 平仓成功") logger.info(f" ✅ Bitget 平仓成功")
decision['_execution_succeeded'] = True
self._record_execution_event("Bitget", "close_success", decision=decision, status="success") self._record_execution_event("Bitget", "close_success", decision=decision, status="success")
await self._send_signal_notification(market_signal, decision, current_price, prefix="[Bitget]")
if next_decision: if next_decision:
await self._execute_bitget_decisions(next_decision, market_signal, current_price) await self._execute_bitget_decisions(next_decision, market_signal, current_price)
else: else:
@ -3325,6 +3650,7 @@ class CryptoAgent:
if success_count > 0: if success_count > 0:
logger.info(f" ✅ Bitget 取消成功: {success_count} 个挂单") logger.info(f" ✅ Bitget 取消成功: {success_count} 个挂单")
decision['_execution_succeeded'] = True
self._record_execution_event( self._record_execution_event(
"Bitget", "cancel_success", decision=decision, status="success", "Bitget", "cancel_success", decision=decision, status="success",
extra={"cancelled_count": success_count}, extra={"cancelled_count": success_count},
@ -3613,6 +3939,7 @@ class CryptoAgent:
if result.get('success'): if result.get('success'):
order_status = result.get('order_status', 'filled') order_status = result.get('order_status', 'filled')
logger.info(f" ✅ Hyperliquid 交易成功 ({order_status})") logger.info(f" ✅ Hyperliquid 交易成功 ({order_status})")
decision['_execution_succeeded'] = True
self._record_execution_event( self._record_execution_event(
"Hyperliquid", "open_success", decision=decision, status="success", "Hyperliquid", "open_success", decision=decision, status="success",
reason=decision.get('reason', decision.get('reasoning', '')), reason=decision.get('reason', decision.get('reasoning', '')),
@ -3623,6 +3950,21 @@ class CryptoAgent:
prefix="[Hyperliquid]", prefix="[Hyperliquid]",
hl_order_status=order_status hl_order_status=order_status
) )
if result.get('pending_tp_sl'):
order_id = str(result.get('order_id') or '')
pending_tp_sl = result.get('pending_tp_sl') or {}
tracking_key = order_id or f"{symbol}:{datetime.now().timestamp()}"
signal_action = decision.get('signal_action', decision.get('action'))
self._hl_pending_tp_sl[tracking_key] = self._build_pending_tp_sl_task(
symbol=symbol.replace('USDT', ''),
is_long=signal_action == 'buy',
size=result.get('position_size') or result.get('size') or 0,
tp_price=pending_tp_sl.get('tp_price'),
sl_price=pending_tp_sl.get('sl_price'),
order_status=order_status,
has_real_order_id=bool(order_id),
)
logger.info(f" 📌 已记录 Hyperliquid TP/SL 待补设任务 (key={tracking_key})")
if result.get('tp_sl_warning'): if result.get('tp_sl_warning'):
await self._notify_hyperliquid_error(symbol, "设置止盈止损", result['tp_sl_warning']) await self._notify_hyperliquid_error(symbol, "设置止盈止损", result['tp_sl_warning'])
else: else:
@ -3636,8 +3978,8 @@ class CryptoAgent:
result = await executor.execute_close(decision, current_price) result = await executor.execute_close(decision, current_price)
if result.get('success'): if result.get('success'):
logger.info(f" ✅ Hyperliquid 平仓成功") logger.info(f" ✅ Hyperliquid 平仓成功")
decision['_execution_succeeded'] = True
self._record_execution_event("Hyperliquid", "close_success", decision=decision, status="success") self._record_execution_event("Hyperliquid", "close_success", decision=decision, status="success")
await self._send_signal_notification(market_signal, decision, current_price, prefix="[Hyperliquid]")
if next_decision: if next_decision:
await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price) await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price)
else: else:
@ -3657,6 +3999,7 @@ class CryptoAgent:
success_count += 1 success_count += 1
if success_count > 0: if success_count > 0:
logger.info(f" ✅ Hyperliquid 取消成功: {success_count}") logger.info(f" ✅ Hyperliquid 取消成功: {success_count}")
decision['_execution_succeeded'] = True
self._record_execution_event( self._record_execution_event(
"Hyperliquid", "cancel_success", decision=decision, status="success", "Hyperliquid", "cancel_success", decision=decision, status="success",
extra={"cancelled_count": success_count}, extra={"cancelled_count": success_count},
@ -3835,24 +4178,73 @@ class CryptoAgent:
try: try:
for order_id, info in list(self._hl_pending_tp_sl.items()): for order_id, info in list(self._hl_pending_tp_sl.items()):
symbol = info['symbol'] symbol = info['symbol']
open_orders = self.hyperliquid.get_open_orders(symbol) has_real_order_id = info.get('has_real_order_id', True)
still_open = any(str(o.get('order_id')) == order_id for o in open_orders) if has_real_order_id:
open_orders = self.hyperliquid.get_open_orders(symbol)
still_open = any(str(o.get('order_id')) == order_id for o in open_orders)
else:
still_open = info.get('order_status') == 'resting'
if not still_open: if not still_open:
# 订单已不在挂单列表 → 已成交,补设 TP/SL # 订单已不在挂单列表 → 已成交,补设 TP/SL
tp_price = info.get('tp_price') tp_price = info.get('tp_price')
sl_price = info.get('sl_price') sl_price = info.get('sl_price')
position = self.hyperliquid.get_position_for_symbol(symbol)
if not position:
logger.info(f"[Hyperliquid] 挂单/持仓 {order_id} ({symbol}) 当前无持仓,跳过 TP/SL 补设")
del self._hl_pending_tp_sl[order_id]
continue
size = info.get('size') or abs(position.get('size', 0))
if size <= 0:
logger.warning(f"[Hyperliquid] 挂单/持仓 {order_id} ({symbol}) 数量无效,跳过 TP/SL 补设")
del self._hl_pending_tp_sl[order_id]
continue
logger.info(f"[Hyperliquid] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...") logger.info(f"[Hyperliquid] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...")
tp_sl_result = self.hyperliquid.set_tp_sl( tp_sl_result = self.hyperliquid.set_tp_sl(
symbol=symbol, symbol=symbol,
is_long=info['is_long'], is_long=info['is_long'],
size=info['size'], size=size,
tp_price=tp_price, tp_price=tp_price,
sl_price=sl_price, sl_price=sl_price,
) )
if tp_sl_result.get('success'): info['retry_count'] = int(info.get('retry_count', 0)) + 1
tp_set = tp_sl_result.get('tp_set', False)
sl_set = tp_sl_result.get('sl_set', False)
if tp_set and sl_set:
logger.info(f"[Hyperliquid] ✅ TP/SL 补设成功: {symbol} TP={tp_price} SL={sl_price}") logger.info(f"[Hyperliquid] ✅ TP/SL 补设成功: {symbol} TP={tp_price} SL={sl_price}")
elif tp_set or sl_set:
missing_tp = tp_price if not tp_set else None
missing_sl = sl_price if not sl_set else None
self._hl_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
symbol=info['symbol'],
is_long=info['is_long'],
size=size,
tp_price=missing_tp,
sl_price=missing_sl,
order_status=info.get('order_status'),
has_real_order_id=info.get('has_real_order_id', True),
retry_count=info.get('retry_count', 0),
first_seen_at=info.get('first_seen_at'),
last_alert_at=info.get('last_alert_at'),
)
set_text = "TP" if tp_set else "SL"
fail_text = "TP" if not tp_set else "SL"
logger.warning(f"[Hyperliquid] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设")
await self._maybe_alert_tp_sl_incomplete(
"Hyperliquid",
order_id,
self._hl_pending_tp_sl[order_id],
f"{set_text}已设,{fail_text}补设失败",
)
continue
else: else:
logger.warning(f"[Hyperliquid] ⚠️ TP/SL 补设失败: {tp_sl_result.get('error')}") logger.warning(f"[Hyperliquid] ⚠️ TP/SL 补设失败: {tp_sl_result.get('errors') or tp_sl_result.get('error')}")
await self._maybe_alert_tp_sl_incomplete(
"Hyperliquid",
order_id,
info,
str(tp_sl_result.get('errors') or tp_sl_result.get('error') or 'TP/SL补设失败'),
)
continue
del self._hl_pending_tp_sl[order_id] del self._hl_pending_tp_sl[order_id]
except Exception as e: except Exception as e:
logger.error(f"[Hyperliquid] 检查挂单 TP/SL 补设异常: {e}") logger.error(f"[Hyperliquid] 检查挂单 TP/SL 补设异常: {e}")
@ -3876,10 +4268,11 @@ class CryptoAgent:
tp_sl_result = self.bitget.set_tp_sl( tp_sl_result = self.bitget.set_tp_sl(
symbol=symbol, symbol=symbol,
is_long=info['is_long'], is_long=info['is_long'],
size=info['contracts'], size=info['size'],
tp_price=tp_price, tp_price=tp_price,
sl_price=sl_price, sl_price=sl_price,
) )
info['retry_count'] = int(info.get('retry_count', 0)) + 1
tp_set = tp_sl_result.get('tp_set', False) tp_set = tp_sl_result.get('tp_set', False)
sl_set = tp_sl_result.get('sl_set', False) sl_set = tp_sl_result.get('sl_set', False)
@ -3890,17 +4283,34 @@ class CryptoAgent:
missing_tp = tp_price if not tp_set else None missing_tp = tp_price if not tp_set else None
missing_sl = sl_price if not sl_set else None missing_sl = sl_price if not sl_set else None
if missing_tp or missing_sl: if missing_tp or missing_sl:
self._bg_pending_tp_sl[order_id] = { self._bg_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
**info, symbol=info['symbol'],
'tp_price': missing_tp, is_long=info['is_long'],
'sl_price': missing_sl, size=info['size'],
} tp_price=missing_tp,
sl_price=missing_sl,
retry_count=info.get('retry_count', 0),
first_seen_at=info.get('first_seen_at'),
last_alert_at=info.get('last_alert_at'),
)
set_text = "TP" if tp_set else "SL" set_text = "TP" if tp_set else "SL"
fail_text = "TP" if not tp_set else "SL" fail_text = "TP" if not tp_set else "SL"
logger.warning(f"[Bitget] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设") logger.warning(f"[Bitget] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设")
await self._maybe_alert_tp_sl_incomplete(
"Bitget",
order_id,
self._bg_pending_tp_sl[order_id],
f"{set_text}已设,{fail_text}补设失败",
)
continue # 不删除,下轮继续 continue # 不删除,下轮继续
else: else:
logger.warning(f"[Bitget] ⚠️ TP/SL 补设失败: {tp_sl_result.get('errors')}") logger.warning(f"[Bitget] ⚠️ TP/SL 补设失败: {tp_sl_result.get('errors')}")
await self._maybe_alert_tp_sl_incomplete(
"Bitget",
order_id,
info,
str(tp_sl_result.get('errors') or 'TP/SL补设失败'),
)
continue # 不删除,下轮继续重试 continue # 不删除,下轮继续重试
del self._bg_pending_tp_sl[order_id] del self._bg_pending_tp_sl[order_id]
@ -3981,10 +4391,110 @@ class CryptoAgent:
logger.info(f"[Bitget] ✅ 补救成功: {symbol} {' & '.join(set_parts)}") logger.info(f"[Bitget] ✅ 补救成功: {symbol} {' & '.join(set_parts)}")
else: else:
logger.warning(f"[Bitget] ⚠️ 补救失败: {tp_sl_result.get('errors')}") logger.warning(f"[Bitget] ⚠️ 补救失败: {tp_sl_result.get('errors')}")
await self._maybe_alert_tp_sl_incomplete(
"Bitget",
f"fallback:{symbol}",
self._build_pending_tp_sl_task(
symbol=coin,
is_long=pos.get('size', 0) > 0,
size=size,
tp_price=set_tp,
sl_price=set_sl,
retry_count=self.TP_SL_RETRY_ALERT_THRESHOLD,
),
str(tp_sl_result.get('errors') or '兜底补设失败'),
force=True,
)
except Exception as e: except Exception as e:
logger.error(f"[Bitget] 止盈止损兜底检查异常: {e}") logger.error(f"[Bitget] 止盈止损兜底检查异常: {e}")
async def _check_hyperliquid_missing_tp_sl(self):
"""定时检查 Hyperliquid 持仓是否缺少止盈止损,缺少则从最新信号补救"""
if not self.hyperliquid:
return
try:
positions = self.hyperliquid.get_open_positions()
if not positions:
return
for pos in positions:
symbol = pos.get('symbol', '')
if not symbol:
continue
coin = symbol.replace('USDT', '')
tp_sl = self.hyperliquid.get_tp_sl_prices(coin)
has_tp = tp_sl.get('take_profit') is not None
has_sl = tp_sl.get('stop_loss') is not None
if has_tp and has_sl:
continue
latest_signal = self.signal_db.get_latest_signal('crypto', symbol)
if not latest_signal:
missing = ('止盈' if not has_tp else '') + ('/' if not has_tp and not has_sl else '') + ('止损' if not has_sl else '')
logger.warning(f"[Hyperliquid] ⚠️ {symbol} 缺少{missing},且无历史信号可补救")
continue
tp_price = latest_signal.get('take_profit')
sl_price = latest_signal.get('stop_loss')
if not tp_price and not sl_price:
logger.warning(f"[Hyperliquid] ⚠️ {symbol} 缺少止盈止损,最近信号也无 TP/SL")
continue
set_tp = tp_price if not has_tp else None
set_sl = sl_price if not has_sl else None
missing_parts = []
if not has_tp:
missing_parts.append(f"TP={set_tp}")
if not has_sl:
missing_parts.append(f"SL={set_sl}")
logger.warning(f"[Hyperliquid] 🔧 {symbol} 缺少 {' & '.join(missing_parts)},从信号补救...")
size = abs(pos.get('size', 0))
if size <= 0:
continue
tp_sl_result = self.hyperliquid.set_tp_sl(
symbol=coin,
is_long=pos.get('size', 0) > 0,
size=size,
tp_price=set_tp,
sl_price=set_sl,
)
tp_set = tp_sl_result.get('tp_set', False)
sl_set = tp_sl_result.get('sl_set', False)
if tp_set or sl_set:
set_parts = []
if tp_set:
set_parts.append(f"TP={set_tp}")
if sl_set:
set_parts.append(f"SL={set_sl}")
logger.info(f"[Hyperliquid] ✅ 补救成功: {symbol} {' & '.join(set_parts)}")
else:
logger.warning(f"[Hyperliquid] ⚠️ 补救失败: {tp_sl_result.get('errors') or tp_sl_result.get('error')}")
await self._maybe_alert_tp_sl_incomplete(
"Hyperliquid",
f"fallback:{symbol}",
self._build_pending_tp_sl_task(
symbol=coin,
is_long=pos.get('size', 0) > 0,
size=size,
tp_price=set_tp,
sl_price=set_sl,
retry_count=self.TP_SL_RETRY_ALERT_THRESHOLD,
),
str(tp_sl_result.get('errors') or tp_sl_result.get('error') or '兜底补设失败'),
force=True,
)
except Exception as e:
logger.error(f"[Hyperliquid] 止盈止损兜底检查异常: {e}")
def _calculate_hyperliquid_position_size(self, decision: Dict[str, Any], current_price: float) -> float: def _calculate_hyperliquid_position_size(self, decision: Dict[str, Any], current_price: float) -> float:
""" """
计算 Hyperliquid 仓位大小基于可用保证金和风控限制 计算 Hyperliquid 仓位大小基于可用保证金和风控限制
@ -4202,89 +4712,6 @@ class CryptoAgent:
if self.settings.dingtalk_enabled: if self.settings.dingtalk_enabled:
await self.dingtalk.send_action_card(title, content) await self.dingtalk.send_action_card(title, content)
async def _notify_signal_not_executed(
self,
market_signal: Dict[str, Any],
decision: Dict[str, Any],
current_price: float,
reason: str = "",
prefix: str = ""
):
"""发送有信号但未执行交易的通知"""
try:
symbol = market_signal.get('symbol')
account_type = "📊"
title_prefix = f"{prefix} " if prefix else ""
signal = self._get_signal_for_decision(market_signal, decision)
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)
# 决策信息
decision_type = decision.get('decision', 'HOLD')
decision_reason = decision.get('reason', '')
decision_reasoning = decision.get('reasoning', '')
# 如果有外部传入的 reason订单创建失败的具体原因优先使用
if reason:
final_reason = reason
elif decision_reason:
final_reason = decision_reason
elif decision_reasoning:
final_reason = decision_reasoning
else:
final_reason = "未知原因"
# 方向图标
action = signal.get('action', 'wait')
if action == 'buy':
action_icon = '🟢'
action_text = '做多'
elif action == 'sell':
action_icon = '🔴'
action_text = '做空'
else:
action_icon = ''
action_text = '观望'
# 构建标题
title = f"{title_prefix}{account_type} {symbol} 信号未执行"
# 构建内容
content_parts = [
f"{action_icon} **信号**: {action_text} | {timeframe_text} | 📈 信心度: **{confidence}%**",
f"",
f"**入场方式**: {entry_type}",
f"**建议入场价**: ${entry_price:,.2f}" if isinstance(entry_price, (int, float)) else f"**建议入场价**: {entry_price}",
f"**当前价格**: ${current_price:,.2f}",
f"",
f"⚠️ **未执行原因**:",
f"{final_reason}",
]
content = "\n".join(content_parts)
# 发送通知
if self.settings.feishu_enabled:
await self.feishu.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" 📤 已发送信号未执行通知: {decision_type} - {final_reason[:50]}")
except Exception as e:
logger.warning(f"发送信号未执行通知失败: {e}")
async def analyze_once(self, symbol: str) -> Dict[str, Any]: async def analyze_once(self, symbol: str) -> Dict[str, Any]:
"""单次分析并返回市场信号与平台执行预览""" """单次分析并返回市场信号与平台执行预览"""
data = self.exchange.get_multi_timeframe_data(symbol) data = self.exchange.get_multi_timeframe_data(symbol)
@ -4365,6 +4792,7 @@ class CryptoAgent:
'mode': 'LLM 驱动', 'mode': 'LLM 驱动',
'platform_halts': self.get_platform_halt_status(), 'platform_halts': self.get_platform_halt_status(),
'analysis_monitor': self._analysis_monitor, 'analysis_monitor': self._analysis_monitor,
'analysis_notifications': self._analysis_notification_state,
'last_signals': { 'last_signals': {
symbol: { symbol: {
'type': sig.get('type'), 'type': sig.get('type'),
@ -4826,6 +5254,19 @@ class CryptoAgent:
} }
self._save_platform_halts() self._save_platform_halts()
logger.warning(f"🛑 [{platform_name}] 已标记为平台熔断暂停") logger.warning(f"🛑 [{platform_name}] 已标记为平台熔断暂停")
if self.settings.feishu_enabled and self.feishu_error:
asyncio.create_task(
self.feishu_error.send_card(
f"🛑 [{platform_name}] 平台已停机",
"\n".join([
f"**原因**: {reason}",
f"**回撤**: {drawdown_pct:.2f}%",
f"**当前权益**: ${current_balance:,.2f}",
f"**初始权益**: ${initial_balance:,.2f}",
]),
"red",
)
)
def get_platform_halt_status(self) -> Dict[str, Any]: def get_platform_halt_status(self) -> Dict[str, Any]:
result = {} result = {}
@ -4889,6 +5330,18 @@ class CryptoAgent:
} }
self._save_platform_halts() self._save_platform_halts()
logger.info(f"✅ [{platform_name}] 已手动恢复,初始权益重置为 ${current_balance:.2f}") logger.info(f"✅ [{platform_name}] 已手动恢复,初始权益重置为 ${current_balance:.2f}")
if self.settings.feishu_enabled and self.feishu_error:
asyncio.create_task(
self.feishu_error.send_card(
f"✅ [{platform_name}] 平台已恢复",
"\n".join([
f"**当前权益**: ${current_balance:,.2f}",
f"**重置初始权益**: ${current_balance:,.2f}",
f"**恢复时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
]),
"green",
)
)
return self._platform_halts[platform_name] return self._platform_halts[platform_name]
# ==================== 初始余额持久化 ==================== # ==================== 初始余额持久化 ====================
@ -4953,7 +5406,9 @@ class CryptoAgent:
"""发送告警通知(飞书/钉钉/Telegram""" """发送告警通知(飞书/钉钉/Telegram"""
try: try:
# 飞书 # 飞书
if self.feishu: 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}") await self.feishu.send_text(f"{title}\n\n{message}")
# 钉钉 # 钉钉

View File

@ -12,6 +12,12 @@ from app.utils.logger import logger
class BaseExecutor(ABC): class BaseExecutor(ABC):
"""交易执行器基类""" """交易执行器基类"""
MIN_EFFECTIVE_LEVERAGE_BY_SIGNAL_TYPE = {
'short_term': 4.0,
'medium_term': 2.0,
'long_term': 2.0,
}
def __init__(self, platform_name: str): def __init__(self, platform_name: str):
self.platform_name = platform_name self.platform_name = platform_name
@ -338,6 +344,30 @@ class BaseExecutor(ABC):
return adjusted_margin return adjusted_margin
def get_min_effective_leverage(self, decision: Dict[str, Any]) -> float:
signal_type = decision.get('timeframe') or decision.get('type') or 'medium_term'
return float(self.MIN_EFFECTIVE_LEVERAGE_BY_SIGNAL_TYPE.get(signal_type, 2.0))
def validate_effective_leverage(self,
decision: Dict[str, Any],
margin: float,
actual_position_value: float) -> tuple[bool, str, float]:
if margin <= 0:
return False, "保证金无效", 0.0
effective_leverage = actual_position_value / margin if margin > 0 else 0.0
min_effective_leverage = self.get_min_effective_leverage(decision)
if effective_leverage + 1e-9 < min_effective_leverage:
signal_type = decision.get('timeframe') or decision.get('type') or 'medium_term'
return (
False,
f"{signal_type} 实际有效杠杆 {effective_leverage:.2f}x < 最小要求 {min_effective_leverage:.1f}x",
effective_leverage,
)
return True, "", effective_leverage
@abstractmethod @abstractmethod
def get_fee_rate(self) -> float: def get_fee_rate(self) -> float:
""" """
@ -775,4 +805,3 @@ class BaseExecutor(ABC):
color = "green" if success else "red" color = "green" if success else "red"
await self.feishu.send_card(title, content, color) await self.feishu.send_card(title, content, color)

View File

@ -40,6 +40,12 @@ class BitgetExecutor(BaseExecutor):
# 计算合约张数,必须与实际执行杠杆保持一致 # 计算合约张数,必须与实际执行杠杆保持一致
contracts = self._calculate_contracts(symbol, adjusted_margin, entry_price, leverage) contracts = self._calculate_contracts(symbol, adjusted_margin, entry_price, leverage)
actual_position_value = contracts * self.bitget.get_contract_size(symbol) * entry_price
leverage_ok, leverage_reason, effective_leverage = self.validate_effective_leverage(
decision,
adjusted_margin,
actual_position_value,
)
if contracts < 1: if contracts < 1:
return { return {
@ -49,6 +55,12 @@ class BitgetExecutor(BaseExecutor):
f'(保证金=${adjusted_margin:.2f}, 杠杆={leverage}x)' f'(保证金=${adjusted_margin:.2f}, 杠杆={leverage}x)'
) )
} }
if not leverage_ok:
return {
'success': False,
'error': leverage_reason,
'effective_leverage': effective_leverage,
}
# 设置杠杆 # 设置杠杆
self.bitget.update_leverage(symbol, leverage) self.bitget.update_leverage(symbol, leverage)
@ -128,21 +140,7 @@ class BitgetExecutor(BaseExecutor):
logger.info(f" ✅ 开仓成功: {symbol} {contracts}张 @ ${order_type}") logger.info(f" ✅ 开仓成功: {symbol} {contracts}张 @ ${order_type}")
# 发送飞书通知 # 开仓成功通知由 crypto_agent 统一发送,避免与执行摘要重复
await self.send_execution_notification(
operation='OPEN',
symbol=symbol,
result=result,
details={
'size': contracts,
'price': entry_price,
'margin': adjusted_margin,
'leverage': leverage,
'stop_loss': stop_loss,
'take_profit': take_profit,
'order_type': order_type
}
)
return result return result

View File

@ -46,12 +46,24 @@ class HyperliquidExecutor(BaseExecutor):
# 计算仓位大小 # 计算仓位大小
position_size = self._calculate_position_size(symbol, adjusted_margin, entry_price, leverage) position_size = self._calculate_position_size(symbol, adjusted_margin, entry_price, leverage)
actual_position_value = position_size * entry_price
leverage_ok, leverage_reason, effective_leverage = self.validate_effective_leverage(
decision,
adjusted_margin,
actual_position_value,
)
if position_size <= 0: if position_size <= 0:
return { return {
'success': False, 'success': False,
'error': f'仓位计算失败: {position_size}' 'error': f'仓位计算失败: {position_size}'
} }
if not leverage_ok:
return {
'success': False,
'error': leverage_reason,
'effective_leverage': effective_leverage,
}
# 设置杠杆 # 设置杠杆
self.hyperliquid.update_leverage(symbol, leverage) self.hyperliquid.update_leverage(symbol, leverage)
@ -103,38 +115,44 @@ class HyperliquidExecutor(BaseExecutor):
if tp_set and sl_set: if tp_set and sl_set:
logger.info(f" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}") logger.info(f" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}")
elif tp_set or sl_set: elif tp_set or sl_set:
# 部分成功:记录缺失侧 # 部分成功:记录缺失侧,交给 agent 后续补设
set_text = "TP" if tp_set else "SL" set_text = "TP" if tp_set else "SL"
fail_text = "TP" if not tp_set else "SL" fail_text = "TP" if not tp_set else "SL"
result['pending_tp_sl'] = {
'tp_price': take_profit if not tp_set else None,
'sl_price': stop_loss if not sl_set else None,
}
result['position_size'] = position_size
logger.warning(f" ⚠️ 止盈止损部分成功: {set_text}已设, {fail_text}失败") logger.warning(f" ⚠️ 止盈止损部分成功: {set_text}已设, {fail_text}失败")
result['tp_sl_warning'] = f"{fail_text}设置失败: {tp_sl_result.get('errors', [])}" result['tp_sl_warning'] = f"{fail_text}设置失败: {tp_sl_result.get('errors', [])}"
else: else:
errors = tp_sl_result.get('errors', []) errors = tp_sl_result.get('errors', [])
result['pending_tp_sl'] = {
'tp_price': take_profit,
'sl_price': stop_loss,
}
result['position_size'] = position_size
logger.warning(f" ⚠️ 止盈止损设置失败: {errors}") logger.warning(f" ⚠️ 止盈止损设置失败: {errors}")
result['tp_sl_warning'] = f"TP/SL设置失败: {'; '.join(errors)}" result['tp_sl_warning'] = f"TP/SL设置失败: {'; '.join(errors)}"
except Exception as tp_sl_err: except Exception as tp_sl_err:
logger.error(f" ⚠️ 止盈止损设置异常: {tp_sl_err}") logger.error(f" ⚠️ 止盈止损设置异常: {tp_sl_err}")
result['pending_tp_sl'] = {
'tp_price': take_profit,
'sl_price': stop_loss,
}
result['position_size'] = position_size
result['tp_sl_warning'] = str(tp_sl_err) result['tp_sl_warning'] = str(tp_sl_err)
else: else:
# 限价单未成交,暂时跳过(等成交后再设) # 限价单未成交:记录下来,等成交后自动补设
logger.info(f" 📌 限价单待成交TP/SL 将在成交后设置: TP={take_profit}, SL={stop_loss}") result['pending_tp_sl'] = {
result['tp_sl_warning'] = "限价单未成交TP/SL 待成交后设置" 'tp_price': take_profit,
'sl_price': stop_loss,
}
result['position_size'] = position_size
logger.info(f" 📌 限价单待成交TP/SL 将在成交后自动设置: TP={take_profit}, SL={stop_loss}")
result['tp_sl_warning'] = "限价单未成交TP/SL 已加入待补设列表"
# 发送飞书通知(在止盈止损之后,通知失败不影响交易结果) # 开仓成功通知由 crypto_agent 统一发送,避免与执行摘要重复
await self.send_execution_notification(
operation='OPEN',
symbol=symbol,
result=result,
details={
'size': position_size,
'price': entry_price,
'margin': adjusted_margin,
'leverage': leverage,
'stop_loss': stop_loss,
'take_profit': take_profit,
'order_type': order_type
}
)
return result return result
@ -228,10 +246,15 @@ class HyperliquidExecutor(BaseExecutor):
position_size: float) -> Dict[str, Any]: position_size: float) -> Dict[str, Any]:
"""设置止盈止损""" """设置止盈止损"""
try: try:
# Hyperliquid 的 TP/SL 设置方式可能需要查文档 positions = self.hyperliquid.get_open_positions()
# 这里假设有类似的方法 pos = next((p for p in positions if p.get('coin') == symbol.replace('USDT', '')), None)
if not pos:
return {'success': False, 'message': f'找不到 {symbol} 的持仓'}
result = self.hyperliquid.set_tp_sl( result = self.hyperliquid.set_tp_sl(
symbol=symbol.replace('USDT', ''), symbol=symbol.replace('USDT', ''),
is_long=pos['size'] > 0,
size=position_size, size=position_size,
tp_price=take_profit, tp_price=take_profit,
sl_price=stop_loss sl_price=stop_loss

View File

@ -33,6 +33,18 @@ class PaperTradingExecutor(BaseExecutor):
# 调整保证金(模拟盘无手续费) # 调整保证金(模拟盘无手续费)
adjusted_margin = margin adjusted_margin = margin
actual_position_value = adjusted_margin * self.paper_trading.leverage
leverage_ok, leverage_reason, effective_leverage = self.validate_effective_leverage(
decision,
adjusted_margin,
actual_position_value,
)
if not leverage_ok:
return {
'success': False,
'error': leverage_reason,
'effective_leverage': effective_leverage,
}
# 根据 confidence 推算信号等级 # 根据 confidence 推算信号等级
confidence = decision.get('confidence', 0) confidence = decision.get('confidence', 0)

View File

@ -294,6 +294,7 @@ _feishu_crypto_service: Optional[FeishuService] = None
_feishu_stock_service: Optional[FeishuService] = None _feishu_stock_service: Optional[FeishuService] = None
_feishu_news_service: Optional[FeishuService] = None _feishu_news_service: Optional[FeishuService] = None
_feishu_paper_trading_service: Optional[FeishuService] = None _feishu_paper_trading_service: Optional[FeishuService] = None
_feishu_error_service: Optional[FeishuService] = None
def get_feishu_service() -> FeishuService: def get_feishu_service() -> FeishuService:
@ -331,3 +332,11 @@ def get_feishu_paper_trading_service() -> FeishuService:
if _feishu_paper_trading_service is None: if _feishu_paper_trading_service is None:
_feishu_paper_trading_service = FeishuService(service_type="paper_trading") _feishu_paper_trading_service = FeishuService(service_type="paper_trading")
return _feishu_paper_trading_service return _feishu_paper_trading_service
def get_feishu_error_service() -> FeishuService:
"""获取系统异常飞书服务实例"""
global _feishu_error_service
if _feishu_error_service is None:
_feishu_error_service = FeishuService(service_type="error")
return _feishu_error_service

View File

@ -115,6 +115,7 @@ def calculate_margin_and_position_value(
target_margin_pct: float, target_margin_pct: float,
max_margin_pct: float, max_margin_pct: float,
min_margin: float = 0.0, min_margin: float = 0.0,
min_effective_leverage: float = 1.0,
reserve_ratio: float = 0.05, reserve_ratio: float = 0.05,
) -> Tuple[float, float, str]: ) -> Tuple[float, float, str]:
if balance <= 0: if balance <= 0:
@ -123,6 +124,13 @@ def calculate_margin_and_position_value(
return 0.0, 0.0, "可用保证金不足" return 0.0, 0.0, "可用保证金不足"
if order_leverage <= 0: if order_leverage <= 0:
return 0.0, 0.0, "订单杠杆无效" return 0.0, 0.0, "订单杠杆无效"
if min_effective_leverage <= 0:
min_effective_leverage = 1.0
if order_leverage < min_effective_leverage:
return 0.0, 0.0, (
f"订单杠杆 {order_leverage:.1f}x 低于最小有效杠杆 "
f"{min_effective_leverage:.1f}x"
)
if target_margin_pct <= 0: if target_margin_pct <= 0:
return 0.0, 0.0, "目标保证金比例无效" return 0.0, 0.0, "目标保证金比例无效"

View File

@ -1093,6 +1093,105 @@
gap: 10px; gap: 10px;
} }
.runtime-summary-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: 12px;
margin-bottom: 14px;
}
.runtime-summary-card {
padding: 14px;
border-radius: 14px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.05);
}
.runtime-summary-title {
color: var(--muted);
font-size: 11px;
margin-bottom: 10px;
font-family: "IBM Plex Mono", monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.runtime-summary-main {
font-family: "IBM Plex Mono", monospace;
font-size: 16px;
color: var(--text);
margin-bottom: 8px;
}
.runtime-summary-meta {
display: grid;
gap: 8px;
}
.runtime-summary-row {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: var(--muted);
}
.runtime-summary-row strong {
color: var(--text);
font-weight: 500;
}
.blocked-list {
display: grid;
gap: 10px;
}
.blocked-item {
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 184, 77, 0.08);
border: 1px solid rgba(255, 184, 77, 0.16);
}
.blocked-item-head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
align-items: center;
}
.blocked-item-title {
font-size: 13px;
color: var(--text);
font-weight: 600;
}
.blocked-item-meta {
color: var(--muted);
font-size: 11px;
font-family: "IBM Plex Mono", monospace;
}
.blocked-platforms {
display: grid;
gap: 6px;
margin-top: 10px;
}
.blocked-platform {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 12px;
color: var(--muted);
}
.blocked-platform strong {
color: var(--text);
min-width: 92px;
}
.analysis-log-item { .analysis-log-item {
padding: 12px 14px; padding: 12px 14px;
border-radius: 14px; border-radius: 14px;
@ -1505,6 +1604,18 @@
<div class="heartbeat-card"><span class="label">当前进度</span><span class="value">-</span></div> <div class="heartbeat-card"><span class="label">当前进度</span><span class="value">-</span></div>
<div class="heartbeat-card"><span class="label">下一次运行</span><span class="value">-</span></div> <div class="heartbeat-card"><span class="label">下一次运行</span><span class="value">-</span></div>
</div> </div>
<div class="runtime-summary-grid">
<div class="runtime-summary-card" id="runtimeSummaryCard">
<div class="runtime-summary-title">运行摘要</div>
<div class="runtime-summary-main">正在整理分析状态...</div>
</div>
<div class="runtime-summary-card">
<div class="runtime-summary-title">最近阻塞原因</div>
<div class="blocked-list" id="blockedSummaryList">
<div class="loading">正在读取未落单汇总...</div>
</div>
</div>
</div>
<div class="analysis-log-list" id="analysisLogList"> <div class="analysis-log-list" id="analysisLogList">
<div class="loading">正在读取分析日志...</div> <div class="loading">正在读取分析日志...</div>
</div> </div>
@ -2150,7 +2261,9 @@
function renderAnalysisHeartbeat(analysisMonitor, analysisEvents) { function renderAnalysisHeartbeat(analysisMonitor, analysisEvents) {
const heartbeat = document.getElementById('analysisHeartbeat'); const heartbeat = document.getElementById('analysisHeartbeat');
const logList = document.getElementById('analysisLogList'); const logList = document.getElementById('analysisLogList');
const summaryCard = document.getElementById('runtimeSummaryCard');
const monitor = analysisMonitor || {}; const monitor = analysisMonitor || {};
const notifications = cachedConsoleData?.crypto_agent?.analysis_notifications || {};
const cycleStatus = monitor.last_cycle_status || 'idle'; const cycleStatus = monitor.last_cycle_status || 'idle';
const progressText = monitor.current_cycle_total const progressText = monitor.current_cycle_total
? `${monitor.current_cycle_index || 0}/${monitor.current_cycle_total} ${monitor.current_cycle_symbol || ''}` ? `${monitor.current_cycle_index || 0}/${monitor.current_cycle_total} ${monitor.current_cycle_symbol || ''}`
@ -2175,6 +2288,19 @@
</div> </div>
`; `;
const heartbeatSentAt = notifications.last_heartbeat_notified_at;
const lastSignalAt = notifications.last_signal_at;
const lastSignalSymbol = notifications.last_signal_symbol || '-';
summaryCard.innerHTML = `
<div class="runtime-summary-title">运行摘要</div>
<div class="runtime-summary-main">${monitor.last_analysis_status || 'idle'} / ${monitor.last_analysis_symbol || '-'}</div>
<div class="runtime-summary-meta">
<div class="runtime-summary-row"><span>最近分析说明</span><strong>${monitor.last_analysis_detail || '-'}</strong></div>
<div class="runtime-summary-row"><span>最近信号</span><strong>${lastSignalAt ? `${lastSignalSymbol} / ${relativeTime(lastSignalAt)}` : '近 60 分钟无信号'}</strong></div>
<div class="runtime-summary-row"><span>上次心跳通知</span><strong>${heartbeatSentAt ? `${relativeTime(heartbeatSentAt)} / ${formatTime(heartbeatSentAt)}` : '尚未发送'}</strong></div>
</div>
`;
if (!analysisEvents || analysisEvents.length === 0) { if (!analysisEvents || analysisEvents.length === 0) {
logList.innerHTML = compactEmpty('最近还没有分析日志', '等待下一轮分析或新的运行事件写入。'); logList.innerHTML = compactEmpty('最近还没有分析日志', '等待下一轮分析或新的运行事件写入。');
return; return;
@ -2191,6 +2317,39 @@
`).join(''); `).join('');
} }
function renderBlockedSummaries(events = cachedExecutionEvents) {
const container = document.getElementById('blockedSummaryList');
const blockedEvents = (Array.isArray(events) ? events : [])
.filter((event) => event.event_type === 'execution_blocked_summary')
.slice(0, 3);
if (!blockedEvents.length) {
container.innerHTML = compactEmpty('最近没有未落单阻塞', '出现执行阻塞时,这里会按平台归类展示原因。');
return;
}
container.innerHTML = blockedEvents.map((event) => {
const blockedPlatforms = event.blocked_platforms || [];
return `
<div class="blocked-item">
<div class="blocked-item-head">
<div class="blocked-item-title">${event.symbol || '-'} · ${event.signal_action_text || '-'} / ${event.signal_timeframe_text || '-'}</div>
<div class="blocked-item-meta">${relativeTime(event.timestamp)} / ${formatTime(event.timestamp)}</div>
</div>
<div class="analysis-log-detail">建议价 ${formatMoney(event.entry_price)} / 现价 ${formatMoney(event.current_price)} / 信心 ${formatPercent(event.confidence || 0, 1)}</div>
<div class="blocked-platforms">
${blockedPlatforms.map((item) => `
<div class="blocked-platform">
<strong>${item.platform}</strong>
<span>${item.tag} | ${item.detail}</span>
</div>
`).join('')}
</div>
</div>
`;
}).join('');
}
function summarizeDecision(decision) { function summarizeDecision(decision) {
if (!decision) return { label: '-', detail: '无数据', tone: 'hold' }; if (!decision) return { label: '-', detail: '无数据', tone: 'hold' };
const decisionType = decision.decision || decision.action || 'HOLD'; const decisionType = decision.decision || decision.action || 'HOLD';
@ -2307,8 +2466,19 @@
<strong>${event.symbol || '-'}</strong> <strong>${event.symbol || '-'}</strong>
${event.decision ? `<span class="event-inline-badge">${event.decision}</span>` : ''} ${event.decision ? `<span class="event-inline-badge">${event.decision}</span>` : ''}
${event.action ? `<span class="event-inline-badge">${event.action}</span>` : ''} ${event.action ? `<span class="event-inline-badge">${event.action}</span>` : ''}
${event.signal_timeframe_text ? `<span class="event-inline-badge">${event.signal_timeframe_text}</span>` : ''}
</div> </div>
<span style="color: var(--muted);">${event.reason || '无说明'}</span> <span style="color: var(--muted);">${event.reason || '无说明'}</span>
${event.event_type === 'execution_blocked_summary' && Array.isArray(event.blocked_platforms) && event.blocked_platforms.length > 0 ? `
<div class="blocked-platforms" style="margin-top: 10px;">
${event.blocked_platforms.map((item) => `
<div class="blocked-platform">
<strong>${item.platform}</strong>
<span>${item.tag} | ${item.detail}</span>
</div>
`).join('')}
</div>
` : ''}
${(event.reason || '').length > 90 ? ` ${(event.reason || '').length > 90 ? `
<details class="event-details"> <details class="event-details">
<summary>查看完整详情</summary> <summary>查看完整详情</summary>
@ -2495,6 +2665,7 @@
renderDecisionPreview(data.crypto_agent?.last_execution_preview || {}); renderDecisionPreview(data.crypto_agent?.last_execution_preview || {});
renderHalts(data.crypto_agent?.platform_halts || {}); renderHalts(data.crypto_agent?.platform_halts || {});
renderExecutionEvents(data.execution_events || []); renderExecutionEvents(data.execution_events || []);
renderBlockedSummaries(data.execution_events || []);
renderAttentionItems(data.management?.attention_items || []); renderAttentionItems(data.management?.attention_items || []);
renderUnifiedPositions(data.management?.positions || []); renderUnifiedPositions(data.management?.positions || []);
renderUnifiedOrders(data.management?.orders || []); renderUnifiedOrders(data.management?.orders || []);