diff --git a/backend/app/api/paper_trading.py b/backend/app/api/paper_trading.py index 8be8086..af10f46 100644 --- a/backend/app/api/paper_trading.py +++ b/backend/app/api/paper_trading.py @@ -11,6 +11,7 @@ from app.services.price_monitor_service import get_price_monitor_service from app.services.bitget_service import bitget_service from app.services.db_service import db_service from app.utils.logger import logger +from app.crypto_agent.crypto_agent import get_crypto_agent router = APIRouter(prefix="/api/trading", tags=["交易"]) @@ -27,6 +28,11 @@ class DeleteOrdersRequest(BaseModel): recalculate: bool = True # 是否重新计算统计数据 +class ResumePlatformRequest(BaseModel): + """恢复平台执行请求""" + platform: str + + class OrderResponse(BaseModel): """订单响应""" success: bool @@ -219,6 +225,39 @@ async def delete_order( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/platform-halts") +async def get_platform_halts(): + """获取平台熔断/停机状态""" + try: + agent = get_crypto_agent() + return { + "success": True, + "platform_halts": agent.get_platform_halt_status(), + } + except Exception as e: + logger.error(f"获取平台熔断状态失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/platform-halts/resume") +async def resume_platform(request: ResumePlatformRequest): + """手动恢复指定平台执行""" + try: + agent = get_crypto_agent() + result = agent.resume_platform(request.platform) + return { + "success": True, + "message": f"{request.platform} 已恢复执行", + "platform": request.platform, + "status": result, + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"恢复平台执行失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/orders/batch-delete") async def batch_delete_orders(request: DeleteOrdersRequest): """ diff --git a/backend/app/api/system.py b/backend/app/api/system.py index d9774bd..e067485 100644 --- a/backend/app/api/system.py +++ b/backend/app/api/system.py @@ -1,14 +1,33 @@ """ 系统状态 API """ +from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException from typing import Dict, Any from app.utils.logger import logger from app.utils.system_status import get_system_monitor +from app.crypto_agent.crypto_agent import get_crypto_agent +from app.services.signal_database_service import get_signal_db_service +from app.services.paper_trading_service import get_paper_trading_service +from app.services.bitget_live_trading_service import get_bitget_live_service +from app.services.hyperliquid_trading_service import get_hyperliquid_service router = APIRouter() +def _parse_signal_timestamp(value: Any) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value.replace(tzinfo=None) if value.tzinfo else value + text = str(value).replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(text) + return parsed.replace(tzinfo=None) if parsed.tzinfo else parsed + except ValueError: + return None + + @router.get("/status", response_model=Dict[str, Any]) async def get_system_status(): """ @@ -91,3 +110,155 @@ async def get_agent_status(agent_id: str): except Exception as e: logger.error(f"获取 Agent 状态失败: {e}") raise HTTPException(status_code=500, detail=f"获取 Agent 状态失败: {str(e)}") + + +@router.get("/console", response_model=Dict[str, Any]) +async def get_console_snapshot(): + """ + 获取总控台快照 + + 聚合系统运行态、信号统计、模拟盘与实盘平台状态,供总控台页面使用。 + """ + try: + monitor = get_system_monitor() + summary = monitor.get_summary() + now = datetime.now() + + signal_db = get_signal_db_service() + signal_stats = signal_db.get_signal_stats(days=7) + latest_signals = signal_db.get_latest_signals(limit=12, days=3) + + crypto_agent = get_crypto_agent() + crypto_status = crypto_agent.get_status() + + paper_service = get_paper_trading_service() + paper_account = paper_service.get_account_status() + paper_orders = paper_service.get_active_orders() + paper_positions = [o for o in paper_orders if o.get('status') == 'open'] + paper_pending = [o for o in paper_orders if o.get('status') == 'pending'] + paper_stats = paper_service.calculate_statistics() + + bitget_service = get_bitget_live_service() + bitget_summary = {"enabled": False} + if bitget_service is not None: + bg_account = bitget_service.get_account_state() + bg_positions = bitget_service.get_open_positions() + bg_orders = bitget_service.get_open_orders() + bg_total_position_value = sum(abs(p["size"]) * p["entry_price"] for p in bg_positions) + bg_drawdown = 0.0 + if bitget_service.initial_balance and bitget_service.initial_balance > 0: + bg_drawdown = (bitget_service.initial_balance - bg_account["account_value"]) / bitget_service.initial_balance * 100 + + bitget_summary = { + "enabled": True, + "account": { + "account_value": bg_account.get("account_value", 0), + "available_balance": bg_account.get("available_balance", 0), + "total_margin_used": bg_account.get("total_margin_used", 0), + "initial_balance": bitget_service.initial_balance, + }, + "positions": { + "count": len(bg_positions), + "total_value": bg_total_position_value, + "items": bg_positions[:8], + }, + "orders": { + "count": len(bg_orders), + "entry_orders": len([o for o in bg_orders if not o.get("is_reduce_only")]), + "tp_sl_orders": len([o for o in bg_orders if o.get("is_reduce_only")]), + "items": bg_orders[:8], + }, + "risk": { + "current_leverage": bg_total_position_value / bg_account["account_value"] if bg_account.get("account_value", 0) > 0 else 0, + "max_leverage": bitget_service.max_total_leverage, + "drawdown_percent": bg_drawdown, + "circuit_breaker_threshold": bitget_service.circuit_breaker_drawdown * 100, + }, + } + + hyperliquid_service = get_hyperliquid_service() + hyperliquid_summary = {"enabled": False} + if hyperliquid_service is not None: + hl_account = hyperliquid_service.get_account_state() + hl_positions = hyperliquid_service.get_open_positions() + hl_orders = hyperliquid_service.get_open_orders() + hl_total_position_value = sum(abs(p["size"]) * p["entry_price"] for p in hl_positions) + hl_drawdown = 0.0 + if hyperliquid_service.initial_balance and hyperliquid_service.initial_balance > 0: + hl_drawdown = (hyperliquid_service.initial_balance - hl_account["account_value"]) / hyperliquid_service.initial_balance * 100 + + hyperliquid_summary = { + "enabled": True, + "account": { + "account_value": hl_account.get("account_value", 0), + "available_balance": hl_account.get("available_balance", 0), + "total_margin_used": hl_account.get("total_margin_used", 0), + "initial_balance": hyperliquid_service.initial_balance, + }, + "positions": { + "count": len(hl_positions), + "total_value": hl_total_position_value, + "items": hl_positions[:8], + }, + "orders": { + "count": len(hl_orders), + "entry_orders": len([o for o in hl_orders if not o.get("is_reduce_only")]), + "tp_sl_orders": len([o for o in hl_orders if o.get("is_reduce_only")]), + "items": hl_orders[:8], + }, + "risk": { + "current_leverage": hl_total_position_value / hl_account["account_value"] if hl_account.get("account_value", 0) > 0 else 0, + "max_leverage": hyperliquid_service.max_total_leverage, + "drawdown_percent": hl_drawdown, + "circuit_breaker_threshold": hyperliquid_service.circuit_breaker_drawdown * 100, + }, + } + + recent_cutoff = now - timedelta(minutes=30) + recent_signal_count = sum( + 1 + for signal in latest_signals + if (_parse_signal_timestamp(signal.get("created_at")) or datetime.min) >= recent_cutoff + ) + + return { + "status": "success", + "data": { + "generated_at": now.isoformat(), + "system": summary, + "crypto_agent": crypto_status, + "execution_events": crypto_agent.get_recent_execution_events(limit=40), + "signals": { + "stats_7d": signal_stats, + "latest": latest_signals, + "recent_30m_count": recent_signal_count, + }, + "platforms": { + "paper": { + "enabled": True, + "account": paper_account, + "positions": { + "count": len(paper_positions), + "items": paper_positions[:8], + }, + "orders": { + "count": len(paper_orders), + "pending_count": len(paper_pending), + "items": paper_pending[:8], + }, + "statistics": { + "win_rate": paper_stats.get("win_rate", 0), + "total_trades": paper_stats.get("total_trades", 0), + "total_pnl": paper_stats.get("total_pnl", 0), + "max_drawdown": paper_stats.get("max_drawdown", 0), + "by_grade": paper_stats.get("by_grade", {}), + }, + }, + "bitget": bitget_summary, + "hyperliquid": hyperliquid_summary, + }, + } + } + except Exception as e: + logger.error(f"获取总控台快照失败: {e}") + raise HTTPException(status_code=500, detail=f"获取总控台快照失败: {str(e)}") diff --git a/backend/app/crypto_agent/crypto_agent.py b/backend/app/crypto_agent/crypto_agent.py index 47b2298..2751aa8 100644 --- a/backend/app/crypto_agent/crypto_agent.py +++ b/backend/app/crypto_agent/crypto_agent.py @@ -3,6 +3,7 @@ """ import asyncio import math +from collections import deque from typing import Dict, Any, List, Optional from datetime import datetime, timedelta import pandas as pd @@ -84,17 +85,44 @@ class CryptoAgent: } SIGNAL_MIN_STOP_LOSS_PCT = { - 'short_term': 0.6, - 'medium_term': 1.0, + 'short_term': 0.7, + 'medium_term': 1.5, 'long_term': 1.2, } SIGNAL_MIN_TAKE_PROFIT_PCT = { - 'short_term': 1.0, - 'medium_term': 2.0, + 'short_term': 1.2, + 'medium_term': 3.0, 'long_term': 2.5, } + SIGNAL_EXECUTION_RULES = { + 'short_term': { + 'min_add_price_gap_pct': 1.0, + 'min_add_profit_pct': 1.0, + 'roll_loss_threshold_pct': -1.0, + 'flip_confidence': 92, + 'protect_profit_pct': 1.2, + 'min_remaining_tp_pct': 0.8, + }, + 'medium_term': { + 'min_add_price_gap_pct': 2.0, + 'min_add_profit_pct': 1.5, + 'roll_loss_threshold_pct': -1.2, + 'flip_confidence': 85, + 'protect_profit_pct': 2.0, + 'min_remaining_tp_pct': 1.2, + }, + 'long_term': { + 'min_add_price_gap_pct': 2.5, + 'min_add_profit_pct': 2.0, + 'roll_loss_threshold_pct': -1.5, + 'flip_confidence': 82, + 'protect_profit_pct': 2.5, + 'min_remaining_tp_pct': 1.5, + }, + } + def __new__(cls, *args, **kwargs): """单例模式 - 确保只有一个实例""" if cls._instance is None: @@ -163,11 +191,15 @@ class CryptoAgent: # 状态管理 self.last_signals: Dict[str, Dict[str, Any]] = {} + self.last_execution_preview: Dict[str, Dict[str, Any]] = {} self.signal_cooldown: Dict[str, datetime] = {} # 账户初始余额持久化(用于计算回撤) self._initial_balances: Dict[str, float] = {} self._load_initial_balances() + self._platform_halts: Dict[str, Dict[str, Any]] = {} + self._load_platform_halts() + self._execution_events: deque[Dict[str, Any]] = deque(maxlen=120) # 挂单 TP/SL 追踪:挂单成交后自动补设止盈止损 # key=order_id, value={symbol, is_long, size/contracts, tp_price, sl_price} @@ -205,6 +237,31 @@ class CryptoAgent: logger.info(f"加密货币智能体初始化完成(LLM 驱动),监控交易对: {self.symbols}") logger.info(f"📊 模拟交易: 始终启用") + def _record_execution_event(self, + platform: str, + event_type: str, + symbol: str = "", + decision: Optional[Dict[str, Any]] = None, + reason: str = "", + status: str = "info", + extra: Optional[Dict[str, Any]] = None): + event = { + "timestamp": datetime.now().isoformat(), + "platform": platform, + "event_type": event_type, + "status": status, + "symbol": symbol or (decision or {}).get("symbol", ""), + "decision": (decision or {}).get("decision"), + "action": (decision or {}).get("action"), + "reason": reason or (decision or {}).get("reason") or (decision or {}).get("reasoning", ""), + } + if extra: + event.update(extra) + self._execution_events.appendleft(event) + + def get_recent_execution_events(self, limit: int = 30) -> List[Dict[str, Any]]: + return list(self._execution_events)[:limit] + def _on_price_update(self, symbol: str, price: float): """处理实时价格更新(用于模拟交易)""" if not self.paper_trading: @@ -487,9 +544,8 @@ class CryptoAgent: should_stop, stop_reason = await self._check_account_level_stop_loss() if should_stop: logger.error(f"🚨 {stop_reason}") - # 触发账户级止损,停止所有交易 - self.running = False - break + # 分平台熔断后继续保留信号层和其他平台运行 + logger.warning("账户级止损已触发平台熔断,主循环继续运行") # 检查并取消超时挂单(在分析开始前) cancelled = self.paper_trading.check_and_cancel_expired_orders() @@ -722,7 +778,7 @@ class CryptoAgent: if self.settings.paper_trading_enabled: logger.info(f"\n📊 【模拟盘】") paper_positions, paper_account, paper_pending = self._get_paper_trading_state() - paper_signal = self._select_signal_for_platform(valid_signals, 'PaperTrading', market_state=market_signal.get('market_state', '中性')) + paper_signal = self._select_signal_for_platform(valid_signals, 'PaperTrading', market_state=market_signal.get('market_state', '中性'), trend_direction=market_signal.get('trend_direction', 'neutral')) if paper_signal: logger.info( f" 采用信号: {paper_signal.get('timeframe', 'unknown')} | " @@ -746,7 +802,7 @@ class CryptoAgent: if self.hyperliquid: logger.info(f"\n🔥 【Hyperliquid】") hl_positions, hl_account, hl_pending = self._get_hyperliquid_trading_state() - hl_signal = self._select_signal_for_platform(valid_signals, 'Hyperliquid', market_state=market_signal.get('market_state', '中性')) + hl_signal = self._select_signal_for_platform(valid_signals, 'Hyperliquid', market_state=market_signal.get('market_state', '中性'), trend_direction=market_signal.get('trend_direction', 'neutral')) if hl_signal: logger.info( f" 采用信号: {hl_signal.get('timeframe', 'unknown')} | " @@ -770,7 +826,7 @@ class CryptoAgent: if self.bitget: logger.info(f"\n🔥 【Bitget】") bg_positions, bg_account, bg_pending = self._get_bitget_trading_state() - bg_signal = self._select_signal_for_platform(valid_signals, 'Bitget', market_state=market_signal.get('market_state', '中性')) + bg_signal = self._select_signal_for_platform(valid_signals, 'Bitget', market_state=market_signal.get('market_state', '中性'), trend_direction=market_signal.get('trend_direction', 'neutral')) if bg_signal: logger.info( f" 采用信号: {bg_signal.get('timeframe', 'unknown')} | " @@ -790,6 +846,14 @@ class CryptoAgent: bg_decision = {"action": "IGNORE", "reason": "未启用"} logger.info(f"⏸️ Bitget 实盘交易未启用") + self.last_execution_preview[symbol] = { + 'timestamp': datetime.now().isoformat(), + 'current_price': current_price, + 'paper': paper_decision, + 'hyperliquid': hl_decision, + 'bitget': bg_decision, + } + # ============================================================ # 第三阶段:执行交易动作(各平台独立) # ============================================================ @@ -888,7 +952,7 @@ class CryptoAgent: for order in active_orders: if order.get('status') == 'open' and order.get('filled_price'): # 已成交的订单作为持仓 - position_list.append({ + position = { 'order_id': order.get('order_id'), 'symbol': order.get('symbol'), 'side': 'buy' if order.get('side') == 'long' else 'sell', @@ -899,7 +963,8 @@ class CryptoAgent: 'take_profit': order.get('take_profit'), 'opened_at': order.get('opened_at'), 'created_at': order.get('created_at'), - }) + } + position_list.append(self._build_runtime_position_state(position)) elif order.get('status') == 'pending': # 未成交的订单作为挂单 pending_orders.append({ @@ -940,19 +1005,24 @@ class CryptoAgent: if size != 0: entry_price = float(position_data.get("entryPx", 0)) unrealized_pnl = float(position_data.get("unrealizedPnl", 0)) + mark_price = float(position_data.get("markPx", 0) or position_data.get("mark_price", 0) or 0) # 获取止盈止损价格(从挂单中查询) tp_sl_prices = self.hyperliquid.get_tp_sl_prices(coin) - position_list.append({ + position = { 'symbol': f"{coin}USDT", # BTC → BTCUSDT 'side': 'buy' if size > 0 else 'sell', 'holding': abs(size), 'entry_price': entry_price, + 'mark_price': mark_price, 'unrealized_pnl': unrealized_pnl, 'stop_loss': tp_sl_prices.get('stop_loss'), - 'take_profit': tp_sl_prices.get('take_profit') - }) + 'take_profit': tp_sl_prices.get('take_profit'), + 'opened_at': datetime.fromtimestamp(position_data.get("timestamp", 0) / 1000).isoformat() + if position_data.get("timestamp") else None, + } + position_list.append(self._build_runtime_position_state(position)) # 转换账户格式(匹配模拟盘格式) account = { @@ -1025,6 +1095,7 @@ class CryptoAgent: symbol = self._normalize_symbol(decision.get('symbol', '')) signal_action = decision.get('signal_action', decision.get('action')) opposite_side = 'sell' if signal_action == 'buy' else 'buy' + actionable_pending_orders = self._get_actionable_pending_orders(pending_orders) same_positions = [ pos for pos in positions @@ -1035,7 +1106,7 @@ class CryptoAgent: if self._normalize_symbol(pos.get('symbol', '')) == symbol and pos.get('side') == opposite_side ] opposite_pending = [ - order for order in pending_orders + order for order in actionable_pending_orders if self._normalize_symbol(order.get('symbol', '')) == symbol and order.get('side') == opposite_side ] @@ -1118,20 +1189,47 @@ class CryptoAgent: # ============================================================ # 执行模拟盘决策 # ============================================================ - if paper_decision: + if paper_decision and not self._is_platform_halted('PaperTrading'): await self._execute_paper_decisions(paper_decision, market_signal, current_price) + elif paper_decision and self._is_platform_halted('PaperTrading'): + self._record_execution_event( + "PaperTrading", + "platform_halted_skip", + symbol=paper_decision.get("symbol", market_signal.get("symbol", "")), + decision=paper_decision, + reason="平台已停机,跳过执行", + status="warning", + ) # ============================================================ # 执行 Hyperliquid 决策 # ============================================================ - if hyperliquid_decision and self.hyperliquid: + if hyperliquid_decision and self.hyperliquid and not self._is_platform_halted('Hyperliquid'): await self._execute_hyperliquid_decisions(hyperliquid_decision, market_signal, current_price) + elif hyperliquid_decision and self.hyperliquid and self._is_platform_halted('Hyperliquid'): + self._record_execution_event( + "Hyperliquid", + "platform_halted_skip", + symbol=hyperliquid_decision.get("symbol", market_signal.get("symbol", "")), + decision=hyperliquid_decision, + reason="平台已停机,跳过执行", + status="warning", + ) # ============================================================ # 执行 Bitget 决策 # ============================================================ - if bitget_decision and self.bitget: + if bitget_decision and self.bitget and not self._is_platform_halted('Bitget'): await self._execute_bitget_decisions(bitget_decision, market_signal, current_price) + elif bitget_decision and self.bitget and self._is_platform_halted('Bitget'): + self._record_execution_event( + "Bitget", + "platform_halted_skip", + symbol=bitget_decision.get("symbol", market_signal.get("symbol", "")), + decision=bitget_decision, + reason="平台已停机,跳过执行", + status="warning", + ) async def _execute_paper_decisions(self, decision: Dict[str, Any], market_signal: Dict[str, Any], @@ -1144,6 +1242,7 @@ class CryptoAgent: if decision_type == 'HOLD': hold_reason = decision.get('reason', decision.get('reasoning', '观望')) logger.info(f"\n📊 交易决策: {hold_reason}") + self._record_execution_event("PaperTrading", "hold", decision=decision, reason=hold_reason, status="hold") # 仅记录日志,不发飞书通知(避免消息过多) return @@ -1162,6 +1261,11 @@ class CryptoAgent: if result.get('success'): order_id = result.get('order_id', 'unknown') logger.info(f" ✅ 交易成功: 订单ID {order_id}") + 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 警告 @@ -1170,6 +1274,7 @@ class CryptoAgent: else: error = result.get('error', result.get('message', '未知错误')) logger.error(f" ❌ 交易失败: {error}") + self._record_execution_event("PaperTrading", "open_failed", decision=decision, reason=error, status="error") # 仅记录日志,不发飞书通知(避免消息过多) # 执行平仓 @@ -1178,12 +1283,14 @@ class CryptoAgent: if result.get('success'): logger.info(f" ✅ 平仓成功") + 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: error = result.get('error', '平仓失败') logger.error(f" ❌ 平仓失败: {error}") + self._record_execution_event("PaperTrading", "close_failed", decision=decision, reason=error, status="error") # 执行撤单 elif decision_type == 'CANCEL_PENDING': @@ -1199,16 +1306,23 @@ class CryptoAgent: if success_count > 0: logger.info(f" ✅ 成功取消 {success_count} 个挂单") + 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: logger.warning(f" ⚠️ 没有成功取消任何挂单") + self._record_execution_event("PaperTrading", "cancel_failed", decision=decision, reason="没有成功取消任何挂单", status="warning") else: logger.warning(f" ⚠️ 模拟盘暂不支持的执行动作: {decision_type}") + self._record_execution_event("PaperTrading", "unsupported_decision", decision=decision, reason=f"暂不支持的执行动作: {decision_type}", status="warning") except Exception as e: logger.error(f" ❌ 模拟盘执行异常: {e}") + self._record_execution_event("PaperTrading", "exception", decision=decision, reason=str(e), status="error") import traceback logger.error(traceback.format_exc()) @@ -1224,7 +1338,8 @@ class CryptoAgent: def _select_signal_for_platform(self, signals: List[Dict[str, Any]], platform_name: str, - market_state: str = '中性') -> Optional[Dict[str, Any]]: + market_state: str = '中性', + trend_direction: str = 'neutral') -> Optional[Dict[str, Any]]: """根据平台偏好和市场状态选择最适合执行的信号""" if not signals: return None @@ -1245,6 +1360,16 @@ class CryptoAgent: pass # 趋势市不降权日内信号 adjusted_signals.append(s) + # 逆势信号大幅降权(安全网,主过滤在 _merge_lane_results) + if trend_direction in ('uptrend', 'downtrend'): + forbidden = 'sell' if trend_direction == 'uptrend' else 'buy' + for s in adjusted_signals: + if s.get('action') == forbidden: + original = s.get('_original_confidence', s.get('confidence', 50)) + s['confidence'] = int(s.get('confidence', 50) * 0.3) + s['_trend_penalized'] = True + s['_original_confidence'] = original + lane_priority = self.PLATFORM_SIGNAL_PRIORITY.get(platform_name, ['short_term', 'medium_term']) by_lane: Dict[str, List[Dict[str, Any]]] = {} for signal in adjusted_signals: @@ -1267,6 +1392,9 @@ class CryptoAgent: """构建传给执行规则层的标准信号格式""" signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term' position_size = signal.get('position_size') or self.SIGNAL_POSITION_SIZE_DEFAULTS.get(signal_type, 'light') + funding_rate_data = market_signal.get('funding_rate_data') if market_signal else None + range_metrics = market_signal.get('range_metrics') or {} if market_signal else {} + market_location = market_signal.get('market_location') or {} if market_signal else {} return { 'symbol': symbol, @@ -1281,9 +1409,15 @@ class CryptoAgent: 'timeframe': signal_type, 'type': signal_type, 'position_size': position_size, + 'current_price': current_price, 'market_state': market_signal.get('market_state', '中性') if market_signal else '中性', - 'regime': (market_signal.get('range_metrics') or {}).get('regime', '') if market_signal else '', - 'funding_rate_data': market_signal.get('funding_rate_data') if market_signal else None, + 'regime': range_metrics.get('regime', ''), + 'range_metrics': range_metrics, + 'market_location': market_location, + 'funding_rate_data': funding_rate_data, + 'crowding_bias': (funding_rate_data or {}).get('crowding_bias'), + 'crowding_regime': (funding_rate_data or {}).get('crowding_regime'), + 'crowding_score': (funding_rate_data or {}).get('crowding_score'), } def _get_signal_for_decision(self, market_signal: Dict[str, Any], decision: Dict[str, Any]) -> Dict[str, Any]: @@ -1306,6 +1440,184 @@ class CryptoAgent: return self._get_best_signal_from_market(market_signal) + def _get_signal_execution_rule(self, signal: Dict[str, Any]) -> Dict[str, float]: + signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term' + return self.SIGNAL_EXECUTION_RULES.get(signal_type, self.SIGNAL_EXECUTION_RULES['medium_term']) + + def _get_actionable_pending_orders(self, pending_orders: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return [order for order in pending_orders if not order.get('is_reduce_only')] + + def _parse_runtime_datetime(self, value: Any) -> Optional[datetime]: + if not value: + return None + if isinstance(value, datetime): + return value + if isinstance(value, (int, float)): + timestamp = value / 1000 if value > 10**12 else value + try: + return datetime.fromtimestamp(timestamp) + except (OverflowError, OSError, ValueError): + return None + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace('Z', '+00:00')) + except ValueError: + return None + return None + + def _build_runtime_position_state(self, + position: Dict[str, Any], + reference_price: Optional[float] = None) -> Dict[str, Any]: + enriched = dict(position) + entry_price = float(position.get('entry_price', 0) or 0) + side = position.get('side') + + current_price_raw = position.get('current_price', position.get('mark_price', reference_price)) + current_price = float(current_price_raw or 0) if current_price_raw not in (None, '') else 0.0 + if current_price > 0: + enriched['current_price'] = current_price + + pnl_pct = position.get('unrealized_pnl_pct') + if not isinstance(pnl_pct, (int, float)) and entry_price > 0 and current_price > 0: + if side == 'buy': + pnl_pct = (current_price - entry_price) / entry_price * 100 + elif side == 'sell': + pnl_pct = (entry_price - current_price) / entry_price * 100 + if isinstance(pnl_pct, (int, float)): + enriched['unrealized_pnl_pct'] = round(float(pnl_pct), 4) + + take_profit = position.get('take_profit') + remaining_tp_pct = position.get('remaining_tp_pct') + if not isinstance(remaining_tp_pct, (int, float)) and current_price > 0 and isinstance(take_profit, (int, float)): + if side == 'buy': + remaining_tp_pct = max(0.0, (float(take_profit) - current_price) / current_price * 100) + elif side == 'sell': + remaining_tp_pct = max(0.0, (current_price - float(take_profit)) / current_price * 100) + if isinstance(remaining_tp_pct, (int, float)): + enriched['remaining_tp_pct'] = round(float(remaining_tp_pct), 4) + + stop_loss = position.get('stop_loss') + stop_to_entry_pct = position.get('stop_to_entry_pct') + if not isinstance(stop_to_entry_pct, (int, float)) and entry_price > 0 and isinstance(stop_loss, (int, float)): + if side == 'buy': + stop_to_entry_pct = (float(stop_loss) - entry_price) / entry_price * 100 + elif side == 'sell': + stop_to_entry_pct = (entry_price - float(stop_loss)) / entry_price * 100 + if isinstance(stop_to_entry_pct, (int, float)): + enriched['stop_to_entry_pct'] = round(float(stop_to_entry_pct), 4) + enriched['is_protected'] = bool(float(stop_to_entry_pct) >= -0.2) + elif isinstance(position.get('is_protected'), bool): + enriched['is_protected'] = position.get('is_protected') + + opened_at = self._parse_runtime_datetime(position.get('opened_at')) + if opened_at: + now = datetime.now(opened_at.tzinfo) if opened_at.tzinfo else datetime.now() + holding_hours = max(0.0, (now - opened_at).total_seconds() / 3600) + enriched['opened_at'] = opened_at.isoformat() + enriched['holding_hours'] = round(holding_hours, 2) + + return enriched + + def _resolve_position_pnl_pct(self, position: Dict[str, Any], signal: Dict[str, Any]) -> float: + pnl_pct = position.get('unrealized_pnl_pct') + if isinstance(pnl_pct, (int, float)): + return float(pnl_pct) + + entry_price = float(position.get('entry_price', 0) or 0) + current_price = float(signal.get('current_price', signal.get('entry_price', 0)) or 0) + side = position.get('side') + + if entry_price <= 0 or current_price <= 0: + return 0.0 + + if side == 'buy': + return (current_price - entry_price) / entry_price * 100 + if side == 'sell': + return (entry_price - current_price) / entry_price * 100 + return 0.0 + + def _remaining_target_distance_pct(self, signal: Dict[str, Any], position: Dict[str, Any]) -> Optional[float]: + remaining_tp_pct = position.get('remaining_tp_pct') + if isinstance(remaining_tp_pct, (int, float)): + return float(remaining_tp_pct) + + current_price = float(signal.get('current_price', signal.get('entry_price', 0)) or 0) + take_profit = position.get('take_profit') or signal.get('take_profit') + side = position.get('side') or signal.get('action') + + if current_price <= 0 or not isinstance(take_profit, (int, float)): + return None + + if side == 'buy': + return max(0.0, (float(take_profit) - current_price) / current_price * 100) + if side == 'sell': + return max(0.0, (current_price - float(take_profit)) / current_price * 100) + return None + + def _is_position_protected(self, position: Dict[str, Any]) -> bool: + if isinstance(position.get('is_protected'), bool): + return position.get('is_protected') + + entry_price = float(position.get('entry_price', 0) or 0) + stop_loss = position.get('stop_loss') + side = position.get('side') + if entry_price <= 0 or not isinstance(stop_loss, (int, float)): + return False + if side == 'buy': + return float(stop_loss) >= entry_price * 0.998 + if side == 'sell': + return float(stop_loss) <= entry_price * 1.002 + return False + + def _should_replace_pending_order(self, signal: Dict[str, Any], order: Dict[str, Any]) -> tuple[bool, str]: + entry_type = signal.get('entry_type', 'market') + if entry_type != 'limit': + return False, "仅 limit 信号考虑替换挂单" + + signal_price = float(signal.get('entry_price', 0) or 0) + order_price = float(order.get('entry_price', 0) or 0) + current_price = float(signal.get('current_price', signal_price) or signal_price or 0) + signal_side = signal.get('action') + signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term' + market_location = signal.get('market_location') or {} + rule = self._get_signal_execution_rule(signal) + + if signal_price <= 0 or order_price <= 0 or current_price <= 0: + return False, "价格无效,不替换" + + location_tag = market_location.get('location_tag') + preferred_tag = 'near_long_zone' if signal_side == 'buy' else 'near_short_zone' + preferred_dist = market_location.get( + 'distance_to_best_long_zone_pct' if signal_side == 'buy' else 'distance_to_best_short_zone_pct' + ) + + if location_tag in {'middle_of_range', 'far_from_trade_zone'}: + return False, f"当前 market_location={location_tag},不在优先替换区" + if location_tag == 'between_trade_zones': + if signal_type != 'short_term': + return False, "当前位于交易区之间,仅短线信号允许主动替换挂单" + if not isinstance(preferred_dist, (int, float)) or preferred_dist > 1.0: + return False, f"当前距优先交易区 {preferred_dist}% 过远,不替换" + elif location_tag and location_tag != preferred_tag: + return False, f"当前 market_location={location_tag},与信号方向优先交易区不匹配" + + price_diff_pct = abs(signal_price - order_price) / order_price * 100 + if price_diff_pct < 0.5: + return False, f"新旧挂单价格差 {price_diff_pct:.2f}% < 0.5%" + + signal_is_better = ( + (signal_side == 'buy' and signal_price < order_price) or + (signal_side == 'sell' and signal_price > order_price) + ) + if not signal_is_better: + return False, "新挂单价格不优于旧挂单" + + signal_distance_to_current = abs(signal_price - current_price) / current_price * 100 + if signal_distance_to_current > rule['min_add_price_gap_pct'] * 2.5: + return False, f"新挂单距现价 {signal_distance_to_current:.1f}% 过远" + + return True, f"新挂单更优 {order_price:.2f} → {signal_price:.2f}" + async def _send_market_signal_notification(self, market_signal: Dict[str, Any], current_price: float): """发送市场信号通知(第一阶段)- 调用前已确保有有效信号""" @@ -2181,15 +2493,25 @@ class CryptoAgent: tp_sl = self.bitget.get_tp_sl_prices(coin) except Exception as e: logger.warning(f"获取 {coin} TP/SL 失败(不影响交易): {e}") - position_list.append({ + raw_position = pos.get("position", {}) if isinstance(pos.get("position"), dict) else {} + mark_price = float( + pos.get("mark_price") + or raw_position.get("markPrice") + or raw_position.get("mark_price") + or 0 + ) + position = { 'symbol': f"{coin}USDT", 'side': 'buy' if size > 0 else 'sell', 'holding': abs(size), 'entry_price': pos["entry_price"], + 'mark_price': mark_price, 'unrealized_pnl': pos["unrealized_pnl"], 'stop_loss': tp_sl.get('stop_loss'), 'take_profit': tp_sl.get('take_profit'), - }) + 'opened_at': pos.get('opened_at'), + } + position_list.append(self._build_runtime_position_state(position)) except Exception as e: logger.error(f"获取 Bitget 持仓失败: {e}") @@ -2329,6 +2651,8 @@ class CryptoAgent: symbol = signal.get('symbol') signal_side = signal.get('action') signal_price = signal.get('entry_price', 0) + entry_type = signal.get('entry_type', 'market') + rule = self._get_signal_execution_rule(signal) # 检查同向持仓 same_positions = [p for p in positions @@ -2338,10 +2662,20 @@ class CryptoAgent: pos = same_positions[0] pos_entry = pos.get('entry_price', 0) price_diff_pct = abs(signal_price - pos_entry) / pos_entry * 100 if pos_entry > 0 else 0 - pnl_pct = pos.get('unrealized_pnl_pct', 0) + pnl_pct = self._resolve_position_pnl_pct(pos, signal) + remaining_tp_pct = self._remaining_target_distance_pct(signal, pos) + better_price = (signal_side == 'buy' and signal_price < pos_entry) or \ + (signal_side == 'sell' and signal_price > pos_entry) + position_protected = self._is_position_protected(pos) - # 规则1: 价格距离 >= 2% 且持仓盈利 >= 2% → 加仓 - if price_diff_pct >= 2 and pnl_pct >= 2: + if remaining_tp_pct is not None and remaining_tp_pct < rule['min_remaining_tp_pct']: + return "HOLD", f"同向持仓距止盈仅剩{remaining_tp_pct:.1f}%,不再加仓/滚仓" + + if position_protected: + return "HOLD", "同向持仓已进入保本/保护态,不再主动加仓" + + # 规则1: 价格距离足够 + 持仓已有浮盈 + 新价格更优 → 加仓 + if better_price and price_diff_pct >= rule['min_add_price_gap_pct'] and pnl_pct >= rule['min_add_profit_pct']: return "ADD", f"加仓:价格差{price_diff_pct:.1f}%,盈利{pnl_pct:.1f}%" # 规则2: 价格距离 < 2% → 忽略 @@ -2349,9 +2683,7 @@ class CryptoAgent: return "IGNORE", f"同向持仓价格差{price_diff_pct:.1f}% < 2%,忽略" # 规则3: 持仓亏损且新价格更优 → 滚仓 - if pnl_pct < -1: - better_price = (signal_side == 'buy' and signal_price < pos_entry) or \ - (signal_side == 'sell' and signal_price > pos_entry) + if pnl_pct < rule['roll_loss_threshold_pct']: if better_price: return "ROLL", f"滚仓:持仓亏损{pnl_pct:.1f}%,新价格更优" @@ -2360,18 +2692,31 @@ class CryptoAgent: # 检查同向挂单 same_orders = [o for o in pending_orders - if o.get('symbol') == symbol and o.get('side') == signal_side] + if o.get('symbol') == symbol and o.get('side') == signal_side and not o.get('is_reduce_only')] if same_orders: - order = same_orders[0] + order = sorted( + same_orders, + key=lambda item: item.get('created_at') or '', + reverse=True + )[0] order_price = order.get('entry_price', 0) price_diff_pct = abs(signal_price - order_price) / order_price * 100 if order_price > 0 else 0 + signal_is_better = ( + (signal_side == 'buy' and signal_price < order_price) or + (signal_side == 'sell' and signal_price > order_price) + ) # 规则5: 价格距离 < 2% → 忽略 if price_diff_pct < 2: return "IGNORE", f"同向挂单价格差{price_diff_pct:.1f}% < 2%,忽略" - # 规则6: 价格距离 >= 2% 且挂单 < 3 → 可再挂一单 + # 规则6: limit 信号且新挂单更优,同时位置允许时,优先替换旧挂单 + should_replace, replace_reason = self._should_replace_pending_order(signal, order) + if should_replace: + return "REPLACE_PENDING", f"同向挂单存在,{replace_reason}" + + # 规则7: 价格距离 >= 2% 且挂单 < 3 → 可再挂一单 if len(same_orders) < 3: return "OPEN", f"同向挂单价格差{price_diff_pct:.1f}% >= 2%,可开新单" else: @@ -2393,6 +2738,7 @@ class CryptoAgent: signal_side = signal.get('action') opposite_side = 'sell' if signal_side == 'buy' else 'buy' confidence = signal.get('confidence', 0) + rule = self._get_signal_execution_rule(signal) # 检查反向持仓 opposite_positions = [p for p in positions @@ -2400,11 +2746,10 @@ class CryptoAgent: if opposite_positions: pos = opposite_positions[0] - pnl_pct = pos.get('unrealized_pnl_pct', 0) - order_id = pos.get('order_id', '') + pnl_pct = self._resolve_position_pnl_pct(pos, signal) # 规则1: 信号强度 >= 90 → 强制反转 - if confidence >= 90: + if confidence >= rule['flip_confidence'] and pnl_pct <= rule['protect_profit_pct']: return "FLIP", f"强信号({confidence}%),平反向持仓并开新仓" # 规则2: 持仓亏损 >= 1% → 平仓 @@ -2421,7 +2766,7 @@ class CryptoAgent: # 检查反向挂单 opposite_orders = [o for o in pending_orders - if o.get('symbol') == symbol and o.get('side') == opposite_side] + if o.get('symbol') == symbol and o.get('side') == opposite_side and not o.get('is_reduce_only')] if opposite_orders: # 规则5: 取消反向挂单后开仓 @@ -2516,7 +2861,7 @@ class CryptoAgent: tp = signal.get('take_profit') signal_type = signal.get('timeframe') or signal.get('type') or 'medium_term' - min_rr = 1.5 if signal_type == 'short_term' else 1.8 if signal_type == 'medium_term' else 1.2 + min_rr = 1.6 if signal_type == 'short_term' else 2.0 if signal_type == 'medium_term' else 1.2 if entry > 0 and sl and tp: try: @@ -2593,8 +2938,14 @@ class CryptoAgent: logger.info(f"\n🎯 [{platform_name}] 处理交易信号: {signal.get('action')} {signal.get('symbol')}") + # 预过滤:止盈止损单不参与开仓决策 + actionable_pending_orders = [ + order for order in pending_orders + if not order.get('is_reduce_only') + ] + # 1. 风控检查 - passed, reason = self._check_risk_control(signal, platform_name, account, positions, pending_orders) + passed, reason = self._check_risk_control(signal, platform_name, account, positions, actionable_pending_orders) if not passed: logger.info(f" ❌ 风控未通过: {reason}") return { @@ -2606,7 +2957,7 @@ class CryptoAgent: } # 2. 处理同向订单 - same_action, same_reason = self._handle_same_direction(signal, positions, pending_orders) + same_action, same_reason = self._handle_same_direction(signal, positions, actionable_pending_orders) if same_action in ["IGNORE", "HOLD", "WAIT"]: logger.info(f" {same_action}: {same_reason}") @@ -2619,7 +2970,7 @@ class CryptoAgent: } # 3. 处理反向订单 - opposite_action, opposite_reason = self._handle_opposite_direction(signal, positions, pending_orders) + opposite_action, opposite_reason = self._handle_opposite_direction(signal, positions, actionable_pending_orders) # 4. 综合决策 final_action = None @@ -2629,6 +2980,9 @@ class CryptoAgent: # 加仓 final_action = "ADD" final_reason = same_reason + elif same_action == "REPLACE_PENDING": + final_action = "REPLACE_PENDING" + final_reason = same_reason elif same_action == "ROLL": # 滚仓 final_action = "ROLL" @@ -2639,6 +2993,9 @@ class CryptoAgent: # 有反向订单需要处理 final_action = opposite_action final_reason = opposite_reason + elif opposite_action in ["WAIT", "HOLD", "IGNORE"]: + final_action = "HOLD" + final_reason = opposite_reason else: # 无反向订单 final_action = "OPEN" @@ -2671,6 +3028,42 @@ class CryptoAgent: **signal } + if final_action == "REPLACE_PENDING": + same_side_orders = [ + order for order in actionable_pending_orders + if order.get('symbol') == signal.get('symbol') and order.get('side') == signal.get('action') + ] + latest_order = sorted( + same_side_orders, + key=lambda item: item.get('created_at') or '', + reverse=True + )[0] if same_side_orders else None + + if latest_order and latest_order.get('order_id'): + logger.info(f" REPLACE_PENDING: {final_reason}") + return { + "decision": "CANCEL_PENDING", + "action": "CANCEL_PENDING", + "orders_to_cancel": [latest_order.get('order_id')], + "next_decision": { + **signal, + "decision": "OPEN", + "action": "OPEN", + "signal_action": signal.get('action'), + "reason": final_reason, + "reasoning": final_reason, + }, + "reason": final_reason, + "reasoning": final_reason, + "signal_action": signal.get('action'), + "symbol": signal.get('symbol'), + "timeframe": signal.get('timeframe'), + "type": signal.get('type'), + } + + final_action = "OPEN" + final_reason = "未找到可替换挂单,回退为正常开仓" + # 其他动作(FLIP, ROLL, CLOSE_OPPOSITE 等) logger.info(f" {final_action}: {final_reason}") return { @@ -2702,6 +3095,7 @@ class CryptoAgent: if decision_type == 'HOLD': hold_reason = decision.get('reason', decision.get('reasoning', '观望')) logger.info(f" Bitget 决策: {hold_reason}") + self._record_execution_event("Bitget", "hold", decision=decision, reason=hold_reason, status="hold") # 仅记录日志,不发飞书通知(避免消息过多) return @@ -2720,6 +3114,11 @@ class CryptoAgent: order_id = result.get('order_id', 'unknown') order_status = result.get('order_status', 'filled') logger.info(f" ✅ Bitget 交易成功: {order_id} ({order_status})") + self._record_execution_event( + "Bitget", "open_success", decision=decision, status="success", + reason=decision.get('reason', decision.get('reasoning', '')), + extra={"order_id": order_id, "order_status": order_status}, + ) # 发送通知 await self._send_signal_notification( @@ -2746,6 +3145,7 @@ class CryptoAgent: else: error = result.get('error', result.get('message', '未知错误')) logger.error(f" ❌ Bitget 交易失败: {error}") + self._record_execution_event("Bitget", "open_failed", decision=decision, reason=error, status="error") await self._notify_bitget_error(symbol, decision_type, error) # 执行平仓 @@ -2755,12 +3155,14 @@ class CryptoAgent: if result.get('success'): logger.info(f" ✅ Bitget 平仓成功") + 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: error = result.get('error', '未知错误') logger.error(f" ❌ Bitget 平仓失败: {error}") + self._record_execution_event("Bitget", "close_failed", decision=decision, reason=error, status="error") await self._notify_bitget_error(symbol, "平仓", error) # 执行撤单 @@ -2779,17 +3181,24 @@ class CryptoAgent: if success_count > 0: logger.info(f" ✅ Bitget 取消成功: {success_count} 个挂单") + self._record_execution_event( + "Bitget", "cancel_success", decision=decision, status="success", + extra={"cancelled_count": success_count}, + ) if next_decision: await self._execute_bitget_decisions(next_decision, market_signal, current_price) else: error = "没有成功取消任何挂单" logger.error(f" ❌ Bitget 取消失败: {error}") + self._record_execution_event("Bitget", "cancel_failed", decision=decision, reason=error, status="error") await self._notify_bitget_error(symbol, "取消挂单", error) else: logger.warning(f" ⚠️ Bitget 暂不支持的执行动作: {decision_type}") + self._record_execution_event("Bitget", "unsupported_decision", decision=decision, reason=f"暂不支持的执行动作: {decision_type}", status="warning") except Exception as e: logger.error(f" ❌ Bitget 执行异常: {e}") + self._record_execution_event("Bitget", "exception", decision=decision, reason=str(e), status="error") await self._notify_bitget_error(symbol, decision.get('decision', 'UNKNOWN'), str(e)) async def _execute_bitget_trade(self, decision: Dict[str, Any], @@ -3042,6 +3451,7 @@ class CryptoAgent: if decision_type == 'HOLD': hold_reason = decision.get('reason', decision.get('reasoning', '观望')) logger.info(f" Hyperliquid 决策: {hold_reason}") + self._record_execution_event("Hyperliquid", "hold", decision=decision, reason=hold_reason, status="hold") # 仅记录日志,不发飞书通知(避免消息过多) return @@ -3059,6 +3469,11 @@ class CryptoAgent: if result.get('success'): order_status = result.get('order_status', 'filled') logger.info(f" ✅ Hyperliquid 交易成功 ({order_status})") + self._record_execution_event( + "Hyperliquid", "open_success", decision=decision, status="success", + reason=decision.get('reason', decision.get('reasoning', '')), + extra={"order_status": order_status, "order_id": result.get('order_id')}, + ) await self._send_signal_notification( market_signal, decision, current_price, prefix="[Hyperliquid]", @@ -3069,6 +3484,7 @@ class CryptoAgent: else: error = result.get('error', '未知错误') logger.error(f" ❌ Hyperliquid 交易失败: {error}") + self._record_execution_event("Hyperliquid", "open_failed", decision=decision, reason=error, status="error") await self._notify_hyperliquid_error(symbol, decision_type, error) # 执行平仓 elif decision_type == 'CLOSE': @@ -3076,12 +3492,14 @@ class CryptoAgent: result = await executor.execute_close(decision, current_price) if result.get('success'): logger.info(f" ✅ Hyperliquid 平仓成功") + 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: error = result.get('error', '未知错误') logger.error(f" ❌ Hyperliquid 平仓失败: {error}") + self._record_execution_event("Hyperliquid", "close_failed", decision=decision, reason=error, status="error") await self._notify_hyperliquid_error(symbol, "平仓", error) # 执行撤单 elif decision_type == 'CANCEL_PENDING': @@ -3095,16 +3513,23 @@ class CryptoAgent: success_count += 1 if success_count > 0: logger.info(f" ✅ Hyperliquid 取消成功: {success_count} 个") + self._record_execution_event( + "Hyperliquid", "cancel_success", decision=decision, status="success", + extra={"cancelled_count": success_count}, + ) if next_decision: await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price) else: error = "没有成功取消任何挂单" logger.error(f" ❌ Hyperliquid 取消失败: {error}") + self._record_execution_event("Hyperliquid", "cancel_failed", decision=decision, reason=error, status="error") await self._notify_hyperliquid_error(symbol, "取消挂单", error) else: logger.warning(f" ⚠️ Hyperliquid 暂不支持的执行动作: {decision_type}") + self._record_execution_event("Hyperliquid", "unsupported_decision", decision=decision, reason=f"暂不支持的执行动作: {decision_type}", status="warning") except Exception as e: logger.error(f" ❌ Hyperliquid 执行异常: {e}") + self._record_execution_event("Hyperliquid", "exception", decision=decision, reason=str(e), status="error") await self._notify_hyperliquid_error(symbol, decision_type, str(e)) async def _execute_hyperliquid_trade(self, decision: Dict[str, Any], @@ -3259,6 +3684,8 @@ class CryptoAgent: async def _check_and_set_pending_tp_sl_hyperliquid(self): """检查 Hyperliquid 挂单是否已成交,若成交则补设止盈止损""" + if self._is_platform_halted('Hyperliquid'): + return if not self._hl_pending_tp_sl: return try: @@ -3288,6 +3715,8 @@ class CryptoAgent: async def _check_and_set_pending_tp_sl_bitget(self): """检查 Bitget 挂单是否已成交,若成交则补设止盈止损""" + if self._is_platform_halted('Bitget'): + return if not self._bg_pending_tp_sl: return try: @@ -3790,6 +4219,7 @@ class CryptoAgent: 'running': self.running, 'symbols': self.symbols, 'mode': 'LLM 驱动', + 'platform_halts': self.get_platform_halt_status(), 'last_signals': { symbol: { 'type': sig.get('type'), @@ -3798,7 +4228,8 @@ class CryptoAgent: 'grade': sig.get('grade') } for symbol, sig in self.last_signals.items() - } + }, + 'last_execution_preview': self.last_execution_preview, } async def _notify_expired_orders_cancelled(self, cancelled_orders: List): @@ -3821,6 +4252,9 @@ class CryptoAgent: """检查各平台的挂单超时""" try: for platform_name, executor in self.executors.items(): + if self._is_platform_halted(platform_name): + continue + # 获取平台挂单 if platform_name == 'PaperTrading': pending_orders = self.paper_trading.get_open_orders() @@ -3876,23 +4310,18 @@ class CryptoAgent: alert_threshold = self.settings.account_drawdown_alert alerts = [] - # 检查所有平台 - platforms_to_check = [] - - # 添加模拟盘 - if self.paper_trading: - platforms_to_check.append(('模拟盘', self.paper_trading)) - - # 添加 Bitget 实盘 - if self.bitget: - platforms_to_check.append(('Bitget', self.bitget)) - - # 添加 Hyperliquid 实盘 - if self.hyperliquid: - platforms_to_check.append(('Hyperliquid', self.hyperliquid)) + platforms_to_check = self._get_risk_platforms() for platform_name, platform_service in platforms_to_check: try: + if self._is_platform_halted(platform_name): + halt_info = self._platform_halts.get(platform_name, {}) + logger.warning( + f"[{platform_name}] 平台已熔断暂停,跳过账户止损检查: " + f"{halt_info.get('reason', '无原因')}" + ) + continue + # 获取账户状态 if hasattr(platform_service, 'get_account_state'): account_state = platform_service.get_account_state() @@ -3942,6 +4371,13 @@ class CryptoAgent: # 立即平掉所有持仓 await self._emergency_close_all_positions(platform_name, platform_service) + self._mark_platform_halted( + platform_name, + reason=critical_msg, + drawdown_pct=drawdown_pct, + current_balance=current_balance, + initial_balance=initial_balance, + ) return True, critical_msg @@ -4003,7 +4439,7 @@ class CryptoAgent: f"🚨 [{platform_name}] 紧急平仓完成", f"触发原因: 账户回撤超过 {self.settings.account_max_drawdown*100:.0f}%\n" f"平仓数量: {closed_count}/{len(positions)}\n\n" - f"⚠️ 交易系统已停止,请人工检查账户!" + f"⚠️ 该平台已暂停执行,请人工检查后手动恢复!" ) logger.info(f"🚨 [{platform_name}] 紧急平仓完成: {closed_count}/{len(positions)}") @@ -4047,7 +4483,7 @@ class CryptoAgent: f"🚨 [{platform_name}] 紧急平仓完成", f"触发原因: 账户回撤超过 {self.settings.account_max_drawdown*100:.0f}%\n" f"平仓数量: {closed_count}/{len(positions)}\n\n" - f"⚠️ 交易系统已停止,请人工检查账户!" + f"⚠️ 该平台已暂停执行,请人工检查后手动恢复!" ) logger.info(f"🚨 [{platform_name}] 紧急平仓完成: {closed_count}/{len(positions)}") @@ -4075,6 +4511,9 @@ class CryptoAgent: continue for platform_name, executor in self.executors.items(): + if self._is_platform_halted(platform_name): + continue + # 获取平台持仓 if platform_name == 'PaperTrading': positions = self.paper_trading.get_open_positions() @@ -4175,6 +4614,137 @@ class CryptoAgent: except Exception as e: logger.error(f"检查持仓管理失败: {e}") + # ==================== 平台熔断状态 ==================== + + def _get_risk_platforms(self) -> List[tuple[str, Any]]: + platforms_to_check = [] + if self.paper_trading: + platforms_to_check.append(('PaperTrading', self.paper_trading)) + if self.bitget: + platforms_to_check.append(('Bitget', self.bitget)) + if self.hyperliquid: + platforms_to_check.append(('Hyperliquid', self.hyperliquid)) + return platforms_to_check + + def _load_platform_halts(self): + """从文件加载平台熔断状态。""" + try: + import json + from pathlib import Path + + file_path = Path("data/platform_halts.json") + if file_path.exists(): + with open(file_path, 'r') as f: + self._platform_halts = json.load(f) + logger.info(f"📂 已加载平台熔断状态: {self._platform_halts}") + else: + self._platform_halts = {} + except Exception as e: + logger.error(f"加载平台熔断状态失败: {e}") + self._platform_halts = {} + + def _save_platform_halts(self): + """保存平台熔断状态到文件。""" + try: + import json + from pathlib import Path + + Path("data").mkdir(exist_ok=True) + file_path = Path("data/platform_halts.json") + with open(file_path, 'w') as f: + json.dump(self._platform_halts, f, indent=2, ensure_ascii=False) + logger.info(f"💾 已保存平台熔断状态: {self._platform_halts}") + except Exception as e: + logger.error(f"保存平台熔断状态失败: {e}") + + def _is_platform_halted(self, platform_name: str) -> bool: + info = self._platform_halts.get(platform_name, {}) + return bool(info.get('halted')) + + def _mark_platform_halted( + self, + platform_name: str, + *, + reason: str, + drawdown_pct: float, + current_balance: float, + initial_balance: float, + ): + self._platform_halts[platform_name] = { + 'halted': True, + 'reason': reason, + 'drawdown_pct': round(drawdown_pct, 2), + 'current_balance': round(current_balance, 2), + 'initial_balance': round(initial_balance, 2), + 'halted_at': datetime.now().isoformat(), + } + self._save_platform_halts() + logger.warning(f"🛑 [{platform_name}] 已标记为平台熔断暂停") + + def get_platform_halt_status(self) -> Dict[str, Any]: + result = {} + for platform_name in ['PaperTrading', 'Bitget', 'Hyperliquid']: + info = self._platform_halts.get(platform_name, {}) + result[platform_name] = { + 'halted': bool(info.get('halted')), + 'reason': info.get('reason', ''), + 'drawdown_pct': info.get('drawdown_pct'), + 'halted_at': info.get('halted_at'), + 'current_balance': info.get('current_balance'), + 'initial_balance': info.get('initial_balance'), + } + return result + + def resume_platform(self, platform_name: str) -> Dict[str, Any]: + valid_platforms = {'PaperTrading', 'Bitget', 'Hyperliquid'} + if platform_name not in valid_platforms: + raise ValueError(f"不支持的平台: {platform_name}") + + platform_service = { + 'PaperTrading': self.paper_trading, + 'Bitget': self.bitget, + 'Hyperliquid': self.hyperliquid, + }.get(platform_name) + + if not platform_service: + raise ValueError(f"平台未启用: {platform_name}") + + current_balance = 0.0 + if hasattr(platform_service, 'get_account_state'): + state = platform_service.get_account_state() + elif hasattr(platform_service, 'get_account_status'): + state = platform_service.get_account_status() + else: + state = {} + + current_balance = ( + state.get('current_balance') + or state.get('account_value') + or state.get('balance') + or 0.0 + ) + current_balance = float(current_balance or 0.0) + if current_balance <= 0: + raise ValueError(f"{platform_name} 当前余额无效,无法恢复") + + self._initial_balances[platform_name] = current_balance + self._save_initial_balances() + + previous = self._platform_halts.get(platform_name, {}) + self._platform_halts[platform_name] = { + 'halted': False, + 'reason': '', + 'drawdown_pct': 0.0, + 'halted_at': None, + 'current_balance': round(current_balance, 2), + 'initial_balance': round(current_balance, 2), + 'resumed_at': datetime.now().isoformat(), + 'previous_reason': previous.get('reason', ''), + } + self._save_platform_halts() + logger.info(f"✅ [{platform_name}] 已手动恢复,初始权益重置为 ${current_balance:.2f}") + return self._platform_halts[platform_name] + # ==================== 初始余额持久化 ==================== def _load_initial_balances(self): diff --git a/backend/app/crypto_agent/executor/hyperliquid_executor.py b/backend/app/crypto_agent/executor/hyperliquid_executor.py index 9275c9e..92c9900 100644 --- a/backend/app/crypto_agent/executor/hyperliquid_executor.py +++ b/backend/app/crypto_agent/executor/hyperliquid_executor.py @@ -22,6 +22,7 @@ class HyperliquidExecutor(BaseExecutor): try: symbol = decision.get('symbol', '').replace('USDT', '') action = decision.get('signal_action', decision.get('action')) # buy/sell + margin = decision.get('margin', decision.get('quantity', 0)) entry_price = decision.get('entry_price', current_price) stop_loss = decision.get('stop_loss') take_profit = decision.get('take_profit') @@ -33,15 +34,15 @@ class HyperliquidExecutor(BaseExecutor): # 获取账户状态 account_state = self.hyperliquid.get_account_state() available = account_state.get('available_balance', 0) - account_value = account_state.get('account_value', 0) - # 仓位价值 = 1x 账户价值,所需保证金 = 账户价值 / 杠杆 leverage = min(decision.get('leverage', 10), 10) - target_position_value = account_value # 1倍账户价值 - margin_needed = target_position_value / leverage if leverage > 0 else 0 + adjusted_margin = self.calculate_effective_margin(available, margin) - # 预留手续费并限制在可用余额内 - adjusted_margin = self.calculate_effective_margin(available, margin_needed) + if adjusted_margin <= 0: + return { + 'success': False, + 'error': f'保证金无效: {adjusted_margin}' + } # 计算仓位大小 position_size = self._calculate_position_size(symbol, adjusted_margin, entry_price, leverage) @@ -84,25 +85,40 @@ class HyperliquidExecutor(BaseExecutor): logger.info(f" ✅ 开仓成功: {symbol} {position_size} @ ${order_type}") - # 成交后设置止盈止损(Hyperliquid 不支持下单时设置 TP/SL) - # 必须在飞书通知之前设置,避免通知异常导致止盈止损跳过 + # 设置止盈止损 if stop_loss or take_profit: - try: - tp_sl_result = self.hyperliquid.set_tp_sl( - symbol=symbol, - is_long=is_buy, - size=position_size, - tp_price=take_profit, - sl_price=stop_loss - ) - if not tp_sl_result.get('success'): - logger.warning(f" ⚠️ 止盈止损设置失败: {tp_sl_result.get('error', tp_sl_result.get('message'))}") - result['tp_sl_warning'] = tp_sl_result.get('error', tp_sl_result.get('message')) - else: - logger.info(f" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}") - except Exception as tp_sl_err: - logger.error(f" ⚠️ 止盈止损设置异常: {tp_sl_err}") - result['tp_sl_warning'] = str(tp_sl_err) + if order_status == 'filled': + # 市价单已成交,直接设置 TP/SL + try: + tp_sl_result = self.hyperliquid.set_tp_sl( + symbol=symbol, + is_long=is_buy, + size=position_size, + tp_price=take_profit, + sl_price=stop_loss + ) + 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" ✅ 止盈止损已设置: TP={take_profit}, SL={stop_loss}") + elif tp_set or sl_set: + # 部分成功:记录缺失侧 + set_text = "TP" if tp_set else "SL" + fail_text = "TP" if not tp_set else "SL" + 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', []) + 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['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 待成交后设置" # 发送飞书通知(在止盈止损之后,通知失败不影响交易结果) await self.send_execution_notification( diff --git a/backend/app/crypto_agent/market_signal_analyzer.py b/backend/app/crypto_agent/market_signal_analyzer.py index bce63b1..e5bbf59 100644 --- a/backend/app/crypto_agent/market_signal_analyzer.py +++ b/backend/app/crypto_agent/market_signal_analyzer.py @@ -27,45 +27,49 @@ from app.services.bitget_service import bitget_service class MarketSignalAnalyzer: """市场信号分析器 - 只关注市场,输出客观信号""" - INTRADAY_ANALYSIS_TEMPERATURE = 0.15 - TREND_ANALYSIS_TEMPERATURE = 0.10 + INTRADAY_ANALYSIS_TEMPERATURE = 0.12 + TREND_ANALYSIS_TEMPERATURE = 0.08 ANALYSIS_MAX_TOKENS = 1200 LANE_MIN_CONFIDENCE = { 'short_term': 70, 'medium_term': 70, } LANE_MIN_RISK_REWARD = { - 'short_term': 1.5, - 'medium_term': 1.8, + 'short_term': 1.6, + 'medium_term': 2.0, } LANE_MIN_STOP_LOSS_PCT = { - 'short_term': 0.6, - 'medium_term': 1.0, + 'short_term': 0.7, + 'medium_term': 1.5, } LANE_MIN_TAKE_PROFIT_PCT = { - 'short_term': 1.0, - 'medium_term': 2.0, + 'short_term': 1.2, + 'medium_term': 3.0, } FIB_MIN_PIVOT_SEPARATION_BARS = 4 FIB_PIVOT_VOLUME_LOOKBACK = 20 INTRADAY_ANALYSIS_PROMPT = """你是一位专业的加密货币日内交易员,只负责生成 short_term 信号。 -你的任务是基于 5m / 15m、当日开盘、VWAP、开盘区间、关键位、Fib 回撤位和衍生品拥挤度,判断未来 30 分钟到 4 小时内是否存在可执行 setup。 +你的任务是基于 5m / 15m、当日开盘、VWAP、开盘区间、关键位、Fib 回撤位和衍生品拥挤度,判断未来 30 分钟到 4 小时内是否存在可执行的合约日内 setup。 执行原则: 1. 先判断日内 regime:trending / ranging / neutral。 2. 趋势日内只做顺势回调或突破后的回踩确认,不追涨杀跌。 3. 震荡日内只做区间边界附近的反转,不在区间中部开仓。 -4. 技术指标只做辅助,优先看结构、关键位、波动率、量能、VWAP 偏离和距离。 +4. 技术指标只做辅助,优先看结构、关键位、波动率、量能、VWAP 偏离和位置优势。 5. 优先使用“优先支撑 / 优先阻力”和“可交易多头区 / 可交易空头区”,普通支撑阻力只作补充。 6. 没有清晰止损、止盈和盈亏比就不交易。 7. 本次分析独立进行,不参考任何上一轮信号。 +8. 硬性禁止: + - 如果多周期特征已确认上升趋势(HH+HL 结构,或突破震荡区间向上),禁止输出 sell 信号。 + - 如果多周期特征已确认下降趋势(LL+LH 结构,或跌破震荡区间向下),禁止输出 buy 信号。 + - 逆势信号只允许在 trend_direction=neutral 且有明确区间边界反转结构时输出。 信号要求: 1. 只允许输出 0 或 1 个 short_term 信号。 -2. 盈亏比至少 1:1.5。 -3. 如果价格处于加速延伸,优先返回空信号。 +2. 盈亏比至少 1:1.6。 +3. 如果价格处于加速延伸、远离优先交易区、或衍生品同向拥挤,优先返回空信号。 4. 如果价格位于区间中部、离关键位太远、止损过宽或方向证据冲突,必须返回空信号。 5. 做多时,entry 应尽量靠近优先支撑或多头共振区;做空时,entry 应尽量靠近优先阻力或空头共振区。 6. 只有在 setup 足够清晰时才允许输出信号;宁可空仓,不要勉强给单。 @@ -78,8 +82,10 @@ class MarketSignalAnalyzer: - C: 70-71,只有轻仓试错级别 - 70 以下不要输出交易信号 9. 止损止盈距离下限: - - short_term 止损距离至少 0.6% - - short_term 止盈距离至少 1.0% + - short_term 止损距离至少 0.7% + - short_term 止盈距离至少 1.2% +10. reasoning 必须覆盖四点中的至少三点:结构、位置、量价/波动、衍生品拥挤度。 +11. 如果数据明确显示 `market_location=middle_of_range` 或 `far_from_trade_zone`,必须返回空信号。 输出 JSON,禁止输出解释性正文: ```json @@ -102,7 +108,7 @@ class MarketSignalAnalyzer: "entry_price": 0, "stop_loss": 0, "take_profit": 0, - "reasoning": "结构+关键位+量能+波动率" + "reasoning": "结构+位置+量价/波动+拥挤度" } ] } @@ -118,7 +124,7 @@ class MarketSignalAnalyzer: TREND_ANALYSIS_PROMPT = """你是一位专业的加密货币趋势交易员,只负责生成 medium_term 信号。 -你的任务是基于 1h / 4h / 1d、关键位、Fib 回撤/扩展位、趋势阶段、反转检测、衍生品拥挤度和新闻催化,判断未来 4 小时到 1 周内是否存在趋势 setup。 +你的任务是基于 1h / 4h / 1d、关键位、Fib 回撤/扩展位、趋势阶段、反转检测、衍生品拥挤度和新闻催化,判断未来 4 小时到 1 周内是否存在可执行的合约趋势 setup。 执行原则: 1. 4h/1d 决定大方向,1h 决定节奏与入场位置。 @@ -126,14 +132,15 @@ class MarketSignalAnalyzer: - 趋势延续:4h/1d 趋势明确,1h 回踩关键位后确认继续 - 趋势反转:4h/1d 结构和 1h 动能同时改善,且反转证据充分 3. 禁止仅凭 15m 噪音逆 4h 开仓。 -4. 趋势晚期、资金费率过热或价格过度偏离关键均线时,要显著降低开仓积极性。 +4. 趋势晚期、资金费率过热、价格过度偏离关键均线、或衍生品顺向拥挤时,要显著降低开仓积极性。 5. 没有清晰位置优势就不交易。 6. 本次分析独立进行,不参考任何上一轮信号。 7. 优先使用“优先支撑 / 优先阻力”和“可交易多头区 / 可交易空头区”,普通关键位只作补充。 +8. 趋势单的核心不是猜方向,而是等待大级别方向明确后,在有位置优势的回踩/反抽处开仓。 信号要求: 1. 只允许输出 0 或 1 个 medium_term 信号。 -2. 盈亏比至少 1:1.8。 +2. 盈亏比至少 1:2.0。 3. 如果 4h/1d 与 1h 明显冲突,优先返回空信号。 4. 反转信号必须比延续信号更严格。 5. 如果趋势处于晚期且没有回踩确认,或反转证据不足,必须返回空信号。 @@ -145,8 +152,10 @@ class MarketSignalAnalyzer: - C: 70-71,仅限早期确认不足的轻仓趋势尝试 - 70 以下不要输出交易信号 9. 止损止盈距离下限: - - medium_term 止损距离至少 1.0% - - medium_term 止盈距离至少 2.0% + - medium_term 止损距离至少 1.5% + - medium_term 止盈距离至少 3.0% +10. reasoning 必须明确:大级别方向、1h 入场节奏、位置优势、拥挤度风险。 +11. 如果价格已经远离优先交易区,或趋势方向虽对但没有回踩/反抽确认,必须返回空信号。 输出 JSON,禁止输出解释性正文: ```json @@ -169,7 +178,7 @@ class MarketSignalAnalyzer: "entry_price": 0, "stop_loss": 0, "take_profit": 0, - "reasoning": "4h方向+1h节奏+关键位+量价" + "reasoning": "4h/1d方向+1h节奏+位置优势+拥挤度" } ] } @@ -216,14 +225,16 @@ class MarketSignalAnalyzer: lane="intraday", market_context=market_context, news_context=news_context, - futures_context=futures_context + futures_context=futures_context, + futures_market_data=futures_market_data, ) trend_prompt = self._build_analysis_prompt( symbol=symbol, lane="trend", market_context=market_context, news_context=news_context, - futures_context=futures_context + futures_context=futures_context, + futures_market_data=futures_market_data, ) intraday_messages = [ @@ -298,6 +309,7 @@ class MarketSignalAnalyzer: trend_stage = self._detect_trend_stage(data) fib_context = self._build_fibonacci_context(data, current_price) key_levels = self._derive_key_levels(data, range_zone, fib_context, current_price) + market_location = self._build_market_location_summary(current_price, range_zone, key_levels) snapshot_parts = [ f"## 市场快照", @@ -316,6 +328,7 @@ class MarketSignalAnalyzer: snapshot_parts.append( f"- 开盘区间(前30分钟): 高 {opening_range['high']:.2f} / 低 {opening_range['low']:.2f}" ) + snapshot_parts.append(f"- 市场位置: {market_location['summary']}") intraday_parts = [ "## 日内特征", @@ -396,6 +409,53 @@ class MarketSignalAnalyzer: f"{'BB收口' if range_metrics['bb_squeeze'] else 'BB正常'}" ) + intraday_structured = self._build_market_context_block( + lane='intraday', + symbol=symbol, + current_price=current_price, + day_open=day_open, + session_vwap=session_vwap, + opening_range=opening_range, + intraday_alignment=intraday_alignment, + trend_alignment=trend_alignment, + feature_map={ + '5m': feature_5m, + '15m': feature_15m, + '1h': feature_1h, + '4h': feature_4h, + }, + range_zone=range_zone, + range_metrics=range_metrics, + reversal_detection=reversal_detection, + trend_stage=trend_stage, + fib_context=fib_context, + key_levels=key_levels, + market_location=market_location, + ) + trend_structured = self._build_market_context_block( + lane='trend', + symbol=symbol, + current_price=current_price, + day_open=day_open, + session_vwap=session_vwap, + opening_range=opening_range, + intraday_alignment=intraday_alignment, + trend_alignment=trend_alignment, + feature_map={ + '15m': feature_15m, + '1h': feature_1h, + '4h': feature_4h, + '1d': feature_1d, + }, + range_zone=range_zone, + range_metrics=range_metrics, + reversal_detection=reversal_detection, + trend_stage=trend_stage, + fib_context=fib_context, + key_levels=key_levels, + market_location=market_location, + ) + return { 'snapshot': "\n".join(snapshot_parts), 'intraday': "\n".join(intraday_parts), @@ -403,6 +463,9 @@ class MarketSignalAnalyzer: 'levels': "\n".join(levels_parts), 'range_warning': range_warning, 'range_metrics': range_metrics, + 'market_location': market_location, + 'intraday_structured': intraday_structured, + 'trend_structured': trend_structured, } def _get_session_open(self, df: Optional[pd.DataFrame]) -> Optional[float]: @@ -1118,6 +1181,199 @@ class MarketSignalAnalyzer: return "N/A" return ", ".join(f"{level:.2f}" for level in levels[:3]) + def _build_market_location_summary(self, + current_price: float, + range_zone: Dict[str, Any], + key_levels: Dict[str, Any]) -> Dict[str, Any]: + """量化当前价格相对区间和优先交易区的位置""" + summary = { + 'location_tag': 'unknown', + 'relative_to_range': 'unknown', + 'distance_to_best_long_zone_pct': None, + 'distance_to_best_short_zone_pct': None, + 'summary': '未知', + } + + best_long_zone = key_levels.get('best_long_zone') + best_short_zone = key_levels.get('best_short_zone') + + if best_long_zone and current_price > 0: + summary['distance_to_best_long_zone_pct'] = round( + abs(current_price - float(best_long_zone['center'])) / current_price * 100, 2 + ) + if best_short_zone and current_price > 0: + summary['distance_to_best_short_zone_pct'] = round( + abs(current_price - float(best_short_zone['center'])) / current_price * 100, 2 + ) + + if range_zone.get('is_ranging') and range_zone.get('support_level') and range_zone.get('resistance_level'): + low = float(range_zone['support_level']) + high = float(range_zone['resistance_level']) + width = high - low + if width > 0: + position = (current_price - low) / width + if position <= 0.25: + summary['relative_to_range'] = 'near_range_support' + elif position >= 0.75: + summary['relative_to_range'] = 'near_range_resistance' + else: + summary['relative_to_range'] = 'middle_of_range' + + long_dist = summary['distance_to_best_long_zone_pct'] + short_dist = summary['distance_to_best_short_zone_pct'] + + candidates = [(long_dist, 'near_long_zone'), (short_dist, 'near_short_zone')] + valid_candidates = [(dist, tag) for dist, tag in candidates if dist is not None] + if valid_candidates: + nearest_dist, nearest_tag = min(valid_candidates, key=lambda item: item[0]) + if nearest_dist <= 0.6: + summary['location_tag'] = nearest_tag + elif nearest_dist >= 2.0: + summary['location_tag'] = 'far_from_trade_zone' + else: + summary['location_tag'] = 'between_trade_zones' + + if summary['relative_to_range'] == 'middle_of_range': + summary['location_tag'] = 'middle_of_range' + + summary['summary'] = ( + f"location={summary['location_tag']} | range={summary['relative_to_range']} | " + f"dist_long={summary['distance_to_best_long_zone_pct']}% | " + f"dist_short={summary['distance_to_best_short_zone_pct']}%" + ) + return summary + + def _serialize_feature_block(self, feature: Dict[str, Any]) -> Dict[str, Any]: + """把单周期特征压成稳定字段,供 prompt 直接消费""" + if not feature.get('available'): + return {'available': False} + + def rounded(value: Optional[float], digits: int = 2) -> Optional[float]: + if value is None: + return None + return round(float(value), digits) + + return { + 'available': True, + 'structure': feature.get('structure'), + 'ema_alignment': feature.get('ema_alignment'), + 'momentum_3_pct': rounded(feature.get('momentum_3')), + 'momentum_12_pct': rounded(feature.get('momentum_12')), + 'rsi': rounded(feature.get('rsi'), 1), + 'atr_pct': rounded(feature.get('atr_pct')), + 'volume_ratio': rounded(feature.get('volume_ratio')), + 'distance_to_ema20_pct': rounded(feature.get('distance_to_ema20')), + 'distance_to_recent_high_pct': rounded(feature.get('distance_to_recent_high')), + 'distance_to_recent_low_pct': rounded(feature.get('distance_to_recent_low')), + 'is_accelerating': bool(feature.get('is_accelerating')), + 'adx': rounded(feature.get('adx'), 1), + 'trend_strength_adx': feature.get('trend_strength_adx'), + } + + def _build_market_context_block(self, + lane: str, + symbol: str, + current_price: float, + day_open: Optional[float], + session_vwap: Optional[float], + opening_range: Optional[Dict[str, float]], + intraday_alignment: str, + trend_alignment: str, + feature_map: Dict[str, Dict[str, Any]], + range_zone: Dict[str, Any], + range_metrics: Dict[str, Any], + reversal_detection: Dict[str, Any], + trend_stage: Dict[str, Any], + fib_context: Dict[str, Any], + key_levels: Dict[str, Any], + market_location: Dict[str, Any]) -> str: + """构建给 LLM 的结构化行情上下文""" + block = { + 'symbol': symbol, + 'lane': lane, + 'current_price': round(current_price, 4), + 'day_open': round(float(day_open), 4) if day_open else None, + 'session_vwap': round(float(session_vwap), 4) if session_vwap else None, + 'opening_range': ( + { + 'high': round(float(opening_range['high']), 4), + 'low': round(float(opening_range['low']), 4), + } if opening_range else None + ), + 'alignment': { + 'intraday': intraday_alignment, + 'trend': trend_alignment, + }, + 'market_location': market_location, + 'range_state': { + 'is_ranging': bool(range_zone.get('is_ranging')), + 'support_level': round(float(range_zone.get('support_level')), 4) if range_zone.get('support_level') else None, + 'resistance_level': round(float(range_zone.get('resistance_level')), 4) if range_zone.get('resistance_level') else None, + 'range_width_pct': round(float(range_zone.get('range_width_pct', 0) or 0), 2), + 'confidence': int(range_zone.get('confidence', 0) or 0), + 'regime': range_metrics.get('regime'), + 'regime_score': int(range_metrics.get('regime_score', 0) or 0), + 'efficiency': round(float(range_metrics.get('range_efficiency', 0) or 0), 2), + 'adx': round(float(range_metrics.get('adx', 0) or 0), 1), + }, + 'trend_stage': { + 'stage': trend_stage.get('stage', 'unknown'), + 'confidence': int(trend_stage.get('confidence', 0) or 0), + }, + 'reversal_detection': { + 'is_reversing': bool(reversal_detection.get('is_reversing')), + 'type': reversal_detection.get('reversal_type'), + 'confidence': int(reversal_detection.get('confidence', 0) or 0), + }, + 'timeframes': { + timeframe: self._serialize_feature_block(feature) + for timeframe, feature in feature_map.items() + }, + 'levels': { + 'support': [round(float(level), 4) for level in key_levels.get('support', [])[:3]], + 'resistance': [round(float(level), 4) for level in key_levels.get('resistance', [])[:3]], + 'priority_support': [ + { + 'price': round(float(level['price']), 4), + 'score': round(float(level['score']), 2), + 'distance_pct': round(float(level.get('distance_pct', 0) or 0), 2), + 'sources': level.get('sources', [])[:3], + } + for level in key_levels.get('support_priority', [])[:2] + ], + 'priority_resistance': [ + { + 'price': round(float(level['price']), 4), + 'score': round(float(level['score']), 2), + 'distance_pct': round(float(level.get('distance_pct', 0) or 0), 2), + 'sources': level.get('sources', [])[:3], + } + for level in key_levels.get('resistance_priority', [])[:2] + ], + 'best_long_zone': self._serialize_trade_zone(key_levels.get('best_long_zone')), + 'best_short_zone': self._serialize_trade_zone(key_levels.get('best_short_zone')), + }, + 'fib_context': { + 'intraday': fib_context.get('intraday') if lane == 'intraday' else None, + 'trend': fib_context.get('trend') if lane == 'trend' else None, + }, + } + + return "```json\n" + json.dumps(block, ensure_ascii=False, indent=2) + "\n```" + + def _serialize_trade_zone(self, zone: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if not zone: + return None + return { + 'action': zone.get('action'), + 'center': round(float(zone['center']), 4), + 'low': round(float(zone['low']), 4), + 'high': round(float(zone['high']), 4), + 'distance_pct': round(float(zone.get('distance_pct', 0) or 0), 2), + 'score': round(float(zone.get('score', 0) or 0), 2), + 'sources': zone.get('sources', [])[:3], + } + def _infer_price_structure(self, df: pd.DataFrame, lookback: int = 20) -> str: """根据分段高低点判断 HH/HL / LH/LL / 区间""" if df is None or len(df) < lookback: @@ -1227,6 +1483,7 @@ class MarketSignalAnalyzer: funding = market_data.get('funding_rate', {}) oi = market_data.get('open_interest', {}) premium = market_data.get('premium_rate') + derivatives_state = self._summarize_derivatives_state(market_data) lines = [ f"## 衍生品特征", @@ -1247,30 +1504,137 @@ class MarketSignalAnalyzer: if premium is not None: lines.append(f"- 溢价率: {premium:+.2f}%") + if derivatives_state.get('summary'): + lines.append(f"- 拥挤度结论: {derivatives_state['summary']}") + return "\n".join(lines) + def _summarize_derivatives_state(self, market_data: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """把资金费率/持仓/溢价压缩成更适合 LLM 判断的拥挤度特征""" + summary = { + 'crowding_bias': 'neutral', + 'crowding_score': 0, + 'oi_regime': 'stable', + 'premium_regime': 'neutral', + 'summary': '中性', + } + + if not market_data: + return summary + + funding = market_data.get('funding_rate') or {} + oi_change_pct = float(market_data.get('oi_change_percent_24h', 0) or 0) + premium_rate = float(market_data.get('premium_rate', 0) or 0) + funding_pct = float(funding.get('funding_rate_percent', 0) or 0) + + score = 0 + bias = 'neutral' + + if funding_pct >= 0.03: + score += 20 + bias = 'long_crowded' + elif funding_pct <= -0.03: + score += 20 + bias = 'short_crowded' + + if abs(oi_change_pct) >= 8: + score += 20 + summary['oi_regime'] = 'expanding_fast' + elif abs(oi_change_pct) >= 3: + score += 10 + summary['oi_regime'] = 'expanding' + elif abs(oi_change_pct) <= 1: + summary['oi_regime'] = 'flat' + + if premium_rate >= 0.25: + score += 10 + summary['premium_regime'] = 'rich' + if bias == 'neutral': + bias = 'long_crowded' + elif premium_rate <= -0.25: + score += 10 + summary['premium_regime'] = 'discount' + if bias == 'neutral': + bias = 'short_crowded' + + if score >= 40: + regime = 'high' + elif score >= 20: + regime = 'medium' + else: + regime = 'low' + + summary['crowding_bias'] = bias + summary['crowding_score'] = score + summary['crowding_regime'] = regime + summary['summary'] = ( + f"{bias} | score={score} | oi={summary['oi_regime']} | premium={summary['premium_regime']}" + ) + return summary + + def _build_futures_context_block(self, market_data: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + """为 LLM 构建稳定的衍生品结构化输入""" + if not market_data: + return None + + funding = market_data.get('funding_rate') or {} + oi = market_data.get('open_interest') or {} + state = self._summarize_derivatives_state(market_data) + + return { + 'funding_rate_percent': round(float(funding.get('funding_rate_percent', 0) or 0), 4), + 'funding_sentiment': funding.get('sentiment_level') or funding.get('sentiment') or 'neutral', + 'open_interest': round(float(oi.get('open_interest', 0) or 0), 2), + 'oi_change_percent_24h': round(float(market_data.get('oi_change_percent_24h', 0) or 0), 2), + 'premium_rate_percent': round(float(market_data.get('premium_rate', 0) or 0), 4), + 'mark_vs_index_basis_percent': round( + float(market_data.get('premium_rate', 0) or 0), + 4 + ), + 'crowding_bias': state.get('crowding_bias', 'neutral'), + 'crowding_regime': state.get('crowding_regime', 'low'), + 'crowding_score': state.get('crowding_score', 0), + 'oi_regime': state.get('oi_regime', 'stable'), + 'premium_regime': state.get('premium_regime', 'neutral'), + 'price_change_24h_pct': round(float(market_data.get('price_change_24h_pct', 0) or 0), 2), + 'range_position_24h': round(float(market_data.get('range_position_24h', 0.5) or 0.5), 2), + 'bid_ask_spread_pct': round(float(market_data.get('bid_ask_spread_pct', 0) or 0), 4), + 'quote_volume_24h': round(float(market_data.get('quote_volume_24h', 0) or 0), 2), + } + def _build_analysis_prompt(self, symbol: str, lane: str, market_context: Dict[str, str], news_context: str, - futures_context: str = "") -> str: + futures_context: str = "", + futures_market_data: Optional[Dict[str, Any]] = None) -> str: """构建分析提示词""" lane_text = "日内交易分析" if lane == "intraday" else "趋势交易分析" lane_scope = ( [ "只根据下面提供的日内结构化特征做判断,不要脑补未提供的数据。", - "重点阅读 5m/15m、当日开盘、VWAP、开盘区间、区间状态、关键位、Fib 回撤位和衍生品过热程度。", - "优先参考“优先支撑/优先阻力”和“可交易多头区/可交易空头区”,不要在远离关键位的位置给 entry。", + "先看 JSON 结构块,再用后面的说明性摘要做交叉验证。", + "重点判断是否存在位置优势,而不是只判断方向。", + "优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone。", ] if lane == "intraday" else [ "只根据下面提供的趋势结构化特征做判断,不要脑补未提供的数据。", - "重点阅读 1h/4h/1d、一致性、趋势阶段、反转检测、关键位、Fib 回撤/扩展位、新闻催化和衍生品拥挤度。", - "优先参考“优先支撑/优先阻力”和“可交易多头区/可交易空头区”,趋势单必须体现位置优势,不接受远离关键位追价。", + "先看 JSON 结构块,再用后面的说明性摘要做交叉验证。", + "趋势单必须同时回答四个问题:大方向是否清晰、1h 节奏是否支持、位置是否优、拥挤是否可接受。", + "优先参考 priority_support / priority_resistance / best_long_zone / best_short_zone,不接受远离关键位追价。", ] ) + structured_market_context = ( + market_context.get('intraday_structured', '') + if lane == "intraday" + else market_context.get('trend_structured', '') + ) + futures_block = self._build_futures_context_block(futures_market_data) + selected_sections = [ market_context.get('snapshot', ''), + structured_market_context, market_context.get('intraday', '') if lane == "intraday" else market_context.get('trend', ''), market_context.get('levels', ''), ] @@ -1280,6 +1644,15 @@ class MarketSignalAnalyzer: *lane_scope, ] + if futures_block: + prompt_parts.extend([ + "", + "## 衍生品结构化特征", + "```json", + json.dumps(futures_block, ensure_ascii=False, indent=2), + "```", + ]) + for section in selected_sections: if section: prompt_parts.append("") @@ -1297,6 +1670,12 @@ class MarketSignalAnalyzer: prompt_parts.append("") prompt_parts.append(market_context['range_warning']) + prompt_parts.append("") + prompt_parts.append("判断时必须优先看这些约束:") + prompt_parts.append("1. 没有位置优势,不交易。") + prompt_parts.append("2. 方向正确但拥挤过热,也可以不交易。") + prompt_parts.append("3. 远离优先交易区、处于区间中部、或已经加速延伸,优先空仓。") + prompt_parts.append("4. 输出的是可执行 setup,不是主观行情评论。") prompt_parts.append("") prompt_parts.append("输出要求:只返回 system prompt 定义的 JSON 对象。没有高质量 setup 就返回 signals: []。") @@ -1312,8 +1691,22 @@ class MarketSignalAnalyzer: 'trend': trend_result.get('raw_response', '') } + # 1. 先确定趋势方向(trend 车道优先,fallback 到 intraday) + trend_direction = trend_result.get('trend_direction') + if trend_direction in (None, 'neutral'): + trend_direction = intraday_result.get('trend_direction', 'neutral') + trend_direction = trend_direction or 'neutral' + result['trend_direction'] = trend_direction + + # 2. 标准化信号 intraday_signals = self._normalize_lane_signals(intraday_result.get('signals', []), 'short_term') trend_signals = self._normalize_lane_signals(trend_result.get('signals', []), 'medium_term') + + # 3. 过滤逆势信号(上升趋势丢弃 sell,下降趋势丢弃 buy) + intraday_signals = self._filter_counter_trend_signals(intraday_signals, trend_direction) + trend_signals = self._filter_counter_trend_signals(trend_signals, trend_direction) + + # 4. 合并取 top 2 merged_signals = sorted( intraday_signals + trend_signals, key=lambda signal: signal.get('confidence', 0), @@ -1334,11 +1727,6 @@ class MarketSignalAnalyzer: ), } - trend_direction = trend_result.get('trend_direction') - if trend_direction in (None, 'neutral'): - trend_direction = intraday_result.get('trend_direction', 'neutral') - result['trend_direction'] = trend_direction or 'neutral' - trend_strength = trend_result.get('trend_strength') if trend_strength in (None, 'weak') and result['trend_direction'] == 'neutral': trend_strength = intraday_result.get('trend_strength', 'weak') @@ -1397,6 +1785,32 @@ class MarketSignalAnalyzer: normalized.append(signal) return normalized[:1] + def _filter_counter_trend_signals(self, signals: List[Dict[str, Any]], + trend_direction: str) -> List[Dict[str, Any]]: + """ + 过滤掉与确认趋势方向矛盾的信号。 + + - uptrend → 丢弃 sell 信号 + - downtrend → 丢弃 buy 信号 + - neutral → 不过滤 + """ + if trend_direction not in ('uptrend', 'downtrend'): + return signals + + forbidden = 'sell' if trend_direction == 'uptrend' else 'buy' + kept = [] + for s in signals: + if s.get('action') == forbidden: + lane = s.get('timeframe') or s.get('type', 'unknown') + logger.info( + f" [TrendFilter] 丢弃逆势 {forbidden} 信号 " + f"({lane}, confidence={s.get('confidence')}) " + f"因为 trend_direction={trend_direction}" + ) + else: + kept.append(s) + return kept + def _infer_signal_grade(self, confidence: float, lane_type: str) -> str: """根据 lane 规则统一 grade,避免模型随意给等级""" if lane_type == 'medium_term': diff --git a/backend/app/main.py b/backend/app/main.py index 2e823c2..66cba4e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -738,6 +738,14 @@ async def status_page(): return FileResponse(page_path) return {"message": "页面不存在"} +@app.get("/console") +async def console_page(): + """系统总控台页面""" + page_path = os.path.join(frontend_path, "console.html") + if os.path.exists(page_path): + return FileResponse(page_path) + return {"message": "页面不存在"} + @app.get("/hyperliquid") async def hyperliquid_page(): """Hyperliquid 交易监控页面""" diff --git a/backend/app/services/bitget_service.py b/backend/app/services/bitget_service.py index 6189707..dad2aed 100644 --- a/backend/app/services/bitget_service.py +++ b/backend/app/services/bitget_service.py @@ -661,24 +661,75 @@ class BitgetService: premium_rate = 0 index_price = float(ticker.get('indexPrice', 0)) mark_price = float(ticker.get('markPrice', 0)) + last_price = float(ticker.get('lastPrice', 0) or 0) + bid_price = float(ticker.get('bidPrice', 0) or 0) + ask_price = float(ticker.get('askPrice', 0) or 0) + high_24h = float(ticker.get('high24h', 0) or 0) + low_24h = float(ticker.get('low24h', 0) or 0) + base_volume_24h = float(ticker.get('baseVolume', 0) or 0) + quote_volume_24h = float(ticker.get('quoteVolume', 0) or 0) + price_change_24h_pct = float( + ticker.get('price24hPcnt', ticker.get('changeUtc24h', 0)) or 0 + ) * 100 + oi_change_percent_24h = self._extract_ticker_oi_change_percent(ticker) + bid_ask_spread_pct = 0.0 + range_position_24h = 0.5 if index_price > 0: premium_rate = ((mark_price - index_price) / index_price * 100) + if bid_price > 0 and ask_price > 0: + mid_price = (bid_price + ask_price) / 2 + if mid_price > 0: + bid_ask_spread_pct = (ask_price - bid_price) / mid_price * 100 + if high_24h > low_24h and last_price > 0: + range_position_24h = (last_price - low_24h) / (high_24h - low_24h) return { 'funding_rate': funding_rate, 'open_interest': open_interest, + 'oi_change_percent_24h': oi_change_percent_24h, 'premium_rate': premium_rate, 'market_sentiment': funding_rate.get('sentiment', ''), 'sentiment_level': funding_rate.get('sentiment_level', ''), 'mark_price': mark_price, - 'index_price': index_price + 'index_price': index_price, + 'last_price': last_price, + 'bid_price': bid_price, + 'ask_price': ask_price, + 'bid_ask_spread_pct': bid_ask_spread_pct, + 'high_24h': high_24h, + 'low_24h': low_24h, + 'range_position_24h': range_position_24h, + 'price_change_24h_pct': price_change_24h_pct, + 'base_volume_24h': base_volume_24h, + 'quote_volume_24h': quote_volume_24h, } except Exception as e: logger.error(f"获取 {symbol} 合约市场数据失败: {e}") return None + def _extract_ticker_oi_change_percent(self, ticker: Dict[str, Any]) -> float: + """从 ticker 中兼容提取 OI 24h 变化百分比""" + candidates = [ + ticker.get('openInterestChg'), + ticker.get('openInterestChange'), + ticker.get('openInterestChangePercent'), + ticker.get('oiChange'), + ticker.get('oiChangePercent'), + ] + for candidate in candidates: + if candidate in (None, ''): + continue + try: + value = float(candidate) + if abs(value) <= 2: + return value * 100 + return value + except (TypeError, ValueError): + continue + return 0.0 + def format_futures_data_for_llm(self, symbol: str, market_data: Dict[str, Any]) -> str: """ diff --git a/backend/app/services/bitget_trading_api_sdk.py b/backend/app/services/bitget_trading_api_sdk.py index 749a97c..156f4cf 100644 --- a/backend/app/services/bitget_trading_api_sdk.py +++ b/backend/app/services/bitget_trading_api_sdk.py @@ -424,23 +424,24 @@ class BitgetTradingAPI: try: ccxt_symbol = self._standardize_symbol(symbol) - # 获取当前持仓 - positions = self.get_position(symbol) - if not positions: - logger.warning(f"没有找到 {symbol} 的持仓") - result["errors"].append("没有找到持仓") - return result - - # 查找有持仓的仓位 + # 获取当前持仓(重试最多 3 次,间隔 0.5s,等待仓位数据同步) position = None - for pos in positions: - if float(pos.get('contracts', 0)) != 0: - position = pos + for attempt in range(3): + positions = self.get_position(symbol) + for pos in positions: + if float(pos.get('contracts', 0)) != 0: + position = pos + break + if position: break + if attempt < 2: + import time + logger.info(f"持仓数据未同步,等待重试 ({attempt + 1}/3)...") + time.sleep(0.5) if not position: - logger.warning(f"{symbol} 持仓数量为 0") - result["errors"].append("持仓数量为 0") + logger.warning(f"没有找到 {symbol} 的持仓") + result["errors"].append("没有找到持仓") return result contracts = float(position.get('contracts', 0)) diff --git a/backend/app/services/hyperliquid_trading_service.py b/backend/app/services/hyperliquid_trading_service.py index cf2e89d..6c60ddc 100644 --- a/backend/app/services/hyperliquid_trading_service.py +++ b/backend/app/services/hyperliquid_trading_service.py @@ -313,7 +313,8 @@ class HyperliquidTradingService: # Hyperliquid API 不直接返回 reduce_only 标记 # 但我们可以根据其他信息判断 # 暂时将所有订单都标记为非 reduce_only - is_reduce_only = order.get("reduce_only", False) + # Hyperliquid API 返回 reduceOnly(驼峰),不是 reduce_only + is_reduce_only = order.get("reduceOnly", order.get("reduce_only", False)) orders.append({ "order_id": order.get("oid"), @@ -396,54 +397,86 @@ class HyperliquidTradingService: sl_price: 止损价格(可选) Returns: - 执行结果 + {"success": bool, "tp_set": bool, "sl_set": bool, "errors": [...]} + success=True 仅当所有请求的都设置成功 """ - try: - results = [] - close_is_buy = not is_long # 平多头=卖出,平空头=买入 + result = {"success": False, "tp_set": False, "sl_set": False, "errors": []} + close_is_buy = not is_long # 平多头=卖出,平空头=买入 - # 设置止盈(限价单) - if tp_price: - # 四舍五入价格到合适精度(避免 float_to_wire rounding 错误) + # 设置止盈(限价单)— 独立 try-except,失败不影响止损 + if tp_price: + try: tp_price = round(float(tp_price), 5) - tp_result = self.exchange.order( symbol, close_is_buy, size, tp_price, {"limit": {"tif": "Gtc"}}, reduce_only=True ) - results.append({"type": "take_profit", "result": tp_result}) - logger.info(f"✅ 设置止盈: {symbol} @ ${tp_price}") + # 验证响应 + if tp_result.get("status") == "ok": + statuses = tp_result.get("response", {}).get("data", {}).get("statuses", []) + error_statuses = [s for s in statuses if "error" in s] + if error_statuses: + err_msg = error_statuses[0]["error"] + logger.warning(f"设置止盈失败: {symbol} {err_msg}") + result["errors"].append(f"止盈设置失败: {err_msg}") + else: + result["tp_set"] = True + logger.info(f"✅ 设置止盈: {symbol} @ ${tp_price}") + else: + err_msg = tp_result.get("response", str(tp_result)) + logger.warning(f"设置止盈失败: {symbol} {err_msg}") + result["errors"].append(f"止盈设置失败: {err_msg}") + except Exception as e: + logger.warning(f"设置止盈失败: {symbol} {e}") + result["errors"].append(f"止盈设置失败: {e}") - # 设置止损(触发单) - if sl_price: - # 触发价格需要稍微偏离(避免滑点问题) - exec_px = sl_price * 0.999 if close_is_buy else sl_price * 1.001 - - # 四舍五入价格到合适精度(避免 float_to_wire rounding 错误) - # Hyperliquid 要求价格最多 5 位小数 + # 设置止损(触发单)— 独立 try-except,失败不影响止盈 + if sl_price: + try: + # 买单止损:exec_px 略高于 trigger(接受更高的买入价) + # 卖单止损:exec_px 略低于 trigger(接受更低的卖出价) + exec_px = sl_price * 1.001 if close_is_buy else sl_price * 0.999 sl_price = round(float(sl_price), 5) exec_px = round(float(exec_px), 5) - sl_result = self.exchange.order( symbol, close_is_buy, size, exec_px, {"trigger": {"triggerPx": sl_price, "isMarket": True, "tpsl": "sl"}}, reduce_only=True ) - results.append({"type": "stop_loss", "result": sl_result}) - logger.info(f"✅ 设置止损: {symbol} @ ${sl_price}(触发)") + # 验证响应 + if sl_result.get("status") == "ok": + statuses = sl_result.get("response", {}).get("data", {}).get("statuses", []) + error_statuses = [s for s in statuses if "error" in s] + if error_statuses: + err_msg = error_statuses[0]["error"] + logger.warning(f"设置止损失败: {symbol} {err_msg}") + result["errors"].append(f"止损设置失败: {err_msg}") + else: + result["sl_set"] = True + logger.info(f"✅ 设置止损: {symbol} @ ${sl_price}(触发)") + else: + err_msg = sl_result.get("response", str(sl_result)) + logger.warning(f"设置止损失败: {symbol} {err_msg}") + result["errors"].append(f"止损设置失败: {err_msg}") + except Exception as e: + logger.warning(f"设置止损失败: {symbol} {e}") + result["errors"].append(f"止损设置失败: {e}") - return { - "success": True, - "results": results - } + # 判断整体成功 + requested_tp = tp_price is not None + requested_sl = sl_price is not None + all_ok = (not requested_tp or result["tp_set"]) and (not requested_sl or result["sl_set"]) + result["success"] = all_ok - except Exception as e: - logger.error(f"设置止盈止损失败: {e}") - return { - "success": False, - "error": str(e) - } + if all_ok: + logger.info(f"✅ 止盈止损设置完成: {symbol} TP={tp_price} SL={sl_price}") + elif result["tp_set"] or result["sl_set"]: + logger.warning(f"⚠️ 止盈止损部分成功: {symbol} tp_set={result['tp_set']} sl_set={result['sl_set']}") + else: + logger.error(f"❌ 止盈止损设置失败: {symbol} errors={result['errors']}") + + return result def cancel_tp_sl_orders(self, symbol: str) -> Dict[str, Any]: """ diff --git a/backend/tests/test_crypto_agent_platform_halts.py b/backend/tests/test_crypto_agent_platform_halts.py new file mode 100644 index 0000000..9b6d5d4 --- /dev/null +++ b/backend/tests/test_crypto_agent_platform_halts.py @@ -0,0 +1,193 @@ +""" +CryptoAgent 平台熔断回归测试 + +覆盖重点: + - 账户级止损只暂停单个平台,不再要求全局停机 + - 手动恢复平台会重置该平台初始权益基线 +""" +import asyncio +import importlib.util +import os +import sys +import types +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +def load_crypto_agent_class(): + agent_path = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'crypto_agent.py' + + if 'app' not in sys.modules: + app_pkg = types.ModuleType('app') + app_pkg.__path__ = [str(agent_path.parents[2] / 'app')] + sys.modules['app'] = app_pkg + + for pkg_name, pkg_path in [ + ('app.crypto_agent', agent_path.parent), + ('app.services', agent_path.parents[1] / 'services'), + ('app.utils', agent_path.parents[1] / 'utils'), + ]: + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [str(pkg_path)] + sys.modules[pkg_name] = pkg + + logger_module = types.ModuleType('app.utils.logger') + logger_module.logger = MagicMock() + sys.modules['app.utils.logger'] = logger_module + + config_module = types.ModuleType('app.config') + config_module.get_settings = MagicMock() + sys.modules['app.config'] = config_module + + bitget_service_module = types.ModuleType('app.services.bitget_service') + bitget_service_module.bitget_service = MagicMock() + sys.modules['app.services.bitget_service'] = bitget_service_module + + feishu_module = types.ModuleType('app.services.feishu_service') + feishu_module.get_feishu_service = MagicMock() + feishu_module.get_feishu_paper_trading_service = MagicMock() + sys.modules['app.services.feishu_service'] = feishu_module + + telegram_module = types.ModuleType('app.services.telegram_service') + telegram_module.get_telegram_service = MagicMock() + sys.modules['app.services.telegram_service'] = telegram_module + + dingtalk_module = types.ModuleType('app.services.dingtalk_service') + dingtalk_module.get_dingtalk_service = MagicMock() + sys.modules['app.services.dingtalk_service'] = dingtalk_module + + paper_module = types.ModuleType('app.services.paper_trading_service') + paper_module.get_paper_trading_service = MagicMock() + sys.modules['app.services.paper_trading_service'] = paper_module + + signal_db_module = types.ModuleType('app.services.signal_database_service') + signal_db_module.get_signal_db_service = MagicMock() + sys.modules['app.services.signal_database_service'] = signal_db_module + + position_sizing_module = types.ModuleType('app.services.position_sizing') + position_sizing_module.DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME = {} + position_sizing_module.DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS = {} + position_sizing_module.calculate_margin_and_position_value = MagicMock() + position_sizing_module.resolve_target_margin_pct = MagicMock() + sys.modules['app.services.position_sizing'] = position_sizing_module + + market_analyzer_module = types.ModuleType('app.crypto_agent.market_signal_analyzer') + market_analyzer_module.MarketSignalAnalyzer = MagicMock() + sys.modules['app.crypto_agent.market_signal_analyzer'] = market_analyzer_module + + system_status_module = types.ModuleType('app.utils.system_status') + system_status_module.get_system_monitor = MagicMock() + system_status_module.AgentStatus = types.SimpleNamespace(RUNNING='running', STOPPED='stopped') + sys.modules['app.utils.system_status'] = system_status_module + + module_name = 'app.crypto_agent.crypto_agent_test' + spec = importlib.util.spec_from_file_location(module_name, agent_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module.CryptoAgent + + +def make_agent(): + CryptoAgent = load_crypto_agent_class() + agent = CryptoAgent.__new__(CryptoAgent) + agent.settings = types.SimpleNamespace(account_max_drawdown=0.25, account_drawdown_alert=0.15) + agent.paper_trading = None + agent.hyperliquid = None + agent.bitget = None + agent.symbols = ['BTCUSDT'] + agent.executors = {} + agent._platform_halts = {} + from collections import deque + agent._execution_events = deque(maxlen=120) + agent._initial_balances = {} + agent._save_platform_halts = MagicMock() + agent._save_initial_balances = MagicMock() + agent._send_alert_notification = AsyncMock() + agent._emergency_close_all_positions = AsyncMock() + return agent + + +def test_account_stop_loss_halts_only_triggered_platform(): + agent = make_agent() + bitget = MagicMock() + bitget.get_account_state.return_value = { + 'account_value': 700.0, + 'current_balance': 700.0, + } + agent.bitget = bitget + agent._get_risk_platforms = MagicMock(return_value=[('Bitget', bitget)]) + agent._get_initial_balance = MagicMock(return_value=1000.0) + + should_stop, reason = asyncio.run(agent._check_account_level_stop_loss()) + + assert should_stop is True + assert 'Bitget' in reason + assert agent._platform_halts['Bitget']['halted'] is True + agent._emergency_close_all_positions.assert_awaited_once() + + +def test_resume_platform_resets_initial_balance_and_clears_halt(): + agent = make_agent() + bitget = MagicMock() + bitget.get_account_state.return_value = { + 'account_value': 888.0, + 'current_balance': 888.0, + } + agent.bitget = bitget + agent._platform_halts = { + 'Bitget': { + 'halted': True, + 'reason': 'drawdown', + 'drawdown_pct': 25.1, + } + } + + result = agent.resume_platform('Bitget') + + assert result['halted'] is False + assert agent._initial_balances['Bitget'] == 888.0 + assert result['initial_balance'] == 888.0 + assert result['current_balance'] == 888.0 + + +def test_execution_events_are_recorded_and_returned_in_reverse_time_order(): + agent = make_agent() + + agent._record_execution_event('Bitget', 'open_failed', symbol='ETHUSDT', reason='余额不足', status='error') + agent._record_execution_event('Hyperliquid', 'hold', symbol='BTCUSDT', reason='已有盈利反向仓', status='hold') + + events = agent.get_recent_execution_events(limit=10) + + assert len(events) == 2 + assert events[0]['platform'] == 'Hyperliquid' + assert events[0]['event_type'] == 'hold' + assert events[1]['platform'] == 'Bitget' + assert events[1]['reason'] == '余额不足' + + +def test_get_status_contains_last_execution_preview(): + agent = make_agent() + agent.running = True + agent.symbols = ['BTCUSDT'] + agent.last_signals = { + 'BTCUSDT': {'type': 'medium_term', 'action': 'sell', 'confidence': 78, 'grade': 'B'} + } + agent.last_execution_preview = { + 'BTCUSDT': { + 'timestamp': '2026-04-22T12:00:00', + 'current_price': 65000.0, + 'paper': {'decision': 'OPEN', 'reason': '正常开仓'}, + 'hyperliquid': {'decision': 'HOLD', 'reason': '无适配信号'}, + 'bitget': {'decision': 'CANCEL_PENDING', 'reason': '替换旧挂单'}, + } + } + + status = agent.get_status() + + assert status['last_execution_preview']['BTCUSDT']['paper']['decision'] == 'OPEN' + assert status['last_execution_preview']['BTCUSDT']['bitget']['reason'] == '替换旧挂单' diff --git a/backend/tests/test_crypto_agent_signal_execution_coordination.py b/backend/tests/test_crypto_agent_signal_execution_coordination.py new file mode 100644 index 0000000..ca32309 --- /dev/null +++ b/backend/tests/test_crypto_agent_signal_execution_coordination.py @@ -0,0 +1,439 @@ +""" +CryptoAgent 信号到执行层协同回归测试 + +覆盖重点: + - reduce-only 的止盈止损挂单不应参与新开仓决策 + - 同向 limit 信号在已有旧挂单时,优先替换更优挂单 +""" +import importlib.util +import os +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock + + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +def load_crypto_agent_class(): + agent_path = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'crypto_agent.py' + + if 'app' not in sys.modules: + app_pkg = types.ModuleType('app') + app_pkg.__path__ = [str(agent_path.parents[2] / 'app')] + sys.modules['app'] = app_pkg + + for pkg_name, pkg_path in [ + ('app.crypto_agent', agent_path.parent), + ('app.services', agent_path.parents[1] / 'services'), + ('app.utils', agent_path.parents[1] / 'utils'), + ]: + if pkg_name not in sys.modules: + pkg = types.ModuleType(pkg_name) + pkg.__path__ = [str(pkg_path)] + sys.modules[pkg_name] = pkg + + logger_module = types.ModuleType('app.utils.logger') + logger_module.logger = MagicMock() + sys.modules['app.utils.logger'] = logger_module + + config_module = types.ModuleType('app.config') + config_module.get_settings = MagicMock() + sys.modules['app.config'] = config_module + + bitget_service_module = types.ModuleType('app.services.bitget_service') + bitget_service_module.bitget_service = MagicMock() + sys.modules['app.services.bitget_service'] = bitget_service_module + + feishu_module = types.ModuleType('app.services.feishu_service') + feishu_module.get_feishu_service = MagicMock() + feishu_module.get_feishu_paper_trading_service = MagicMock() + sys.modules['app.services.feishu_service'] = feishu_module + + telegram_module = types.ModuleType('app.services.telegram_service') + telegram_module.get_telegram_service = MagicMock() + sys.modules['app.services.telegram_service'] = telegram_module + + dingtalk_module = types.ModuleType('app.services.dingtalk_service') + dingtalk_module.get_dingtalk_service = MagicMock() + sys.modules['app.services.dingtalk_service'] = dingtalk_module + + paper_module = types.ModuleType('app.services.paper_trading_service') + paper_module.get_paper_trading_service = MagicMock() + sys.modules['app.services.paper_trading_service'] = paper_module + + signal_db_module = types.ModuleType('app.services.signal_database_service') + signal_db_module.get_signal_db_service = MagicMock() + sys.modules['app.services.signal_database_service'] = signal_db_module + + position_sizing_module = types.ModuleType('app.services.position_sizing') + position_sizing_module.DEFAULT_SIGNAL_POSITION_SIZE_BY_TIMEFRAME = {} + position_sizing_module.DEFAULT_TIMEFRAME_MARGIN_MULTIPLIERS = {} + position_sizing_module.calculate_margin_and_position_value = MagicMock() + position_sizing_module.resolve_target_margin_pct = MagicMock() + sys.modules['app.services.position_sizing'] = position_sizing_module + + market_analyzer_module = types.ModuleType('app.crypto_agent.market_signal_analyzer') + market_analyzer_module.MarketSignalAnalyzer = MagicMock() + sys.modules['app.crypto_agent.market_signal_analyzer'] = market_analyzer_module + + system_status_module = types.ModuleType('app.utils.system_status') + system_status_module.get_system_monitor = MagicMock() + system_status_module.AgentStatus = types.SimpleNamespace(RUNNING='running', STOPPED='stopped') + sys.modules['app.utils.system_status'] = system_status_module + + module_name = 'app.crypto_agent.crypto_agent_signal_exec_test' + spec = importlib.util.spec_from_file_location(module_name, agent_path) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module.CryptoAgent + + +def make_agent(): + CryptoAgent = load_crypto_agent_class() + agent = CryptoAgent.__new__(CryptoAgent) + agent.SIGNAL_POSITION_SIZE_DEFAULTS = {} + agent.SIGNAL_MARGIN_MULTIPLIERS = {} + agent.PLATFORM_RULES = {'Bitget': {'min_margin': {}, 'max_margin_pct': 0.25}} + agent._check_losing_streak = MagicMock(return_value={'should_cool_down': False}) + agent._calculate_position_size = MagicMock(return_value=(100.0, 'ok')) + return agent + + +def test_reduce_only_pending_orders_do_not_block_new_open_signal(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 100.0, + 'stop_loss': 98.0, + 'take_profit': 104.0, + 'confidence': 78, + 'timeframe': 'medium_term', + 'position_size': 'medium', + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + positions = [] + pending_orders = [ + { + 'order_id': 'tp-1', + 'symbol': 'BTCUSDT', + 'side': 'sell', + 'entry_price': 104.0, + 'is_reduce_only': True, + } + ] + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, pending_orders) + + assert decision['decision'] == 'OPEN' + assert decision['margin'] == 100.0 + + +def test_same_direction_better_limit_order_replaces_old_pending_order(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 95.0, + 'current_price': 100.0, + 'stop_loss': 92.0, + 'take_profit': 104.0, + 'confidence': 80, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'position_size': 'medium', + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + positions = [] + pending_orders = [ + { + 'order_id': 'old-1', + 'symbol': 'BTCUSDT', + 'side': 'buy', + 'entry_price': 98.0, + 'entry_type': 'limit', + 'is_reduce_only': False, + 'created_at': '2026-04-22T10:00:00', + } + ] + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, pending_orders) + + assert decision['decision'] == 'CANCEL_PENDING' + assert decision['orders_to_cancel'] == ['old-1'] + assert decision['next_decision']['decision'] == 'OPEN' + assert decision['next_decision']['signal_action'] == 'buy' + + +def test_opposite_position_uses_current_price_to_protect_profitable_medium_term_position(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 100.0, + 'current_price': 94.0, + 'stop_loss': 97.0, + 'take_profit': 106.0, + 'confidence': 88, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'position_size': 'medium', + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + positions = [ + { + 'symbol': 'BTCUSDT', + 'side': 'sell', + 'entry_price': 100.0, + 'take_profit': 92.0, + } + ] + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, []) + + assert decision['decision'] == 'HOLD' + assert decision['action'] == 'HOLD' + assert '反向持仓盈利' in decision['reason'] + + +def test_short_term_super_strong_signal_can_flip_when_opposite_profit_is_small(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'market', + 'entry_price': 100.0, + 'current_price': 99.5, + 'stop_loss': 99.0, + 'take_profit': 101.8, + 'confidence': 95, + 'timeframe': 'short_term', + 'type': 'short_term', + 'position_size': 'light', + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + positions = [ + { + 'symbol': 'BTCUSDT', + 'side': 'sell', + 'entry_price': 100.0, + } + ] + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, []) + + assert decision['decision'] == 'FLIP' + + +def test_protected_same_direction_position_will_not_add_even_if_signal_price_is_better(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 97.0, + 'current_price': 101.0, + 'stop_loss': 95.0, + 'take_profit': 108.0, + 'confidence': 82, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'position_size': 'medium', + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + positions = [ + { + 'symbol': 'BTCUSDT', + 'side': 'buy', + 'entry_price': 100.0, + 'stop_loss': 100.1, + 'take_profit': 108.0, + } + ] + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, positions, []) + + assert decision['decision'] == 'HOLD' + assert '保本/保护态' in decision['reason'] + + +def test_middle_of_range_signal_does_not_replace_existing_pending_order(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 95.0, + 'current_price': 100.0, + 'stop_loss': 92.0, + 'take_profit': 104.0, + 'confidence': 80, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'position_size': 'medium', + 'market_location': {'location_tag': 'middle_of_range'}, + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + pending_orders = [ + { + 'order_id': 'old-1', + 'symbol': 'BTCUSDT', + 'side': 'buy', + 'entry_price': 98.0, + 'entry_type': 'limit', + 'is_reduce_only': False, + 'created_at': '2026-04-22T10:00:00', + } + ] + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], pending_orders) + + assert decision['decision'] == 'OPEN' + + +def test_between_trade_zones_medium_term_signal_does_not_replace_pending_order(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 95.0, + 'current_price': 100.0, + 'stop_loss': 92.0, + 'take_profit': 104.0, + 'confidence': 80, + 'timeframe': 'medium_term', + 'type': 'medium_term', + 'position_size': 'medium', + 'market_location': { + 'location_tag': 'between_trade_zones', + 'distance_to_best_long_zone_pct': 0.7, + }, + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + pending_orders = [ + { + 'order_id': 'old-1', + 'symbol': 'BTCUSDT', + 'side': 'buy', + 'entry_price': 98.0, + 'entry_type': 'limit', + 'is_reduce_only': False, + 'created_at': '2026-04-22T10:00:00', + } + ] + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], pending_orders) + + assert decision['decision'] == 'OPEN' + + +def test_between_trade_zones_short_term_signal_can_replace_pending_order_when_near_zone(): + agent = make_agent() + + signal = { + 'symbol': 'BTCUSDT', + 'action': 'buy', + 'entry_type': 'limit', + 'entry_price': 97.5, + 'current_price': 100.0, + 'stop_loss': 96.5, + 'take_profit': 100.5, + 'confidence': 93, + 'timeframe': 'short_term', + 'type': 'short_term', + 'position_size': 'light', + 'market_location': { + 'location_tag': 'between_trade_zones', + 'distance_to_best_long_zone_pct': 0.6, + }, + 'funding_rate_data': {'funding_rate_percent': 0.01}, + } + account = { + 'current_total_leverage': 0, + 'max_total_leverage': 10, + 'available': 1000, + } + pending_orders = [ + { + 'order_id': 'old-1', + 'symbol': 'BTCUSDT', + 'side': 'buy', + 'entry_price': 99.5, + 'entry_type': 'limit', + 'is_reduce_only': False, + 'created_at': '2026-04-22T10:00:00', + } + ] + + decision = agent.execute_signal_with_rules(signal, 'Bitget', account, [], pending_orders) + + assert decision['decision'] == 'CANCEL_PENDING' + assert decision['orders_to_cancel'] == ['old-1'] + + +def test_runtime_position_state_derives_protection_and_remaining_target(): + agent = make_agent() + + position = agent._build_runtime_position_state({ + 'symbol': 'BTCUSDT', + 'side': 'buy', + 'entry_price': 100.0, + 'mark_price': 105.0, + 'stop_loss': 100.2, + 'take_profit': 112.0, + 'opened_at': '2026-04-22T08:00:00', + }) + + assert position['unrealized_pnl_pct'] == 5.0 + assert round(position['remaining_tp_pct'], 4) == round((112.0 - 105.0) / 105.0 * 100, 4) + assert position['is_protected'] is True + assert position['holding_hours'] >= 0 diff --git a/backend/tests/test_execution_safety_fixes.py b/backend/tests/test_execution_safety_fixes.py index 0ce303e..2f9b4d5 100644 --- a/backend/tests/test_execution_safety_fixes.py +++ b/backend/tests/test_execution_safety_fixes.py @@ -77,6 +77,41 @@ def load_bitget_executor_class(): return sys.modules['app.crypto_agent.executor.bitget_executor'].BitgetExecutor +def load_hyperliquid_executor_class(): + """按文件加载执行器,避免触发 app.crypto_agent.__init__ 的重依赖""" + executor_dir = Path(__file__).resolve().parents[1] / 'app' / 'crypto_agent' / 'executor' + + if 'app.crypto_agent' not in sys.modules: + crypto_pkg = types.ModuleType('app.crypto_agent') + crypto_pkg.__path__ = [str(executor_dir.parent)] + sys.modules['app.crypto_agent'] = crypto_pkg + + if 'app.crypto_agent.executor' not in sys.modules: + executor_pkg = types.ModuleType('app.crypto_agent.executor') + executor_pkg.__path__ = [str(executor_dir)] + sys.modules['app.crypto_agent.executor'] = executor_pkg + + if 'app.crypto_agent.executor.base_executor' not in sys.modules: + base_spec = importlib.util.spec_from_file_location( + 'app.crypto_agent.executor.base_executor', + executor_dir / 'base_executor.py', + ) + base_module = importlib.util.module_from_spec(base_spec) + sys.modules[base_spec.name] = base_module + base_spec.loader.exec_module(base_module) + + if 'app.crypto_agent.executor.hyperliquid_executor' not in sys.modules: + executor_spec = importlib.util.spec_from_file_location( + 'app.crypto_agent.executor.hyperliquid_executor', + executor_dir / 'hyperliquid_executor.py', + ) + executor_module = importlib.util.module_from_spec(executor_spec) + sys.modules[executor_spec.name] = executor_module + executor_spec.loader.exec_module(executor_module) + + return sys.modules['app.crypto_agent.executor.hyperliquid_executor'].HyperliquidExecutor + + def test_bitget_market_close_position_only_closes_requested_symbol(): service, mock_api = make_bitget_service() mock_api.get_position.return_value = [ @@ -193,3 +228,48 @@ def test_bitget_executor_open_uses_actual_leverage_for_contracts(): assert result['success'] is True executor.bitget.update_leverage.assert_called_once_with('ETH', 10) executor.bitget.place_market_order.assert_called_once_with('ETH', is_buy=True, size=1) + + +def test_hyperliquid_executor_open_uses_decision_margin_not_account_value(): + HyperliquidExecutor = load_hyperliquid_executor_class() + + executor = HyperliquidExecutor.__new__(HyperliquidExecutor) + executor.hyperliquid = MagicMock() + executor.send_execution_notification = AsyncMock() + executor.decide_order_type = MagicMock(return_value=('market', 'test')) + executor.calculate_effective_margin = MagicMock(return_value=120.0) + executor.hyperliquid.get_account_state.return_value = { + 'available_balance': 5000.0, + 'account_value': 50000.0, + } + executor.hyperliquid.get_sz_decimals.return_value = 3 + executor.hyperliquid.place_market_order.return_value = { + 'success': True, + 'order_id': 'oid-hl-1', + 'order_status': 'filled', + } + executor.hyperliquid.set_tp_sl.return_value = {'tp_set': True, 'sl_set': True} + + result = asyncio.run( + executor.execute_open( + { + 'symbol': 'ETHUSDT', + 'action': 'buy', + 'margin': 120.0, + 'entry_price': 2000.0, + 'stop_loss': 1980.0, + 'take_profit': 2060.0, + 'leverage': 10, + }, + 2000.0, + ) + ) + + assert result['success'] is True + executor.hyperliquid.update_leverage.assert_called_once_with('ETH', 10) + executor.hyperliquid.place_market_order.assert_called_once_with( + symbol='ETH', + is_buy=True, + size=0.6, + reduce_only=False, + ) diff --git a/backend/tests/test_hyperliquid_live_integration.py b/backend/tests/test_hyperliquid_live_integration.py new file mode 100644 index 0000000..5df6074 --- /dev/null +++ b/backend/tests/test_hyperliquid_live_integration.py @@ -0,0 +1,369 @@ +""" +Hyperliquid 真实 API 集成测试 + +⚠️ 警告:此测试会使用真实 API 调用和真实订单! +- 使用最小下单量(ETH,szDecimals=3) +- 市价单会立即成交,产生实际盈亏 +- 测试后自动清理所有订单和持仓 + +覆盖接口: + - 账户状态查询 + - 杠杆设置 + - 持仓查询 + - 市价开仓 + - 止盈止损设置(TP limit 单 + SL trigger 单) + - 止盈止损验证(读取挂单确认 TP 和 SL 都存在) + - 市价平仓 + +运行方式: + cd backend + python3 tests/test_hyperliquid_live_integration.py +""" +import os +import sys +import time +import traceback +from datetime import datetime + +# 添加项目路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv(os.path.join(os.path.dirname(__file__), '..', '..', '.env')) + + +# ==================== 测试配置 ==================== + +TEST_SYMBOL = 'ETH' # ETH 精度好(szDecimals=3),手续费低 +TEST_SIZE = 0.01 # 最小下单量 +TEST_LEVERAGE = 5 # 测试杠杆倍数 + + +class TestResult: + """测试结果收集器""" + + def __init__(self): + self.results = [] + + def record(self, name: str, passed: bool, detail: str = ""): + self.results.append((name, passed, detail)) + status = "✅ PASS" if passed else "❌ FAIL" + print(f" {status}: {name}") + if detail: + print(f" {detail}") + + def summary(self): + print(f"\n{'='*60}") + print("测试结果汇总") + print(f"{'='*60}") + passed = sum(1 for _, p, _ in self.results if p) + total = len(self.results) + for name, p, detail in self.results: + status = "✅" if p else "❌" + line = f" {status} {name}" + if not p and detail: + line += f" — {detail}" + print(line) + print(f"\n 总计: {passed}/{total} 通过") + print(f"{'='*60}") + return passed == total + + +# ==================== 测试函数 ==================== + +def test_account_state(service, r: TestResult): + """查询账户状态""" + try: + state = service.get_account_state() + av = state['account_value'] + ab = state['available_balance'] + r.record( + "查询账户状态", + av > 0, + f"权益=${av:,.2f}, 可用=${ab:,.2f}" + ) + except Exception as e: + r.record("查询账户状态", False, str(e)) + + +def test_update_leverage(service, r: TestResult): + """设置杠杆""" + try: + result = service.update_leverage(TEST_SYMBOL, TEST_LEVERAGE) + r.record("设置杠杆", True, f"{TEST_SYMBOL} → {TEST_LEVERAGE}x") + except Exception as e: + r.record("设置杠杆", False, str(e)) + + +def test_get_positions(service, r: TestResult): + """查询持仓""" + try: + positions = service.get_open_positions() + count = len(positions) + r.record("查询持仓", True, f"当前活跃持仓: {count} 个") + except Exception as e: + r.record("查询持仓", False, str(e)) + + +def test_market_order_with_tp_sl(service, r: TestResult): + """市价开仓 → 设置止盈止损 → 验证 → 平仓""" + opened = False + try: + # 0. 先清理已有持仓和挂单 + try: + service.cancel_all_orders(TEST_SYMBOL) + except: + pass + try: + pos = service.get_position_for_symbol(TEST_SYMBOL) + if pos: + service.market_close_position(TEST_SYMBOL) + time.sleep(1) + except: + pass + + # 1. 获取当前价格 + all_mids = service.info.all_mids() + current_price = float(all_mids.get(TEST_SYMBOL, 0)) + if current_price <= 0: + r.record("获取当前价格", False, f"无法获取 {TEST_SYMBOL} 价格") + return + + print(f"\n 当前 {TEST_SYMBOL}: ${current_price:,.2f}") + + # 2. 计算最小下单量 + sz_decimals = service.get_sz_decimals(TEST_SYMBOL) + import math + size = max(math.floor(TEST_SIZE * (10 ** sz_decimals)) / (10 ** sz_decimals), 1 / (10 ** sz_decimals)) + print(f" 下单量: {size} ({sz_decimals} 位精度)") + + # 3. 设置杠杆 + service.update_leverage(TEST_SYMBOL, TEST_LEVERAGE) + + # 4. 市价开多 + result = service.place_market_order( + symbol=TEST_SYMBOL, + is_buy=True, + size=size, + reduce_only=False + ) + if not result.get('success'): + r.record("市价开仓", False, result.get('error', '未知错误')) + return + + r.record("市价开仓", True, f"{TEST_SYMBOL} buy {size}") + opened = True + + time.sleep(2) + + # 5. 验证持仓 + position = service.get_position_for_symbol(TEST_SYMBOL) + r.record("验证持仓存在", position is not None, + f"size={position['size']}, entry=${position['entry_price']:,.2f}" if position else "未找到持仓") + + # 6. 设置止盈止损 + tp_price = round(current_price * 1.02, 2) # +2% + sl_price = round(current_price * 0.98, 2) # -2% + print(f" 设置 TP=${tp_price:,.2f}, SL=${sl_price:,.2f}") + + tp_sl_result = service.set_tp_sl( + symbol=TEST_SYMBOL, + is_long=True, + size=size, + tp_price=tp_price, + sl_price=sl_price + ) + + tp_set = tp_sl_result.get('tp_set', False) + sl_set = tp_sl_result.get('sl_set', False) + errors = tp_sl_result.get('errors', []) + + detail = f"TP=${tp_price:,.2f}({'✅' if tp_set else '❌'}), SL=${sl_price:,.2f}({'✅' if sl_set else '❌'})" + if errors: + detail += f" errors={errors}" + r.record("设置止盈止损", tp_set and sl_set, detail) + + # 7. 验证止盈止损挂单 + time.sleep(1) + tp_sl_prices = service.get_tp_sl_prices(TEST_SYMBOL) + has_tp = tp_sl_prices.get('take_profit') is not None + has_sl = tp_sl_prices.get('stop_loss') is not None + r.record("验证 TP/SL 挂单", has_tp and has_sl, + f"TP={tp_sl_prices.get('take_profit')}({'✅' if has_tp else '❌'}), " + f"SL={tp_sl_prices.get('stop_loss')}({'✅' if has_sl else '❌'})") + + # 8. 取消止盈止损 + try: + service.cancel_tp_sl_orders(TEST_SYMBOL) + except: + pass + time.sleep(1) + + # 9. 市价平仓 + close_result = service.market_close_position(TEST_SYMBOL) + r.record("市价平仓", close_result.get('success', False), + close_result.get('error', f"成功")) + if close_result.get('success'): + opened = False + + time.sleep(2) + + # 10. 验证已平仓 + position_after = service.get_position_for_symbol(TEST_SYMBOL) + r.record("验证已平仓", position_after is None) + + except Exception as e: + r.record("市价单流程异常", False, f"{e}\n{traceback.format_exc()}") + finally: + if opened: + try: + service.cancel_all_orders(TEST_SYMBOL) + time.sleep(0.5) + service.market_close_position(TEST_SYMBOL) + print(" 🧹 已自动清理残留持仓") + except Exception as cleanup_err: + print(f" ⚠️ 清理失败,请手动检查: {cleanup_err}") + + +def test_set_tp_sl_partial_failure(service, r: TestResult): + """测试 set_tp_sl: 第一个失败不影响第二个""" + # 这个测试验证我们的修复:独立的 try-except + # 如果 TP 失败(例如价格为 0),SL 应该仍然被设置 + opened = False + try: + # 先清理 + try: + service.cancel_all_orders(TEST_SYMBOL) + except: + pass + pos = service.get_position_for_symbol(TEST_SYMBOL) + if pos: + service.market_close_position(TEST_SYMBOL) + time.sleep(1) + + # 1. 市价开仓 + sz_decimals = service.get_sz_decimals(TEST_SYMBOL) + import math + size = max(math.floor(TEST_SIZE * (10 ** sz_decimals)) / (10 ** sz_decimals), 1 / (10 ** sz_decimals)) + service.update_leverage(TEST_SYMBOL, TEST_LEVERAGE) + + result = service.place_market_order( + symbol=TEST_SYMBOL, + is_buy=True, + size=size, + reduce_only=False + ) + if not result.get('success'): + r.record("部分失败测试: 开仓", False, result.get('error', '未知')) + return + opened = True + time.sleep(2) + + # 2. 设置一个有效的 SL(但故意不设置 TP → tp_price=None) + all_mids = service.info.all_mids() + current_price = float(all_mids.get(TEST_SYMBOL, 0)) + sl_price = round(current_price * 0.98, 2) + + tp_sl_result = service.set_tp_sl( + symbol=TEST_SYMBOL, + is_long=True, + size=size, + tp_price=None, # 不设 TP + sl_price=sl_price # 只设 SL + ) + sl_set = tp_sl_result.get('sl_set', False) + r.record("部分设置测试 (仅 SL)", sl_set, f"sl_set={sl_set}, errors={tp_sl_result.get('errors', [])}") + + # 3. 验证 SL 挂单存在 + time.sleep(1) + tp_sl_prices = service.get_tp_sl_prices(TEST_SYMBOL) + has_sl = tp_sl_prices.get('stop_loss') is not None + r.record("验证 SL 挂单", has_sl, f"SL={tp_sl_prices.get('stop_loss')}") + + # 4. 清理 + service.cancel_all_orders(TEST_SYMBOL) + time.sleep(1) + service.market_close_position(TEST_SYMBOL) + opened = False + + except Exception as e: + r.record("部分失败测试异常", False, f"{e}\n{traceback.format_exc()}") + finally: + if opened: + try: + service.cancel_all_orders(TEST_SYMBOL) + time.sleep(0.5) + service.market_close_position(TEST_SYMBOL) + print(" 🧹 已自动清理残留持仓") + except: + pass + + +# ==================== 主入口 ==================== + +def main(): + print(f"\n{'='*60}") + print(f" Hyperliquid 实盘接口集成测试") + print(f"{'='*60}") + print(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f" 交易对: {TEST_SYMBOL}") + print(f" 下单量: {TEST_SIZE}") + print(f" 杠杆: {TEST_LEVERAGE}x") + print(f"{'='*60}") + + r = TestResult() + + # 初始化 + try: + from app.services.hyperliquid_trading_service import HyperliquidTradingService + service = HyperliquidTradingService() + print(f" 钱包: {service.wallet_address[:10]}...") + except Exception as e: + print(f"\n❌ 初始化失败: {e}") + traceback.print_exc() + sys.exit(1) + + # ---- 基础测试 ---- + print(f"\n{'─'*40}") + print(" 基础接口") + print(f"{'─'*40}") + + test_account_state(service, r) + time.sleep(0.3) + + test_update_leverage(service, r) + time.sleep(0.3) + + test_get_positions(service, r) + time.sleep(0.3) + + # ---- 核心测试: 开仓 → TP/SL → 平仓 ---- + print(f"\n{'─'*40}") + print(" 开仓 → 止盈止损 → 验证 → 平仓") + print(f"{'─'*40}") + + test_market_order_with_tp_sl(service, r) + time.sleep(1) + + # ---- 边界测试: 部分设置 ---- + print(f"\n{'─'*40}") + print(" 部分设置测试") + print(f"{'─'*40}") + + test_set_tp_sl_partial_failure(service, r) + + # ---- 汇总 ---- + all_passed = r.summary() + sys.exit(0 if all_passed else 1) + + +if __name__ == '__main__': + print("\n⚠️ 此测试会产生真实订单和手续费!") + print(f" 使用 {TEST_SYMBOL} 最小量 {TEST_SIZE}") + + confirm = input("\n是否继续?(yes/no): ") + if confirm.strip().lower() != 'yes': + print("已取消") + sys.exit(0) + + main() diff --git a/backend/tests/test_market_signal_analyzer_lane_rules.py b/backend/tests/test_market_signal_analyzer_lane_rules.py index b2c605d..59ba32d 100644 --- a/backend/tests/test_market_signal_analyzer_lane_rules.py +++ b/backend/tests/test_market_signal_analyzer_lane_rules.py @@ -132,8 +132,8 @@ def test_lane_specific_risk_reward_and_distance_thresholds(): signal = { "action": "sell", "entry_price": 100.0, - "stop_loss": 101.0, - "take_profit": 98.0, + "stop_loss": 101.6, + "take_profit": 96.8, } assert analyzer._meets_min_risk_reward(signal, "short_term") is True @@ -144,8 +144,8 @@ def test_lane_specific_risk_reward_and_distance_thresholds(): tighter_signal = { "action": "sell", "entry_price": 100.0, - "stop_loss": 101.0, - "take_profit": 98.4, + "stop_loss": 100.8, + "take_profit": 98.7, } assert analyzer._meets_min_risk_reward(tighter_signal, "short_term") is True @@ -153,6 +153,16 @@ def test_lane_specific_risk_reward_and_distance_thresholds(): assert analyzer._meets_min_price_distance(tighter_signal, "short_term") is True assert analyzer._meets_min_price_distance(tighter_signal, "medium_term") is False + too_tight_intraday = { + "action": "sell", + "entry_price": 100.0, + "stop_loss": 100.6, + "take_profit": 98.8, + } + + assert analyzer._meets_min_price_distance(too_tight_intraday, "short_term") is False + assert analyzer._meets_min_risk_reward(too_tight_intraday, "short_term") is True + def test_fibonacci_context_marks_kind_and_trade_zone(): analyzer = make_analyzer() @@ -175,3 +185,56 @@ def test_fibonacci_context_marks_kind_and_trade_zone(): formatted = analyzer._format_fib_levels(result["support_details"] or result["resistance_details"]) assert "回撤Fib" in formatted or "扩展Fib" in formatted + + +def test_market_location_summary_marks_middle_of_range_and_far_from_trade_zone(): + analyzer = make_analyzer() + + location = analyzer._build_market_location_summary( + current_price=100.0, + range_zone={"is_ranging": True, "support_level": 90.0, "resistance_level": 110.0}, + key_levels={ + "best_long_zone": {"center": 92.0}, + "best_short_zone": {"center": 108.0}, + }, + ) + + assert location["relative_to_range"] == "middle_of_range" + assert location["location_tag"] == "middle_of_range" + + far_location = analyzer._build_market_location_summary( + current_price=100.0, + range_zone={"is_ranging": False}, + key_levels={ + "best_long_zone": {"center": 95.0}, + "best_short_zone": {"center": 105.0}, + }, + ) + + assert far_location["location_tag"] == "far_from_trade_zone" + + +def test_build_analysis_prompt_includes_structured_market_and_derivatives_blocks(): + analyzer = make_analyzer() + prompt = analyzer._build_analysis_prompt( + symbol="BTCUSDT", + lane="intraday", + market_context={ + "snapshot": "## 市场快照\n- 当前价格: 100", + "intraday_structured": "```json\n{\"lane\":\"intraday\"}\n```", + "intraday": "## 日内特征\n- 5m: ...", + "levels": "## 关键位\n- 支撑位: 99", + }, + news_context="无最新新闻", + futures_context="## 衍生品特征\n- 资金费率: +0.01%", + futures_market_data={ + "funding_rate": {"funding_rate_percent": 0.01, "sentiment_level": "neutral"}, + "open_interest": {"open_interest": 12345}, + "oi_change_percent_24h": 9.2, + "premium_rate": 0.31, + }, + ) + + assert "## 衍生品结构化特征" in prompt + assert "\"lane\":\"intraday\"" in prompt + assert "\"crowding_regime\": \"medium\"" in prompt or "\"crowding_regime\": \"high\"" in prompt diff --git a/frontend/console.html b/frontend/console.html new file mode 100644 index 0000000..05c1eca --- /dev/null +++ b/frontend/console.html @@ -0,0 +1,1393 @@ + + +
+ + ++ 统一观察信号流、执行层、三端账户风险和平台熔断状态。 + 这个页面的目标不是展示“历史”,而是让你在一屏内判断系统现在是不是健康、哪里堵住了、哪里需要人工接管。 +
+