update
This commit is contained in:
parent
f303e19a46
commit
b1fcb6d29e
@ -11,7 +11,11 @@ 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
|
||||
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
|
||||
@ -96,6 +100,12 @@ class CryptoAgent:
|
||||
'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,
|
||||
@ -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):
|
||||
"""单例模式 - 确保只有一个实例"""
|
||||
if cls._instance is None:
|
||||
@ -140,6 +155,7 @@ class CryptoAgent:
|
||||
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() # 添加钉钉服务
|
||||
|
||||
@ -217,6 +233,11 @@ class CryptoAgent:
|
||||
"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,
|
||||
}
|
||||
|
||||
# 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损
|
||||
# key=order_id, value={symbol, is_long, size/contracts, tp_price, sl_price}
|
||||
@ -282,6 +303,205 @@ class CryptoAgent:
|
||||
def _touch_analysis_heartbeat(self):
|
||||
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,
|
||||
event_type: str,
|
||||
symbol: str = "",
|
||||
@ -620,6 +840,7 @@ class CryptoAgent:
|
||||
# 检查实盘挂单是否已成交,补设止盈止损
|
||||
if self.hyperliquid:
|
||||
await self._check_and_set_pending_tp_sl_hyperliquid()
|
||||
await self._check_hyperliquid_missing_tp_sl()
|
||||
if self.bitget:
|
||||
await self._check_and_set_pending_tp_sl_bitget()
|
||||
await self._check_bitget_missing_tp_sl() # 兜底:检查缺少的 TP/SL 并补救
|
||||
@ -638,6 +859,7 @@ class CryptoAgent:
|
||||
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")
|
||||
@ -1375,6 +1597,100 @@ class CryptoAgent:
|
||||
status="warning",
|
||||
)
|
||||
|
||||
await self._notify_execution_summary_if_needed(
|
||||
market_signal=market_signal,
|
||||
current_price=current_price,
|
||||
decisions={
|
||||
"PaperTrading": paper_decision,
|
||||
"Hyperliquid": hyperliquid_decision,
|
||||
"Bitget": bitget_decision,
|
||||
},
|
||||
)
|
||||
|
||||
async def _notify_execution_summary_if_needed(
|
||||
self,
|
||||
market_signal: Dict[str, Any],
|
||||
current_price: float,
|
||||
decisions: Dict[str, Dict[str, Any]],
|
||||
):
|
||||
"""当存在可交易信号,但本轮所有平台都未真正执行时,仅发送一条汇总通知。"""
|
||||
actionable: Dict[str, Dict[str, Any]] = {}
|
||||
executed = False
|
||||
|
||||
for platform_name, decision in (decisions or {}).items():
|
||||
if not decision:
|
||||
continue
|
||||
if decision.get('_execution_succeeded'):
|
||||
executed = True
|
||||
if decision.get('decision') in {'OPEN', 'ADD', 'CLOSE', 'CANCEL_PENDING'}:
|
||||
actionable[platform_name] = decision
|
||||
|
||||
if executed or not actionable:
|
||||
return
|
||||
|
||||
symbol = market_signal.get('symbol', '')
|
||||
signal = self._get_best_signal_from_market(market_signal)
|
||||
if not signal:
|
||||
return
|
||||
|
||||
confidence = signal.get('confidence', 0)
|
||||
entry_type = signal.get('entry_type', 'market')
|
||||
entry_price = signal.get('entry_price', current_price)
|
||||
signal_timeframe = signal.get('timeframe', signal.get('type', 'unknown'))
|
||||
timeframe_map = {'short_term': '短线', 'medium_term': '趋势', 'long_term': '长线'}
|
||||
timeframe_text = timeframe_map.get(signal_timeframe, signal_timeframe)
|
||||
action = signal.get('action', 'wait')
|
||||
action_text = {'buy': '做多', 'sell': '做空', 'wait': '观望'}.get(action, action)
|
||||
|
||||
title = f"[执行汇总] {symbol} 信号未落单"
|
||||
content_parts = [
|
||||
f"**信号**: {action_text} | {timeframe_text} | 📈 **{confidence}%**",
|
||||
"",
|
||||
f"**入场方式**: {entry_type}",
|
||||
f"**建议入场价**: ${entry_price:,.2f}" if isinstance(entry_price, (int, float)) else f"**建议入场价**: {entry_price}",
|
||||
f"**当前价格**: ${current_price:,.2f}",
|
||||
"",
|
||||
"**平台结果**:",
|
||||
]
|
||||
blocked_platforms: List[Dict[str, Any]] = []
|
||||
|
||||
for platform_name, decision in actionable.items():
|
||||
tag, detail = self._classify_execution_block_reason(platform_name, decision)
|
||||
content_parts.append(f"- {platform_name}: **{tag}** | {detail}")
|
||||
blocked_platforms.append({
|
||||
"platform": platform_name,
|
||||
"tag": tag,
|
||||
"detail": detail,
|
||||
"decision": decision.get("decision"),
|
||||
})
|
||||
|
||||
content = "\n".join(content_parts)
|
||||
self._record_execution_event(
|
||||
"SYSTEM",
|
||||
"execution_blocked_summary",
|
||||
symbol=symbol,
|
||||
reason=f"{action_text} {timeframe_text} 信号未落单",
|
||||
status="warning",
|
||||
extra={
|
||||
"signal_action": action,
|
||||
"signal_action_text": action_text,
|
||||
"signal_timeframe": signal_timeframe,
|
||||
"signal_timeframe_text": timeframe_text,
|
||||
"confidence": confidence,
|
||||
"entry_type": entry_type,
|
||||
"entry_price": entry_price,
|
||||
"current_price": current_price,
|
||||
"blocked_platforms": blocked_platforms,
|
||||
},
|
||||
)
|
||||
|
||||
if self.settings.feishu_enabled:
|
||||
await self.feishu.send_card(title, content, "orange")
|
||||
if self.settings.telegram_enabled:
|
||||
await self.telegram.send_message(f"{title}\n\n{content}")
|
||||
if self.settings.dingtalk_enabled:
|
||||
await self.dingtalk.send_action_card(title, content)
|
||||
|
||||
async def _execute_paper_decisions(self, decision: Dict[str, Any],
|
||||
market_signal: Dict[str, Any],
|
||||
current_price: float):
|
||||
@ -1405,12 +1721,12 @@ class CryptoAgent:
|
||||
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},
|
||||
)
|
||||
await self._send_signal_notification(market_signal, decision, current_price)
|
||||
|
||||
# TP/SL 警告
|
||||
if result.get('tp_sl_warning'):
|
||||
@ -1427,8 +1743,8 @@ class CryptoAgent:
|
||||
|
||||
if result.get('success'):
|
||||
logger.info(f" ✅ 平仓成功")
|
||||
decision['_execution_succeeded'] = True
|
||||
self._record_execution_event("PaperTrading", "close_success", decision=decision, status="success")
|
||||
await self._send_signal_notification(market_signal, decision, current_price)
|
||||
if next_decision:
|
||||
await self._execute_paper_decisions(next_decision, market_signal, current_price)
|
||||
else:
|
||||
@ -1450,11 +1766,11 @@ class CryptoAgent:
|
||||
|
||||
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},
|
||||
)
|
||||
await self._send_signal_notification(market_signal, decision, current_price)
|
||||
if next_decision:
|
||||
await self._execute_paper_decisions(next_decision, market_signal, current_price)
|
||||
else:
|
||||
@ -1908,6 +2224,8 @@ class CryptoAgent:
|
||||
# 根据配置发送通知 - [信号] 发送到 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}"
|
||||
@ -2583,8 +2901,8 @@ class CryptoAgent:
|
||||
f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
])
|
||||
logger.error(f"[Hyperliquid] {operation} 失败 | {symbol} | {error}")
|
||||
if self.settings.feishu_enabled:
|
||||
await self.feishu.send_card(title, content, "red")
|
||||
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:
|
||||
@ -2599,8 +2917,8 @@ class CryptoAgent:
|
||||
f"🕐 **时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
])
|
||||
logger.error(f"[Bitget] {operation} 失败 | {symbol} | {error}")
|
||||
if self.settings.feishu_enabled:
|
||||
await self.feishu.send_card(title, content, "red")
|
||||
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:
|
||||
@ -2735,6 +3053,7 @@ class CryptoAgent:
|
||||
current_leverage = account.get('current_total_leverage', 0)
|
||||
max_leverage = account.get('max_total_leverage', 10)
|
||||
order_leverage = account.get('order_leverage', 10)
|
||||
min_effective_leverage = self.SIGNAL_MIN_EFFECTIVE_LEVERAGE.get(signal_type, 2.0)
|
||||
|
||||
target_margin_pct, sizing_reason, _, _ = resolve_target_margin_pct(
|
||||
position_size=position_size,
|
||||
@ -2773,6 +3092,7 @@ class CryptoAgent:
|
||||
target_margin_pct=target_margin_pct,
|
||||
max_margin_pct=max_margin_pct,
|
||||
min_margin=min_margin,
|
||||
min_effective_leverage=min_effective_leverage,
|
||||
)
|
||||
|
||||
if margin <= 0:
|
||||
@ -2780,6 +3100,7 @@ class CryptoAgent:
|
||||
|
||||
return margin, (
|
||||
f"{sizing_reason} | 平台: {platform_name} | "
|
||||
f"最小有效杠杆 {min_effective_leverage:.1f}x | "
|
||||
f"限制后保证金 ${margin:.2f} ({budget_reason})"
|
||||
)
|
||||
|
||||
@ -3258,6 +3579,7 @@ class CryptoAgent:
|
||||
order_id = result.get('order_id', 'unknown')
|
||||
order_status = result.get('order_status', 'filled')
|
||||
logger.info(f" ✅ Bitget 交易成功: {order_id} ({order_status})")
|
||||
decision['_execution_succeeded'] = True
|
||||
self._record_execution_event(
|
||||
"Bitget", "open_success", decision=decision, status="success",
|
||||
reason=decision.get('reason', decision.get('reasoning', '')),
|
||||
@ -3279,12 +3601,15 @@ class CryptoAgent:
|
||||
if result.get('pending_tp_sl'):
|
||||
order_id = result.get('order_id')
|
||||
if order_id:
|
||||
self._bg_pending_tp_sl[order_id] = {
|
||||
'symbol': symbol,
|
||||
'is_long': decision.get('action') == 'buy',
|
||||
'contracts': result.get('contracts', 0),
|
||||
**result['pending_tp_sl']
|
||||
}
|
||||
signal_action = decision.get('signal_action', decision.get('action'))
|
||||
pending_tp_sl = result.get('pending_tp_sl') or {}
|
||||
self._bg_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
|
||||
symbol=symbol,
|
||||
is_long=signal_action == 'buy',
|
||||
size=result.get('contracts', 0),
|
||||
tp_price=pending_tp_sl.get('tp_price'),
|
||||
sl_price=pending_tp_sl.get('sl_price'),
|
||||
)
|
||||
logger.info(f" 📌 已记录挂单 TP/SL (oid={order_id})")
|
||||
else:
|
||||
error = result.get('error', result.get('message', '未知错误'))
|
||||
@ -3299,8 +3624,8 @@ class CryptoAgent:
|
||||
|
||||
if result.get('success'):
|
||||
logger.info(f" ✅ Bitget 平仓成功")
|
||||
decision['_execution_succeeded'] = True
|
||||
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:
|
||||
await self._execute_bitget_decisions(next_decision, market_signal, current_price)
|
||||
else:
|
||||
@ -3325,6 +3650,7 @@ class CryptoAgent:
|
||||
|
||||
if success_count > 0:
|
||||
logger.info(f" ✅ Bitget 取消成功: {success_count} 个挂单")
|
||||
decision['_execution_succeeded'] = True
|
||||
self._record_execution_event(
|
||||
"Bitget", "cancel_success", decision=decision, status="success",
|
||||
extra={"cancelled_count": success_count},
|
||||
@ -3613,6 +3939,7 @@ class CryptoAgent:
|
||||
if result.get('success'):
|
||||
order_status = result.get('order_status', 'filled')
|
||||
logger.info(f" ✅ Hyperliquid 交易成功 ({order_status})")
|
||||
decision['_execution_succeeded'] = True
|
||||
self._record_execution_event(
|
||||
"Hyperliquid", "open_success", decision=decision, status="success",
|
||||
reason=decision.get('reason', decision.get('reasoning', '')),
|
||||
@ -3623,6 +3950,21 @@ class CryptoAgent:
|
||||
prefix="[Hyperliquid]",
|
||||
hl_order_status=order_status
|
||||
)
|
||||
if result.get('pending_tp_sl'):
|
||||
order_id = str(result.get('order_id') or '')
|
||||
pending_tp_sl = result.get('pending_tp_sl') or {}
|
||||
tracking_key = order_id or f"{symbol}:{datetime.now().timestamp()}"
|
||||
signal_action = decision.get('signal_action', decision.get('action'))
|
||||
self._hl_pending_tp_sl[tracking_key] = self._build_pending_tp_sl_task(
|
||||
symbol=symbol.replace('USDT', ''),
|
||||
is_long=signal_action == 'buy',
|
||||
size=result.get('position_size') or result.get('size') or 0,
|
||||
tp_price=pending_tp_sl.get('tp_price'),
|
||||
sl_price=pending_tp_sl.get('sl_price'),
|
||||
order_status=order_status,
|
||||
has_real_order_id=bool(order_id),
|
||||
)
|
||||
logger.info(f" 📌 已记录 Hyperliquid TP/SL 待补设任务 (key={tracking_key})")
|
||||
if result.get('tp_sl_warning'):
|
||||
await self._notify_hyperliquid_error(symbol, "设置止盈止损", result['tp_sl_warning'])
|
||||
else:
|
||||
@ -3636,8 +3978,8 @@ class CryptoAgent:
|
||||
result = await executor.execute_close(decision, current_price)
|
||||
if result.get('success'):
|
||||
logger.info(f" ✅ Hyperliquid 平仓成功")
|
||||
decision['_execution_succeeded'] = True
|
||||
self._record_execution_event("Hyperliquid", "close_success", decision=decision, status="success")
|
||||
await self._send_signal_notification(market_signal, decision, current_price, prefix="[Hyperliquid]")
|
||||
if next_decision:
|
||||
await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price)
|
||||
else:
|
||||
@ -3657,6 +3999,7 @@ class CryptoAgent:
|
||||
success_count += 1
|
||||
if success_count > 0:
|
||||
logger.info(f" ✅ Hyperliquid 取消成功: {success_count} 个")
|
||||
decision['_execution_succeeded'] = True
|
||||
self._record_execution_event(
|
||||
"Hyperliquid", "cancel_success", decision=decision, status="success",
|
||||
extra={"cancelled_count": success_count},
|
||||
@ -3835,24 +4178,73 @@ class CryptoAgent:
|
||||
try:
|
||||
for order_id, info in list(self._hl_pending_tp_sl.items()):
|
||||
symbol = info['symbol']
|
||||
open_orders = self.hyperliquid.get_open_orders(symbol)
|
||||
still_open = any(str(o.get('order_id')) == order_id for o in open_orders)
|
||||
has_real_order_id = info.get('has_real_order_id', True)
|
||||
if has_real_order_id:
|
||||
open_orders = self.hyperliquid.get_open_orders(symbol)
|
||||
still_open = any(str(o.get('order_id')) == order_id for o in open_orders)
|
||||
else:
|
||||
still_open = info.get('order_status') == 'resting'
|
||||
if not still_open:
|
||||
# 订单已不在挂单列表 → 已成交,补设 TP/SL
|
||||
tp_price = info.get('tp_price')
|
||||
sl_price = info.get('sl_price')
|
||||
position = self.hyperliquid.get_position_for_symbol(symbol)
|
||||
if not position:
|
||||
logger.info(f"[Hyperliquid] 挂单/持仓 {order_id} ({symbol}) 当前无持仓,跳过 TP/SL 补设")
|
||||
del self._hl_pending_tp_sl[order_id]
|
||||
continue
|
||||
size = info.get('size') or abs(position.get('size', 0))
|
||||
if size <= 0:
|
||||
logger.warning(f"[Hyperliquid] 挂单/持仓 {order_id} ({symbol}) 数量无效,跳过 TP/SL 补设")
|
||||
del self._hl_pending_tp_sl[order_id]
|
||||
continue
|
||||
logger.info(f"[Hyperliquid] 挂单 {order_id} ({symbol}) 已成交,补设 TP/SL...")
|
||||
tp_sl_result = self.hyperliquid.set_tp_sl(
|
||||
symbol=symbol,
|
||||
is_long=info['is_long'],
|
||||
size=info['size'],
|
||||
size=size,
|
||||
tp_price=tp_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}")
|
||||
elif tp_set or sl_set:
|
||||
missing_tp = tp_price if not tp_set else None
|
||||
missing_sl = sl_price if not sl_set else None
|
||||
self._hl_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
|
||||
symbol=info['symbol'],
|
||||
is_long=info['is_long'],
|
||||
size=size,
|
||||
tp_price=missing_tp,
|
||||
sl_price=missing_sl,
|
||||
order_status=info.get('order_status'),
|
||||
has_real_order_id=info.get('has_real_order_id', True),
|
||||
retry_count=info.get('retry_count', 0),
|
||||
first_seen_at=info.get('first_seen_at'),
|
||||
last_alert_at=info.get('last_alert_at'),
|
||||
)
|
||||
set_text = "TP" if tp_set else "SL"
|
||||
fail_text = "TP" if not tp_set else "SL"
|
||||
logger.warning(f"[Hyperliquid] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设")
|
||||
await self._maybe_alert_tp_sl_incomplete(
|
||||
"Hyperliquid",
|
||||
order_id,
|
||||
self._hl_pending_tp_sl[order_id],
|
||||
f"{set_text}已设,{fail_text}补设失败",
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"[Hyperliquid] ⚠️ TP/SL 补设失败: {tp_sl_result.get('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]
|
||||
except Exception as e:
|
||||
logger.error(f"[Hyperliquid] 检查挂单 TP/SL 补设异常: {e}")
|
||||
@ -3876,10 +4268,11 @@ class CryptoAgent:
|
||||
tp_sl_result = self.bitget.set_tp_sl(
|
||||
symbol=symbol,
|
||||
is_long=info['is_long'],
|
||||
size=info['contracts'],
|
||||
size=info['size'],
|
||||
tp_price=tp_price,
|
||||
sl_price=sl_price,
|
||||
)
|
||||
info['retry_count'] = int(info.get('retry_count', 0)) + 1
|
||||
tp_set = tp_sl_result.get('tp_set', False)
|
||||
sl_set = tp_sl_result.get('sl_set', False)
|
||||
|
||||
@ -3890,17 +4283,34 @@ class CryptoAgent:
|
||||
missing_tp = tp_price if not tp_set else None
|
||||
missing_sl = sl_price if not sl_set else None
|
||||
if missing_tp or missing_sl:
|
||||
self._bg_pending_tp_sl[order_id] = {
|
||||
**info,
|
||||
'tp_price': missing_tp,
|
||||
'sl_price': missing_sl,
|
||||
}
|
||||
self._bg_pending_tp_sl[order_id] = self._build_pending_tp_sl_task(
|
||||
symbol=info['symbol'],
|
||||
is_long=info['is_long'],
|
||||
size=info['size'],
|
||||
tp_price=missing_tp,
|
||||
sl_price=missing_sl,
|
||||
retry_count=info.get('retry_count', 0),
|
||||
first_seen_at=info.get('first_seen_at'),
|
||||
last_alert_at=info.get('last_alert_at'),
|
||||
)
|
||||
set_text = "TP" if tp_set else "SL"
|
||||
fail_text = "TP" if not tp_set else "SL"
|
||||
logger.warning(f"[Bitget] ⚠️ TP/SL 部分成功: {symbol} {set_text}已设, {fail_text}待下轮补设")
|
||||
await self._maybe_alert_tp_sl_incomplete(
|
||||
"Bitget",
|
||||
order_id,
|
||||
self._bg_pending_tp_sl[order_id],
|
||||
f"{set_text}已设,{fail_text}补设失败",
|
||||
)
|
||||
continue # 不删除,下轮继续
|
||||
else:
|
||||
logger.warning(f"[Bitget] ⚠️ TP/SL 补设失败: {tp_sl_result.get('errors')}")
|
||||
await self._maybe_alert_tp_sl_incomplete(
|
||||
"Bitget",
|
||||
order_id,
|
||||
info,
|
||||
str(tp_sl_result.get('errors') or 'TP/SL补设失败'),
|
||||
)
|
||||
continue # 不删除,下轮继续重试
|
||||
|
||||
del self._bg_pending_tp_sl[order_id]
|
||||
@ -3981,10 +4391,110 @@ class CryptoAgent:
|
||||
logger.info(f"[Bitget] ✅ 补救成功: {symbol} {' & '.join(set_parts)}")
|
||||
else:
|
||||
logger.warning(f"[Bitget] ⚠️ 补救失败: {tp_sl_result.get('errors')}")
|
||||
await self._maybe_alert_tp_sl_incomplete(
|
||||
"Bitget",
|
||||
f"fallback:{symbol}",
|
||||
self._build_pending_tp_sl_task(
|
||||
symbol=coin,
|
||||
is_long=pos.get('size', 0) > 0,
|
||||
size=size,
|
||||
tp_price=set_tp,
|
||||
sl_price=set_sl,
|
||||
retry_count=self.TP_SL_RETRY_ALERT_THRESHOLD,
|
||||
),
|
||||
str(tp_sl_result.get('errors') or '兜底补设失败'),
|
||||
force=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Bitget] 止盈止损兜底检查异常: {e}")
|
||||
|
||||
async def _check_hyperliquid_missing_tp_sl(self):
|
||||
"""定时检查 Hyperliquid 持仓是否缺少止盈止损,缺少则从最新信号补救"""
|
||||
if not self.hyperliquid:
|
||||
return
|
||||
try:
|
||||
positions = self.hyperliquid.get_open_positions()
|
||||
if not positions:
|
||||
return
|
||||
|
||||
for pos in positions:
|
||||
symbol = pos.get('symbol', '')
|
||||
if not symbol:
|
||||
continue
|
||||
|
||||
coin = symbol.replace('USDT', '')
|
||||
tp_sl = self.hyperliquid.get_tp_sl_prices(coin)
|
||||
has_tp = tp_sl.get('take_profit') is not None
|
||||
has_sl = tp_sl.get('stop_loss') is not None
|
||||
|
||||
if has_tp and has_sl:
|
||||
continue
|
||||
|
||||
latest_signal = self.signal_db.get_latest_signal('crypto', symbol)
|
||||
if not latest_signal:
|
||||
missing = ('止盈' if not has_tp else '') + ('/' if not has_tp and not has_sl else '') + ('止损' if not has_sl else '')
|
||||
logger.warning(f"[Hyperliquid] ⚠️ {symbol} 缺少{missing},且无历史信号可补救")
|
||||
continue
|
||||
|
||||
tp_price = latest_signal.get('take_profit')
|
||||
sl_price = latest_signal.get('stop_loss')
|
||||
|
||||
if not tp_price and not sl_price:
|
||||
logger.warning(f"[Hyperliquid] ⚠️ {symbol} 缺少止盈止损,最近信号也无 TP/SL")
|
||||
continue
|
||||
|
||||
set_tp = tp_price if not has_tp else None
|
||||
set_sl = sl_price if not has_sl else None
|
||||
|
||||
missing_parts = []
|
||||
if not has_tp:
|
||||
missing_parts.append(f"TP={set_tp}")
|
||||
if not has_sl:
|
||||
missing_parts.append(f"SL={set_sl}")
|
||||
logger.warning(f"[Hyperliquid] 🔧 {symbol} 缺少 {' & '.join(missing_parts)},从信号补救...")
|
||||
|
||||
size = abs(pos.get('size', 0))
|
||||
if size <= 0:
|
||||
continue
|
||||
|
||||
tp_sl_result = self.hyperliquid.set_tp_sl(
|
||||
symbol=coin,
|
||||
is_long=pos.get('size', 0) > 0,
|
||||
size=size,
|
||||
tp_price=set_tp,
|
||||
sl_price=set_sl,
|
||||
)
|
||||
|
||||
tp_set = tp_sl_result.get('tp_set', False)
|
||||
sl_set = tp_sl_result.get('sl_set', False)
|
||||
if tp_set or sl_set:
|
||||
set_parts = []
|
||||
if tp_set:
|
||||
set_parts.append(f"TP={set_tp}")
|
||||
if sl_set:
|
||||
set_parts.append(f"SL={set_sl}")
|
||||
logger.info(f"[Hyperliquid] ✅ 补救成功: {symbol} {' & '.join(set_parts)}")
|
||||
else:
|
||||
logger.warning(f"[Hyperliquid] ⚠️ 补救失败: {tp_sl_result.get('errors') or tp_sl_result.get('error')}")
|
||||
await self._maybe_alert_tp_sl_incomplete(
|
||||
"Hyperliquid",
|
||||
f"fallback:{symbol}",
|
||||
self._build_pending_tp_sl_task(
|
||||
symbol=coin,
|
||||
is_long=pos.get('size', 0) > 0,
|
||||
size=size,
|
||||
tp_price=set_tp,
|
||||
sl_price=set_sl,
|
||||
retry_count=self.TP_SL_RETRY_ALERT_THRESHOLD,
|
||||
),
|
||||
str(tp_sl_result.get('errors') or tp_sl_result.get('error') or '兜底补设失败'),
|
||||
force=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Hyperliquid] 止盈止损兜底检查异常: {e}")
|
||||
|
||||
def _calculate_hyperliquid_position_size(self, decision: Dict[str, Any], current_price: float) -> float:
|
||||
"""
|
||||
计算 Hyperliquid 仓位大小(基于可用保证金和风控限制)
|
||||
@ -4202,89 +4712,6 @@ class CryptoAgent:
|
||||
if self.settings.dingtalk_enabled:
|
||||
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]:
|
||||
"""单次分析并返回市场信号与平台执行预览"""
|
||||
data = self.exchange.get_multi_timeframe_data(symbol)
|
||||
@ -4365,6 +4792,7 @@ class CryptoAgent:
|
||||
'mode': 'LLM 驱动',
|
||||
'platform_halts': self.get_platform_halt_status(),
|
||||
'analysis_monitor': self._analysis_monitor,
|
||||
'analysis_notifications': self._analysis_notification_state,
|
||||
'last_signals': {
|
||||
symbol: {
|
||||
'type': sig.get('type'),
|
||||
@ -4826,6 +5254,19 @@ class CryptoAgent:
|
||||
}
|
||||
self._save_platform_halts()
|
||||
logger.warning(f"🛑 [{platform_name}] 已标记为平台熔断暂停")
|
||||
if self.settings.feishu_enabled and self.feishu_error:
|
||||
asyncio.create_task(
|
||||
self.feishu_error.send_card(
|
||||
f"🛑 [{platform_name}] 平台已停机",
|
||||
"\n".join([
|
||||
f"**原因**: {reason}",
|
||||
f"**回撤**: {drawdown_pct:.2f}%",
|
||||
f"**当前权益**: ${current_balance:,.2f}",
|
||||
f"**初始权益**: ${initial_balance:,.2f}",
|
||||
]),
|
||||
"red",
|
||||
)
|
||||
)
|
||||
|
||||
def get_platform_halt_status(self) -> Dict[str, Any]:
|
||||
result = {}
|
||||
@ -4889,6 +5330,18 @@ class CryptoAgent:
|
||||
}
|
||||
self._save_platform_halts()
|
||||
logger.info(f"✅ [{platform_name}] 已手动恢复,初始权益重置为 ${current_balance:.2f}")
|
||||
if self.settings.feishu_enabled and self.feishu_error:
|
||||
asyncio.create_task(
|
||||
self.feishu_error.send_card(
|
||||
f"✅ [{platform_name}] 平台已恢复",
|
||||
"\n".join([
|
||||
f"**当前权益**: ${current_balance:,.2f}",
|
||||
f"**重置初始权益**: ${current_balance:,.2f}",
|
||||
f"**恢复时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
]),
|
||||
"green",
|
||||
)
|
||||
)
|
||||
return self._platform_halts[platform_name]
|
||||
|
||||
# ==================== 初始余额持久化 ====================
|
||||
@ -4953,7 +5406,9 @@ class CryptoAgent:
|
||||
"""发送告警通知(飞书/钉钉/Telegram)"""
|
||||
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}")
|
||||
|
||||
# 钉钉
|
||||
|
||||
@ -12,6 +12,12 @@ from app.utils.logger import logger
|
||||
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):
|
||||
self.platform_name = platform_name
|
||||
|
||||
@ -338,6 +344,30 @@ class BaseExecutor(ABC):
|
||||
|
||||
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
|
||||
def get_fee_rate(self) -> float:
|
||||
"""
|
||||
@ -775,4 +805,3 @@ class BaseExecutor(ABC):
|
||||
color = "green" if success else "red"
|
||||
|
||||
await self.feishu.send_card(title, content, color)
|
||||
|
||||
|
||||
@ -40,6 +40,12 @@ class BitgetExecutor(BaseExecutor):
|
||||
|
||||
# 计算合约张数,必须与实际执行杠杆保持一致
|
||||
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:
|
||||
return {
|
||||
@ -49,6 +55,12 @@ class BitgetExecutor(BaseExecutor):
|
||||
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)
|
||||
@ -128,21 +140,7 @@ class BitgetExecutor(BaseExecutor):
|
||||
|
||||
logger.info(f" ✅ 开仓成功: {symbol} {contracts}张 @ ${order_type}")
|
||||
|
||||
# 发送飞书通知
|
||||
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
|
||||
}
|
||||
)
|
||||
# 开仓成功通知由 crypto_agent 统一发送,避免与执行摘要重复
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@ -46,12 +46,24 @@ class HyperliquidExecutor(BaseExecutor):
|
||||
|
||||
# 计算仓位大小
|
||||
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:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'仓位计算失败: {position_size}'
|
||||
}
|
||||
if not leverage_ok:
|
||||
return {
|
||||
'success': False,
|
||||
'error': leverage_reason,
|
||||
'effective_leverage': effective_leverage,
|
||||
}
|
||||
|
||||
# 设置杠杆
|
||||
self.hyperliquid.update_leverage(symbol, leverage)
|
||||
@ -103,38 +115,44 @@ class HyperliquidExecutor(BaseExecutor):
|
||||
if tp_set and sl_set:
|
||||
logger.info(f" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}")
|
||||
elif tp_set or sl_set:
|
||||
# 部分成功:记录缺失侧
|
||||
# 部分成功:记录缺失侧,交给 agent 后续补设
|
||||
set_text = "TP" if 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}失败")
|
||||
result['tp_sl_warning'] = f"{fail_text}设置失败: {tp_sl_result.get('errors', [])}"
|
||||
else:
|
||||
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}")
|
||||
result['tp_sl_warning'] = f"TP/SL设置失败: {'; '.join(errors)}"
|
||||
except Exception as 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)
|
||||
else:
|
||||
# 限价单未成交,暂时跳过(等成交后再设)
|
||||
logger.info(f" 📌 限价单待成交,TP/SL 将在成交后设置: TP={take_profit}, SL={stop_loss}")
|
||||
result['tp_sl_warning'] = "限价单未成交,TP/SL 待成交后设置"
|
||||
# 限价单未成交:记录下来,等成交后自动补设
|
||||
result['pending_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 已加入待补设列表"
|
||||
|
||||
# 发送飞书通知(在止盈止损之后,通知失败不影响交易结果)
|
||||
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
|
||||
}
|
||||
)
|
||||
# 开仓成功通知由 crypto_agent 统一发送,避免与执行摘要重复
|
||||
|
||||
return result
|
||||
|
||||
@ -228,10 +246,15 @@ class HyperliquidExecutor(BaseExecutor):
|
||||
position_size: float) -> Dict[str, Any]:
|
||||
"""设置止盈止损"""
|
||||
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(
|
||||
symbol=symbol.replace('USDT', ''),
|
||||
is_long=pos['size'] > 0,
|
||||
size=position_size,
|
||||
tp_price=take_profit,
|
||||
sl_price=stop_loss
|
||||
|
||||
@ -33,6 +33,18 @@ class PaperTradingExecutor(BaseExecutor):
|
||||
|
||||
# 调整保证金(模拟盘无手续费)
|
||||
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 = decision.get('confidence', 0)
|
||||
|
||||
@ -294,6 +294,7 @@ _feishu_crypto_service: Optional[FeishuService] = None
|
||||
_feishu_stock_service: Optional[FeishuService] = None
|
||||
_feishu_news_service: Optional[FeishuService] = None
|
||||
_feishu_paper_trading_service: Optional[FeishuService] = None
|
||||
_feishu_error_service: Optional[FeishuService] = None
|
||||
|
||||
|
||||
def get_feishu_service() -> FeishuService:
|
||||
@ -331,3 +332,11 @@ def get_feishu_paper_trading_service() -> FeishuService:
|
||||
if _feishu_paper_trading_service is None:
|
||||
_feishu_paper_trading_service = FeishuService(service_type="paper_trading")
|
||||
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
|
||||
|
||||
@ -115,6 +115,7 @@ def calculate_margin_and_position_value(
|
||||
target_margin_pct: float,
|
||||
max_margin_pct: float,
|
||||
min_margin: float = 0.0,
|
||||
min_effective_leverage: float = 1.0,
|
||||
reserve_ratio: float = 0.05,
|
||||
) -> Tuple[float, float, str]:
|
||||
if balance <= 0:
|
||||
@ -123,6 +124,13 @@ def calculate_margin_and_position_value(
|
||||
return 0.0, 0.0, "可用保证金不足"
|
||||
if order_leverage <= 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:
|
||||
return 0.0, 0.0, "目标保证金比例无效"
|
||||
|
||||
|
||||
@ -1093,6 +1093,105 @@
|
||||
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 {
|
||||
padding: 12px 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>
|
||||
<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="loading">正在读取分析日志...</div>
|
||||
</div>
|
||||
@ -2150,7 +2261,9 @@
|
||||
function renderAnalysisHeartbeat(analysisMonitor, analysisEvents) {
|
||||
const heartbeat = document.getElementById('analysisHeartbeat');
|
||||
const logList = document.getElementById('analysisLogList');
|
||||
const summaryCard = document.getElementById('runtimeSummaryCard');
|
||||
const monitor = analysisMonitor || {};
|
||||
const notifications = cachedConsoleData?.crypto_agent?.analysis_notifications || {};
|
||||
const cycleStatus = monitor.last_cycle_status || 'idle';
|
||||
const progressText = monitor.current_cycle_total
|
||||
? `${monitor.current_cycle_index || 0}/${monitor.current_cycle_total} ${monitor.current_cycle_symbol || ''}`
|
||||
@ -2175,6 +2288,19 @@
|
||||
</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) {
|
||||
logList.innerHTML = compactEmpty('最近还没有分析日志', '等待下一轮分析或新的运行事件写入。');
|
||||
return;
|
||||
@ -2191,6 +2317,39 @@
|
||||
`).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) {
|
||||
if (!decision) return { label: '-', detail: '无数据', tone: 'hold' };
|
||||
const decisionType = decision.decision || decision.action || 'HOLD';
|
||||
@ -2307,8 +2466,19 @@
|
||||
<strong>${event.symbol || '-'}</strong>
|
||||
${event.decision ? `<span class="event-inline-badge">${event.decision}</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>
|
||||
<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 ? `
|
||||
<details class="event-details">
|
||||
<summary>查看完整详情</summary>
|
||||
@ -2495,6 +2665,7 @@
|
||||
renderDecisionPreview(data.crypto_agent?.last_execution_preview || {});
|
||||
renderHalts(data.crypto_agent?.platform_halts || {});
|
||||
renderExecutionEvents(data.execution_events || []);
|
||||
renderBlockedSummaries(data.execution_events || []);
|
||||
renderAttentionItems(data.management?.attention_items || []);
|
||||
renderUnifiedPositions(data.management?.positions || []);
|
||||
renderUnifiedOrders(data.management?.orders || []);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user