This commit is contained in:
aaron 2026-03-29 22:58:06 +08:00
parent ef4b7790cf
commit 44fc91dfc2
7 changed files with 539 additions and 122 deletions

View File

@ -732,6 +732,9 @@ class CryptoAgent:
paper_decision = self.execute_signal_with_rules(
trading_signal, 'PaperTrading', paper_account, paper_positions, paper_pending
)
paper_decision = self._normalize_execution_decision(
paper_decision, paper_positions, paper_pending
)
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
# await self._send_trading_decision_notification(
# paper_decision, market_signal, current_price, prefix="[模拟盘]"
@ -747,6 +750,9 @@ class CryptoAgent:
hl_decision = self.execute_signal_with_rules(
trading_signal, 'Hyperliquid', hl_account, hl_positions, hl_pending
)
hl_decision = self._normalize_execution_decision(
hl_decision, hl_positions, hl_pending
)
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
# await self._send_trading_decision_notification(
# hl_decision, market_signal, current_price, prefix="[Hyperliquid]"
@ -762,6 +768,9 @@ class CryptoAgent:
bg_decision = self.execute_signal_with_rules(
trading_signal, 'Bitget', bg_account, bg_positions, bg_pending
)
bg_decision = self._normalize_execution_decision(
bg_decision, bg_positions, bg_pending
)
# 不发送决策通知(因为是基于硬编码规则的执行,不是 LLM 决策)
# await self._send_trading_decision_notification(
# bg_decision, market_signal, current_price, prefix="[Bitget]"
@ -904,23 +913,28 @@ class CryptoAgent:
if order.get('status') == 'open' and order.get('filled_price'):
# 已成交的订单作为持仓
position_list.append({
'order_id': order.get('order_id'),
'symbol': order.get('symbol'),
'side': order.get('side'),
'side': 'buy' if order.get('side') == 'long' else 'sell',
'holding': order.get('quantity', 0),
'entry_price': order.get('filled_price') or order.get('entry_price'),
'unrealized_pnl_pct': order.get('pnl_percent', 0),
'stop_loss': order.get('stop_loss'),
'take_profit': order.get('take_profit')
'take_profit': order.get('take_profit'),
'opened_at': order.get('opened_at'),
'created_at': order.get('created_at'),
})
elif order.get('status') == 'pending':
# 未成交的订单作为挂单
pending_orders.append({
'order_id': order.get('order_id'),
'symbol': order.get('symbol'),
'side': order.get('side'),
'side': 'buy' if order.get('side') == 'long' else 'sell',
'entry_price': order.get('entry_price'),
'quantity': order.get('quantity', 0),
'entry_type': order.get('entry_type', 'market'),
'confidence': order.get('confidence', 0)
'confidence': order.get('confidence', 0),
'created_at': order.get('created_at'),
})
return position_list, account, pending_orders
@ -986,11 +1000,12 @@ class CryptoAgent:
pending_orders.append({
'order_id': order.get('order_id'),
'symbol': f"{order['symbol']}USDT", # 转换格式
'side': 'buy' if order.get('side') == 'B' else 'sell',
'side': order.get('side'),
'entry_price': order.get('price'),
'quantity': order.get('size'),
'entry_type': 'limit',
'is_reduce_only': order.get('is_reduce_only', False)
'is_reduce_only': order.get('is_reduce_only', False),
'created_at': order.get('created_at'),
})
return position_list, account, pending_orders
@ -999,6 +1014,105 @@ class CryptoAgent:
logger.error(f"获取 Hyperliquid 状态失败: {e}")
return [], {}, []
def _normalize_symbol(self, symbol: str) -> str:
"""统一交易对格式为 BTCUSDT"""
if not symbol:
return symbol
return symbol if symbol.endswith('USDT') else f"{symbol}USDT"
def _build_follow_up_open_decision(self, decision: Dict[str, Any]) -> Dict[str, Any]:
"""为复合动作构建二段式开仓决策"""
follow_up = dict(decision)
follow_up.pop('next_decision', None)
follow_up['decision'] = 'OPEN'
follow_up['action'] = decision.get('signal_action', decision.get('action'))
follow_up['symbol'] = self._normalize_symbol(decision.get('symbol', ''))
return follow_up
def _normalize_execution_decision(self,
decision: Dict[str, Any],
positions: List[Dict[str, Any]],
pending_orders: List[Dict[str, Any]]) -> Dict[str, Any]:
"""将复合动作归一化为执行器可落地的动作"""
if not decision:
return decision
decision_type = decision.get('decision', 'HOLD')
if decision_type in {'OPEN', 'ADD', 'CLOSE', 'CANCEL_PENDING', 'HOLD'}:
return decision
symbol = self._normalize_symbol(decision.get('symbol', ''))
signal_action = decision.get('signal_action', decision.get('action'))
opposite_side = 'sell' if signal_action == 'buy' else 'buy'
same_positions = [
pos for pos in positions
if self._normalize_symbol(pos.get('symbol', '')) == symbol and pos.get('side') == signal_action
]
opposite_positions = [
pos for pos in positions
if self._normalize_symbol(pos.get('symbol', '')) == symbol and pos.get('side') == opposite_side
]
opposite_pending = [
order for order in pending_orders
if self._normalize_symbol(order.get('symbol', '')) == symbol and order.get('side') == opposite_side
]
def build_close(target_positions: List[Dict[str, Any]], reason: str) -> Dict[str, Any]:
close_decision = dict(decision)
close_decision['decision'] = 'CLOSE'
close_decision['action'] = 'CLOSE'
close_decision['symbol'] = symbol
close_decision['reason'] = reason
close_decision['reasoning'] = reason
order_ids = [pos.get('order_id') for pos in target_positions if pos.get('order_id')]
if order_ids:
close_decision['orders_to_close'] = order_ids
return close_decision
def build_cancel(target_orders: List[Dict[str, Any]], reason: str) -> Dict[str, Any]:
cancel_decision = dict(decision)
cancel_decision['decision'] = 'CANCEL_PENDING'
cancel_decision['action'] = 'CANCEL_PENDING'
cancel_decision['symbol'] = symbol
cancel_decision['reason'] = reason
cancel_decision['reasoning'] = reason
cancel_decision['orders_to_cancel'] = [
order.get('order_id') for order in target_orders if order.get('order_id')
]
return cancel_decision
if decision_type in {'FLIP', 'CLOSE_OPPOSITE'}:
if opposite_positions:
normalized = build_close(opposite_positions, decision.get('reasoning', decision.get('reason', '反向仓位先平仓')))
normalized['next_decision'] = self._build_follow_up_open_decision(decision)
return normalized
if opposite_pending:
normalized = build_cancel(opposite_pending, decision.get('reasoning', decision.get('reason', '先撤销反向挂单')))
normalized['next_decision'] = self._build_follow_up_open_decision(decision)
return normalized
return self._build_follow_up_open_decision(decision)
if decision_type == 'CANCEL_AND_OPEN':
if opposite_pending:
normalized = build_cancel(opposite_pending, decision.get('reasoning', decision.get('reason', '先撤销反向挂单')))
normalized['next_decision'] = self._build_follow_up_open_decision(decision)
return normalized
return self._build_follow_up_open_decision(decision)
if decision_type == 'ROLL':
if same_positions:
normalized = build_close(same_positions, decision.get('reasoning', decision.get('reason', '滚仓先平旧仓')))
normalized['next_decision'] = self._build_follow_up_open_decision(decision)
return normalized
return self._build_follow_up_open_decision(decision)
logger.warning(f"未知复合决策类型,降级为 HOLD: {decision_type}")
fallback = dict(decision)
fallback['decision'] = 'HOLD'
fallback['reasoning'] = fallback.get('reasoning', fallback.get('reason', f'未支持的决策类型: {decision_type}'))
return fallback
async def _execute_decisions(self, paper_decision: Dict[str, Any],
hyperliquid_decision: Dict[str, Any],
bitget_decision: Dict[str, Any],
@ -1039,6 +1153,7 @@ class CryptoAgent:
"""执行模拟盘决策(使用执行器)"""
try:
decision_type = decision.get('decision', 'HOLD')
next_decision = decision.get('next_decision')
if decision_type == 'HOLD':
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
@ -1077,6 +1192,8 @@ class CryptoAgent:
if result.get('success'):
logger.info(f" ✅ 平仓成功")
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}")
@ -1096,8 +1213,12 @@ class CryptoAgent:
if success_count > 0:
logger.info(f" ✅ 成功取消 {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" ⚠️ 没有成功取消任何挂单")
else:
logger.warning(f" ⚠️ 模拟盘暂不支持的执行动作: {decision_type}")
except Exception as e:
logger.error(f" ❌ 模拟盘执行异常: {e}")
@ -2128,6 +2249,7 @@ class CryptoAgent:
'quantity': order.get('size'),
'entry_type': 'limit',
'is_reduce_only': order.get('is_reduce_only', False),
'created_at': order.get('created_at'),
})
return position_list, account, pending_orders
@ -2474,7 +2596,14 @@ class CryptoAgent:
"decision": final_action,
"action": final_action,
"reason": final_reason,
"reasoning": final_reason
"reasoning": final_reason,
"signal_action": signal.get('action'),
"symbol": signal.get('symbol'),
"entry_price": signal.get('entry_price'),
"stop_loss": signal.get('stop_loss'),
"take_profit": signal.get('take_profit'),
"confidence": signal.get('confidence', 0),
"grade": signal.get('grade', 'C'),
}
async def _execute_bitget_decisions(self, decision: Dict[str, Any],
@ -2484,6 +2613,7 @@ class CryptoAgent:
try:
decision_type = decision.get('decision', 'HOLD')
symbol = decision.get('symbol', 'UNKNOWN')
next_decision = decision.get('next_decision')
if decision_type == 'HOLD':
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
@ -2541,6 +2671,8 @@ class CryptoAgent:
if result.get('success'):
logger.info(f" ✅ Bitget 平仓成功")
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}")
@ -2562,10 +2694,14 @@ class CryptoAgent:
if success_count > 0:
logger.info(f" ✅ Bitget 取消成功: {success_count} 个挂单")
if next_decision:
await self._execute_bitget_decisions(next_decision, market_signal, current_price)
else:
error = "没有成功取消任何挂单"
logger.error(f" ❌ Bitget 取消失败: {error}")
await self._notify_bitget_error(symbol, "取消挂单", error)
else:
logger.warning(f" ⚠️ Bitget 暂不支持的执行动作: {decision_type}")
except Exception as e:
logger.error(f" ❌ Bitget 执行异常: {e}")
@ -2816,6 +2952,7 @@ class CryptoAgent:
try:
decision_type = decision.get('decision', 'HOLD')
symbol = decision.get('symbol', 'UNKNOWN')
next_decision = decision.get('next_decision')
if decision_type == 'HOLD':
reasoning = decision.get('reasoning', decision.get('reason', '观望'))
@ -2854,6 +2991,8 @@ class CryptoAgent:
if result.get('success'):
logger.info(f" ✅ Hyperliquid 平仓成功")
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}")
@ -2868,12 +3007,16 @@ class CryptoAgent:
result = await executor.execute_cancel(order_id, symbol)
if result.get('success'):
success_count += 1
if success_count > 1:
if success_count > 0:
logger.info(f" ✅ Hyperliquid 取消成功: {success_count}")
if next_decision:
await self._execute_hyperliquid_decisions(next_decision, market_signal, current_price)
else:
error = "没有成功取消任何挂单"
logger.error(f" ❌ Hyperliquid 取消失败: {error}")
await self._notify_hyperliquid_error(symbol, "取消挂单", error)
else:
logger.warning(f" ⚠️ Hyperliquid 暂不支持的执行动作: {decision_type}")
except Exception as e:
logger.error(f" ❌ Hyperliquid 执行异常: {e}")
await self._notify_hyperliquid_error(symbol, decision_type, str(e))
@ -3558,7 +3701,7 @@ class CryptoAgent:
platforms_to_check.append(('模拟盘', self.paper_trading))
# 添加 Bitget 实盘
if self.bitget and self.settings.bitget_use_testnet:
if self.bitget:
platforms_to_check.append(('Bitget', self.bitget))
# 添加 Hyperliquid 实盘
@ -3570,6 +3713,8 @@ class CryptoAgent:
# 获取账户状态
if hasattr(platform_service, 'get_account_state'):
account_state = platform_service.get_account_state()
elif hasattr(platform_service, 'get_account_status'):
account_state = platform_service.get_account_status()
elif hasattr(platform_service, 'get_balance'):
account_state = platform_service.get_balance()
else:
@ -3579,8 +3724,9 @@ class CryptoAgent:
# 获取当前余额(统一字段名)
current_balance = (
account_state.get('current_balance') or
account_state.get('account_value') or
account_state.get('balance') or
account_state.get('available_balance', 0)
0
)
if current_balance <= 0:
@ -3665,6 +3811,21 @@ class CryptoAgent:
logger.info(f"[{platform_name}] 需要平仓 {len(positions)} 个持仓")
if hasattr(platform_service, 'market_close_all'):
result = platform_service.market_close_all()
success_items = result.get('results') or result.get('result') or []
closed_count = sum(1 for item in success_items if item.get('success', True))
await self._send_alert_notification(
f"🚨 [{platform_name}] 紧急平仓完成",
f"触发原因: 账户回撤超过 {self.settings.account_max_drawdown*100:.0f}%\n"
f"平仓数量: {closed_count}/{len(positions)}\n\n"
f"⚠️ 交易系统已停止,请人工检查账户!"
)
logger.info(f"🚨 [{platform_name}] 紧急平仓完成: {closed_count}/{len(positions)}")
return
# 逐个平仓
closed_count = 0
for pos in positions:
@ -3753,7 +3914,7 @@ class CryptoAgent:
# 达到目标盈利,平仓
decision = {
'decision': 'CLOSE',
'symbol': symbol + 'USDT',
'symbol': self._normalize_symbol(symbol),
'reason': reason
}
result = await executor.execute_close(decision, current_prices.get(symbol, 0))
@ -3768,7 +3929,7 @@ class CryptoAgent:
# 持仓超时,平仓
decision = {
'decision': 'CLOSE',
'symbol': symbol + 'USDT',
'symbol': self._normalize_symbol(symbol),
'reason': reason
}
result = await executor.execute_close(decision, current_prices.get(symbol, 0))
@ -3874,7 +4035,7 @@ class CryptoAgent:
try:
# 飞书
if self.feishu:
await self.feishu.send_message(f"{title}\n\n{message}")
await self.feishu.send_text(f"{title}\n\n{message}")
# 钉钉
if self.dingtalk:
@ -3895,4 +4056,3 @@ def get_crypto_agent() -> 'CryptoAgent':
"""获取加密货币智能体单例"""
# 直接使用类单例,不使用全局变量(避免 reload 时重置)
return CryptoAgent()

View File

@ -120,37 +120,24 @@ class BitgetExecutor(BaseExecutor):
symbol = decision.get('symbol', '').replace('USDT', '')
orders_to_close = decision.get('orders_to_close', [])
if not orders_to_close:
# 平掉所有持仓
result = self.bitget.market_close_all()
logger.info(f" ✅ 平仓所有持仓")
# Bitget 持仓是按 symbol 聚合管理,不能按 order_id 精确平仓。
result = self.bitget.market_close_position(symbol)
if result.get('success'):
logger.info(f" ✅ 平仓成功: {symbol}")
else:
logger.warning(f" ⚠️ 平仓失败: {symbol} - {result.get('error', '未知错误')}")
# 发送飞书通知
await self.send_execution_notification(
operation='CLOSE',
symbol=symbol,
result=result
)
return result
# 平掉指定订单
results = []
for order_id in orders_to_close:
# 这里需要根据 order_id 找到对应的持仓
# Bitget 的持仓管理需要优化
results.append({'order_id': order_id, 'success': True})
success_result = {'success': True, 'results': results}
if orders_to_close:
result['requested_order_ids'] = orders_to_close
# 发送飞书通知
await self.send_execution_notification(
operation='CLOSE',
symbol=symbol,
result=success_result
result=result
)
return success_result
return result
except Exception as e:
logger.error(f"Bitget 平仓失败: {e}")
@ -169,7 +156,8 @@ class BitgetExecutor(BaseExecutor):
"""执行撤单"""
try:
result = self.bitget.cancel_order(symbol.replace('USDT', ''), order_id)
logger.info(f" ✅ 撤单成功: {order_id}")
if result.get('success'):
logger.info(f" ✅ 撤单成功: {order_id}")
# 发送飞书通知
await self.send_execution_notification(
@ -306,17 +294,24 @@ class BitgetExecutor(BaseExecutor):
{'success': bool, 'message': str}
"""
try:
# Bitget 使用 modify_sl_tp 方法
success = self.bitget.modify_sl_tp(
position = self.bitget.get_position_for_symbol(symbol)
if not position:
return {'success': False, 'message': f'找不到 {symbol} 的持仓'}
tp_sl_prices = self.bitget.get_tp_sl_prices(symbol.replace('USDT', ''))
result = self.bitget.set_tp_sl(
symbol=symbol.replace('USDT', ''),
stop_loss=new_stop_loss
is_long=position['size'] > 0,
size=abs(position['size']),
tp_price=tp_sl_prices.get('take_profit'),
sl_price=new_stop_loss
)
if success:
if result.get('success'):
logger.info(f" ✅ 移动止损成功: {symbol} → ${new_stop_loss:.2f}")
return {'success': True, 'message': f'移动止损成功: {new_stop_loss:.2f}'}
else:
return {'success': False, 'message': '移动止损失败'}
return {'success': False, 'message': result.get('error', result.get('message', '移动止损失败'))}
except Exception as e:
logger.error(f"Bitget 移动止损失败: {e}")

View File

@ -129,39 +129,23 @@ class HyperliquidExecutor(BaseExecutor):
symbol = decision.get('symbol', '').replace('USDT', '')
orders_to_close = decision.get('orders_to_close', [])
if not orders_to_close:
# 平掉所有持仓
result = self.hyperliquid.market_close_all()
logger.info(f" ✅ 平仓所有持仓")
result = self.hyperliquid.market_close_position(symbol)
if result.get('success'):
logger.info(f" ✅ 平仓成功: {symbol}")
else:
logger.warning(f" ⚠️ 平仓失败: {symbol} - {result.get('error', '未知错误')}")
# 发送飞书通知
await self.send_execution_notification(
operation='CLOSE',
symbol=symbol,
result=result
)
return result
# 平掉指定订单
results = []
for order_id in orders_to_close:
close_result = self.hyperliquid.close_position(symbol, order_id)
results.append({
'order_id': order_id,
'success': close_result.get('success', False)
})
success_result = {'success': True, 'results': results}
if orders_to_close:
result['requested_order_ids'] = orders_to_close
# 发送飞书通知
await self.send_execution_notification(
operation='CLOSE',
symbol=symbol,
result=success_result
result=result
)
return success_result
return result
except Exception as e:
logger.error(f"Hyperliquid 平仓失败: {e}")
@ -320,9 +304,16 @@ class HyperliquidExecutor(BaseExecutor):
{'success': bool, 'message': str}
"""
try:
# Hyperliquid 使用 set_tp_sl 方法(只传 sl_price
position = self.hyperliquid.get_position_for_symbol(symbol)
if not position:
return {'success': False, 'message': f'找不到 {symbol} 的持仓'}
tp_sl_prices = self.hyperliquid.get_tp_sl_prices(symbol.replace('USDT', ''))
result = self.hyperliquid.set_tp_sl(
symbol=symbol.replace('USDT', ''),
is_long=position['size'] > 0,
size=abs(position['size']),
tp_price=tp_sl_prices.get('take_profit'),
sl_price=new_stop_loss
)
@ -330,7 +321,7 @@ class HyperliquidExecutor(BaseExecutor):
logger.info(f" ✅ 移动止损成功: {symbol} → ${new_stop_loss:.2f}")
return {'success': True, 'message': f'移动止损成功: {new_stop_loss:.2f}'}
else:
return {'success': False, 'message': result.get('message', '移动止损失败')}
return {'success': False, 'message': result.get('error', result.get('message', '移动止损失败'))}
except Exception as e:
logger.error(f"Hyperliquid 移动止损失败: {e}")

View File

@ -5,6 +5,7 @@ Bitget 实盘交易服务
crypto_agent.py 的决策执行层使用
"""
import math
from datetime import datetime
from typing import Dict, List, Optional, Any
from app.config import get_settings
@ -91,6 +92,7 @@ class BitgetLiveTradingService:
return {
"account_value": account_value,
"current_balance": account_value,
"total_margin_used": frozen,
"available_balance": available,
}
@ -170,14 +172,25 @@ class BitgetLiveTradingService:
side = pos.get('side', 'long')
size = coin_amount if side == 'long' else -coin_amount
tp_sl_prices = self.get_tp_sl_prices(coin)
opened_at_raw = info.get('cTime') or pos.get('timestamp')
if isinstance(opened_at_raw, (int, float)):
opened_at = datetime.fromtimestamp(opened_at_raw / 1000).isoformat()
else:
opened_at = pos.get('datetime') or opened_at_raw
result.append({
"coin": coin,
"symbol": f"{coin}USDT",
"side": "buy" if size > 0 else "sell",
"size": size,
"entry_price": float(pos.get('entryPrice', 0) or 0),
"unrealized_pnl": float(pos.get('unrealizedPnl', 0) or 0),
"leverage": int(float(pos.get('leverage', 1) or 1)),
"liquidation_price": float(pos.get('liquidationPrice', 0) or 0) or None,
"stop_loss": tp_sl_prices.get('stop_loss'),
"take_profit": tp_sl_prices.get('take_profit'),
"opened_at": opened_at,
"position": pos,
})
return result
@ -409,6 +422,11 @@ class BitgetLiveTradingService:
contracts = float(order.get('amount', 0) or 0)
contract_size = self.get_contract_size(coin)
size_in_coins = contracts # ccxt amount 已是币数量
created_at_raw = order.get('timestamp')
if isinstance(created_at_raw, (int, float)):
created_at = datetime.fromtimestamp(created_at_raw / 1000).isoformat()
else:
created_at = order.get('datetime') or created_at_raw
result.append({
"order_id": str(order.get('id', '')),
@ -418,9 +436,27 @@ class BitgetLiveTradingService:
"price": float(order.get('price', 0) or 0),
"is_reduce_only": bool(order.get('reduceOnly', False)),
"order_type": order.get('type', ''),
"created_at": created_at,
})
return result
def cancel_order(self, symbol: str, order_id: str) -> Dict[str, Any]:
"""
撤销单个挂单
Returns:
{"success": bool, "order_id": str, "error"?: str}
"""
try:
success = self.trading_api.cancel_order(symbol=symbol, order_id=order_id)
if success:
logger.info(f"✅ Bitget 单笔撤单成功: {symbol} #{order_id}")
return {"success": True, "order_id": str(order_id), "symbol": symbol}
return {"success": False, "order_id": str(order_id), "error": "cancel_order 返回 False"}
except Exception as e:
logger.error(f"❌ Bitget 单笔撤单失败: {symbol} #{order_id} {e}")
return {"success": False, "order_id": str(order_id), "error": str(e)}
def cancel_all_orders(self, symbol: Optional[str] = None) -> Dict[str, Any]:
"""
撤销指定币种的所有挂单
@ -493,47 +529,57 @@ class BitgetLiveTradingService:
def market_close_all(self) -> Dict[str, Any]:
"""市价平仓所有持仓"""
import math
results = []
positions = self.get_open_positions()
for pos in positions:
coin = pos['coin']
is_long = pos['size'] > 0
coin_amount = abs(pos['size'])
# 精度处理:向下取整到 0.0001Bitget 最小精度)
coin_amount = math.floor(coin_amount * 10000) / 10000
if coin_amount < 0.0001:
logger.warning(f"{coin} 持仓过小 ({coin_amount}),跳过")
continue
# 直接使用币数量平仓,不经过合约转换
try:
ccxt_symbol = self.trading_api._standardize_symbol(coin + 'USDT')
side = 'sell' if is_long else 'buy'
order = self.trading_api.exchange.create_market_order(
symbol=ccxt_symbol,
side=side,
amount=coin_amount,
params={
'reduceOnly': True,
'tdMode': 'cross',
'marginCoin': 'USDT',
}
)
if order:
logger.info(f"✅ Bitget 平仓成功: {coin} {side} {coin_amount}")
results.append({"success": True, "coin": coin, "size": coin_amount})
else:
results.append({"success": False, "coin": coin, "error": "返回空"})
except Exception as e:
logger.error(f"❌ Bitget 平仓失败: {coin} {e}")
results.append({"success": False, "coin": coin, "error": str(e)})
results.append(self.market_close_position(pos['coin']))
all_ok = all(r.get('success') for r in results)
return {"success": all_ok, "results": results}
def market_close_position(self, symbol: str) -> Dict[str, Any]:
"""按交易对市价平仓单个持仓"""
import math
coin = symbol.replace('USDT', '').replace('/', '').upper()
position = self.get_position_for_symbol(coin)
if not position:
return {"success": False, "coin": coin, "error": "未找到持仓"}
is_long = position['size'] > 0
coin_amount = math.floor(abs(position['size']) * 10000) / 10000
if coin_amount < 0.0001:
logger.warning(f"{coin} 持仓过小 ({coin_amount}),跳过")
return {"success": True, "coin": coin, "size": 0}
self.cancel_tp_sl_orders(coin)
try:
ccxt_symbol = self.trading_api._standardize_symbol(coin + 'USDT')
side = 'sell' if is_long else 'buy'
order = self.trading_api.exchange.create_market_order(
symbol=ccxt_symbol,
side=side,
amount=coin_amount,
params={
'reduceOnly': True,
'tdMode': 'cross',
'marginCoin': 'USDT',
}
)
if order:
logger.info(f"✅ Bitget 单币种平仓成功: {coin} {side} {coin_amount}")
return {
"success": True,
"coin": coin,
"size": coin_amount,
"order_id": str(order.get('id', '')),
}
return {"success": False, "coin": coin, "error": "返回空"}
except Exception as e:
logger.error(f"❌ Bitget 单币种平仓失败: {coin} {e}")
return {"success": False, "coin": coin, "error": str(e)}
# ==================== 单例工厂 ====================

View File

@ -79,6 +79,7 @@ class HyperliquidTradingService:
return {
"account_value": account_value,
"current_balance": account_value,
"total_margin_used": total_margin_used,
"available_balance": account_value - total_margin_used,
"positions": state.get("assetPositions", []),
@ -324,7 +325,9 @@ class HyperliquidTradingService:
"order_type": order.get("orderType", {}),
"timestamp": order.get("timestamp"),
"original_size": float(order.get("origSz", 0)),
"raw_side": side
"raw_side": side,
"created_at": datetime.fromtimestamp(order.get("timestamp", 0) / 1000).isoformat()
if order.get("timestamp") else None,
})
return orders
@ -476,6 +479,8 @@ class HyperliquidTradingService:
def cancel_order(self, symbol: str, order_id: int) -> Dict[str, Any]:
"""取消订单"""
try:
if isinstance(order_id, str):
order_id = int(order_id)
result = self.exchange.cancel(symbol, order_id)
logger.info(f"取消订单: {symbol} #{order_id}")
return {"success": True, "result": result}
@ -515,29 +520,46 @@ class HyperliquidTradingService:
for pos in positions:
position_data = pos.get("position", {})
coin = position_data.get("coin")
size = float(position_data.get("szi", 0))
if size == 0:
if not coin:
continue
results.append(self.market_close_position(coin))
# 取消该币种的所有挂单(包括止盈止损)
self.cancel_all_orders(coin)
is_long = size > 0
result = self.place_market_order(
symbol=coin,
is_buy=not is_long, # 平多头=卖出,平空头=买入
size=abs(size),
reduce_only=True
)
results.append(result)
all_ok = all(result.get("success") for result in results)
logger.info(f"🚨 紧急平仓完成,共平仓 {len(results)} 个持仓")
return {"success": True, "closed_positions": len(results), "results": results}
return {"success": all_ok, "closed_positions": len(results), "results": results}
except Exception as e:
logger.error(f"紧急平仓失败: {e}")
return {"success": False, "error": str(e)}
def market_close_position(self, symbol: str) -> Dict[str, Any]:
"""按交易对市价平仓单个持仓"""
position = self.get_position_for_symbol(symbol)
if not position:
return {"success": False, "symbol": symbol, "error": "未找到持仓"}
coin = position.get("coin", symbol.replace('USDT', '').replace('/', '').upper())
size = abs(float(position.get("size", 0)))
if size <= 0:
return {"success": True, "symbol": coin, "size": 0}
self.cancel_all_orders(coin)
is_long = position.get("size", 0) > 0
result = self.place_market_order(
symbol=coin,
is_buy=not is_long,
size=size,
reduce_only=True
)
if result.get("success"):
result["symbol"] = coin
return result
def close_position(self, symbol: str, order_id: Optional[int] = None) -> Dict[str, Any]:
"""兼容旧调用:按交易对平仓,忽略 order_id"""
return self.market_close_position(symbol)
def get_open_positions(self) -> List[Dict[str, Any]]:
"""获取所有持仓"""
try:
@ -552,13 +574,21 @@ class HyperliquidTradingService:
if size == 0:
continue
tp_sl_prices = self.get_tp_sl_prices(coin)
positions.append({
"coin": coin,
"symbol": f"{coin}USDT",
"side": "buy" if size > 0 else "sell",
"size": size, # 正数=多头,负数=空头
"entry_price": float(position_data.get("entryPx", 0)),
"unrealized_pnl": float(position_data.get("unrealizedPnl", 0)),
"leverage": position_data.get("leverage", {}).get("value"),
"liquidation_price": position_data.get("liquidationPx"),
"stop_loss": tp_sl_prices.get("stop_loss"),
"take_profit": tp_sl_prices.get("take_profit"),
"opened_at": datetime.fromtimestamp(position_data.get("timestamp", 0) / 1000).isoformat()
if position_data.get("timestamp") else None,
"position": position_data # 保留原始数据
})
@ -569,9 +599,10 @@ class HyperliquidTradingService:
def get_position_for_symbol(self, symbol: str) -> Optional[Dict[str, Any]]:
"""获取指定币种的持仓"""
normalized_symbol = symbol.replace('USDT', '').replace('/', '').upper()
positions = self.get_open_positions()
for pos in positions:
if pos["coin"] == symbol:
if pos["coin"] == normalized_symbol:
return pos
return None

View File

@ -1604,6 +1604,38 @@ class PaperTradingService:
finally:
db.close()
def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
"""兼容执行层接口:返回未成交挂单列表"""
orders = self.get_active_orders(symbol)
return [order for order in orders if order.get('status') == OrderStatus.PENDING.value]
def get_open_positions(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
"""兼容执行层接口:返回已成交持仓列表"""
orders = self.get_active_orders(symbol)
positions = []
for order in orders:
if order.get('status') != OrderStatus.OPEN.value:
continue
side = order.get('side')
positions.append({
'order_id': order.get('order_id'),
'symbol': order.get('symbol'),
'side': 'buy' if side == 'long' else 'sell',
'entry_price': order.get('filled_price') or order.get('entry_price') or 0,
'filled_price': order.get('filled_price'),
'quantity': order.get('quantity', 0),
'margin': order.get('margin', 0),
'stop_loss': order.get('stop_loss'),
'take_profit': order.get('take_profit'),
'unrealized_pnl_pct': order.get('pnl_percent', 0),
'opened_at': order.get('opened_at'),
'created_at': order.get('created_at'),
})
return positions
def get_order_by_id(self, order_id: str) -> Optional[Dict[str, Any]]:
"""根据ID获取订单"""
# 先从缓存查找
@ -1849,6 +1881,10 @@ class PaperTradingService:
'total_pnl_percent': round((realized_pnl / self.initial_balance * 100), 2) if self.initial_balance > 0 else 0
}
def get_account_state(self) -> Dict[str, Any]:
"""兼容执行层接口:账户状态别名"""
return self.get_account_status()
def _calculate_grade_statistics(self, orders: List[PaperOrder]) -> Dict[str, Any]:
"""按信号等级统计"""
result = {}

View File

@ -0,0 +1,158 @@
"""
执行链安全修复回归测试
覆盖重点
- Bitget 单币种平仓不再误触发全仓平仓
- Bitget 单笔撤单接口可用
- Bitget 移动止损走 set_tp_sl而不是不存在的方法
"""
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 make_bitget_service():
from app.services.bitget_live_trading_service import BitgetLiveTradingService
mock_api = MagicMock()
mock_exchange = MagicMock()
mock_api.exchange = mock_exchange
mock_api._standardize_symbol = lambda s: f"{s.replace('USDT', '')}/USDT:USDT"
mock_settings = MagicMock()
mock_settings.bitget_max_total_leverage = 10.0
mock_settings.bitget_max_single_position = 1000.0
mock_settings.hyperliquid_circuit_breaker_drawdown = 0.10
service = BitgetLiveTradingService.__new__(BitgetLiveTradingService)
service.settings = mock_settings
service.max_total_leverage = mock_settings.bitget_max_total_leverage
service.max_single_position = mock_settings.bitget_max_single_position
service.circuit_breaker_drawdown = mock_settings.hyperliquid_circuit_breaker_drawdown
service.trading_api = mock_api
service.initial_balance = 10000.0
return service, mock_api
def load_bitget_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.bitget_executor' not in sys.modules:
executor_spec = importlib.util.spec_from_file_location(
'app.crypto_agent.executor.bitget_executor',
executor_dir / 'bitget_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.bitget_executor'].BitgetExecutor
def test_bitget_market_close_position_only_closes_requested_symbol():
service, mock_api = make_bitget_service()
mock_api.get_position.return_value = [
{
'symbol': 'BTC/USDT:USDT',
'side': 'long',
'entryPrice': 50000.0,
'unrealizedPnl': 100.0,
'leverage': 5,
'liquidationPrice': 45000.0,
'info': {'available': '0.02'},
},
{
'symbol': 'ETH/USDT:USDT',
'side': 'long',
'entryPrice': 3000.0,
'unrealizedPnl': 50.0,
'leverage': 5,
'liquidationPrice': 2500.0,
'info': {'available': '0.5'},
},
]
mock_api.get_open_orders.return_value = []
mock_api.cancel_all_orders.return_value = True
mock_api.exchange.create_market_order.return_value = {'id': 'close-btc'}
result = service.market_close_position('BTCUSDT')
assert result['success'] is True
assert result['coin'] == 'BTC'
kwargs = mock_api.exchange.create_market_order.call_args.kwargs
assert kwargs['symbol'] == 'BTC/USDT:USDT'
assert kwargs['side'] == 'sell'
def test_bitget_cancel_order_delegates_to_sdk():
service, mock_api = make_bitget_service()
mock_api.cancel_order.return_value = True
result = service.cancel_order('BTC', 'oid-1')
assert result['success'] is True
mock_api.cancel_order.assert_called_once_with(symbol='BTC', order_id='oid-1')
def test_bitget_executor_close_uses_symbol_close_not_close_all():
BitgetExecutor = load_bitget_executor_class()
executor = BitgetExecutor.__new__(BitgetExecutor)
executor.bitget = MagicMock()
executor.send_execution_notification = AsyncMock()
executor.bitget.market_close_position.return_value = {'success': True, 'coin': 'BTC'}
result = asyncio.run(executor.execute_close({'symbol': 'BTCUSDT'}, 50000.0))
assert result['success'] is True
executor.bitget.market_close_position.assert_called_once_with('BTC')
executor.bitget.market_close_all.assert_not_called()
def test_bitget_executor_move_stop_loss_uses_set_tp_sl():
BitgetExecutor = load_bitget_executor_class()
executor = BitgetExecutor.__new__(BitgetExecutor)
executor.bitget = MagicMock()
executor.bitget.get_position_for_symbol.return_value = {'size': 0.02}
executor.bitget.get_tp_sl_prices.return_value = {'take_profit': 55000.0}
executor.bitget.set_tp_sl.return_value = {'success': True}
result = asyncio.run(executor.move_stop_loss('BTCUSDT', 49000.0))
assert result['success'] is True
executor.bitget.set_tp_sl.assert_called_once_with(
symbol='BTC',
is_long=True,
size=0.02,
tp_price=55000.0,
sl_price=49000.0,
)