538 lines
18 KiB
Python
538 lines
18 KiB
Python
"""
|
||
Bitget 实盘交易服务
|
||
|
||
提供与 HyperliquidTradingService 一致的接口,底层调用 BitgetTradingAPI(ccxt)。
|
||
供 crypto_agent.py 的决策执行层使用。
|
||
"""
|
||
import math
|
||
from typing import Dict, List, Optional, Any
|
||
|
||
from app.config import get_settings
|
||
from app.services.bitget_trading_api_sdk import get_bitget_trading_api
|
||
from app.utils.logger import logger
|
||
|
||
# 合约面值表(张 → 币数量)
|
||
CONTRACT_SIZES: Dict[str, float] = {
|
||
'BTC': 0.01,
|
||
'ETH': 0.1,
|
||
'LTC': 0.1,
|
||
'BCH': 0.1,
|
||
'BNB': 0.1,
|
||
'SOL': 1.0,
|
||
'AVAX': 1.0,
|
||
'LINK': 1.0,
|
||
'UNI': 1.0,
|
||
'ATOM': 1.0,
|
||
'FIL': 1.0,
|
||
'DOT': 1.0,
|
||
'XRP': 10.0,
|
||
'DOGE': 100.0,
|
||
'MATIC': 10.0,
|
||
'POL': 10.0,
|
||
}
|
||
|
||
|
||
class BitgetLiveTradingService:
|
||
"""
|
||
Bitget 实盘交易服务
|
||
|
||
接口与 HyperliquidTradingService 保持一致,方便 crypto_agent.py 统一调用。
|
||
"""
|
||
|
||
def __init__(self):
|
||
self.settings = get_settings()
|
||
self.max_total_leverage: float = self.settings.bitget_max_total_leverage
|
||
self.max_single_position: float = self.settings.bitget_max_single_position
|
||
self.circuit_breaker_drawdown: float = self.settings.hyperliquid_circuit_breaker_drawdown
|
||
|
||
self.trading_api = get_bitget_trading_api()
|
||
if not self.trading_api:
|
||
raise RuntimeError("Bitget 交易 API 初始化失败,请检查 API Key 配置")
|
||
|
||
# 初始余额(用于回撤计算)
|
||
self.initial_balance: Optional[float] = None
|
||
self._initialize_account()
|
||
|
||
logger.info(
|
||
f"✅ BitgetLiveTradingService 初始化完成 "
|
||
f"(最大总杠杆: {self.max_total_leverage}x, "
|
||
f"单笔上限: ${self.max_single_position}, "
|
||
f"熔断阈值: {self.circuit_breaker_drawdown * 100:.0f}%)"
|
||
)
|
||
|
||
def _initialize_account(self):
|
||
"""初始化账户状态,记录初始余额"""
|
||
try:
|
||
state = self.get_account_state()
|
||
self.initial_balance = state["account_value"]
|
||
logger.info(f"Bitget 初始账户余额: ${self.initial_balance:.2f}")
|
||
except Exception as e:
|
||
logger.warning(f"Bitget 初始化账户余额失败: {e}")
|
||
|
||
# ==================== 账户 ====================
|
||
|
||
def get_account_state(self) -> Dict[str, Any]:
|
||
"""
|
||
获取账户状态
|
||
|
||
Returns:
|
||
{
|
||
"account_value": float, # 账户总价值(USDT)
|
||
"total_margin_used": float, # 已用保证金
|
||
"available_balance": float, # 可用余额
|
||
}
|
||
"""
|
||
balance = self.trading_api.get_balance()
|
||
|
||
usdt = balance.get('USDT', {})
|
||
available = float(usdt.get('available', 0) or 0)
|
||
frozen = float(usdt.get('frozen', 0) or 0)
|
||
account_value = available + frozen
|
||
|
||
return {
|
||
"account_value": account_value,
|
||
"total_margin_used": frozen,
|
||
"available_balance": available,
|
||
}
|
||
|
||
def check_risk_limits(self) -> Dict[str, Any]:
|
||
"""
|
||
检查风控限制
|
||
|
||
Returns:
|
||
{"allowed": bool, "reason": str}
|
||
"""
|
||
try:
|
||
state = self.get_account_state()
|
||
account_value = state["account_value"]
|
||
|
||
# 1. 熔断检查
|
||
if self.initial_balance and self.initial_balance > 0:
|
||
drawdown = (self.initial_balance - account_value) / self.initial_balance
|
||
if drawdown >= self.circuit_breaker_drawdown:
|
||
return {
|
||
"allowed": False,
|
||
"reason": f"熔断触发: 回撤 {drawdown * 100:.1f}% >= 阈值 {self.circuit_breaker_drawdown * 100:.0f}%"
|
||
}
|
||
|
||
# 2. 总杠杆检查
|
||
positions = self.get_open_positions()
|
||
total_position_value = sum(
|
||
abs(p["size"]) * p["entry_price"] for p in positions
|
||
)
|
||
if account_value > 0:
|
||
current_leverage = total_position_value / account_value
|
||
if current_leverage >= self.max_total_leverage:
|
||
return {
|
||
"allowed": False,
|
||
"reason": f"总杠杆超限: {current_leverage:.1f}x >= {self.max_total_leverage}x"
|
||
}
|
||
|
||
return {"allowed": True, "reason": ""}
|
||
except Exception as e:
|
||
logger.error(f"Bitget 风控检查异常: {e}")
|
||
return {"allowed": False, "reason": f"风控检查异常: {e}"}
|
||
|
||
# ==================== 持仓 ====================
|
||
|
||
def get_open_positions(self) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取所有持仓
|
||
|
||
Returns:
|
||
[
|
||
{
|
||
"coin": "BTC",
|
||
"size": float, # 正数=多, 负数=空(以币为单位)
|
||
"entry_price": float,
|
||
"unrealized_pnl": float,
|
||
"leverage": int,
|
||
"liquidation_price": Optional[float],
|
||
"position": dict, # 原始数据
|
||
}
|
||
]
|
||
"""
|
||
raw_positions = self.trading_api.get_position()
|
||
result = []
|
||
for pos in raw_positions:
|
||
contracts = float(pos.get('contracts', 0))
|
||
if contracts == 0:
|
||
continue
|
||
|
||
symbol_raw = pos.get('symbol', '') # e.g. "BTC/USDT:USDT"
|
||
coin = symbol_raw.split('/')[0] if '/' in symbol_raw else symbol_raw
|
||
|
||
contract_size = self.get_contract_size(coin)
|
||
coin_amount = contracts * contract_size
|
||
|
||
side = pos.get('side', 'long')
|
||
size = coin_amount if side == 'long' else -coin_amount
|
||
|
||
result.append({
|
||
"coin": coin,
|
||
"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,
|
||
"position": pos,
|
||
})
|
||
return result
|
||
|
||
def get_position_for_symbol(self, symbol: str) -> Optional[Dict[str, Any]]:
|
||
"""获取指定币种的持仓,无持仓返回 None"""
|
||
coin = symbol.replace('USDT', '').replace('/', '').upper()
|
||
for pos in self.get_open_positions():
|
||
if pos['coin'] == coin:
|
||
return pos
|
||
return None
|
||
|
||
# ==================== 下单 ====================
|
||
|
||
def place_market_order(
|
||
self,
|
||
symbol: str,
|
||
is_buy: bool,
|
||
size: int,
|
||
reduce_only: bool = False
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
市价下单
|
||
|
||
Args:
|
||
symbol: 币种,如 "BTC"
|
||
is_buy: True=买入/做多, False=卖出/做空
|
||
size: 合约数量(张)
|
||
reduce_only: 是否只减仓
|
||
Returns:
|
||
{"success": bool, "order_id": str, "symbol": str, "side": str, "size": int, "error"?: str}
|
||
"""
|
||
try:
|
||
side = 'buy' if is_buy else 'sell'
|
||
params = {}
|
||
if reduce_only:
|
||
params['reduceOnly'] = True
|
||
|
||
ccxt_symbol = self.trading_api._standardize_symbol(symbol)
|
||
contract_size = self.get_contract_size(symbol)
|
||
actual_amount = size * contract_size
|
||
|
||
order = self.trading_api.exchange.create_order(
|
||
symbol=ccxt_symbol,
|
||
type='market',
|
||
side=side,
|
||
amount=actual_amount,
|
||
params={
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
'holdMode': 'oneWay',
|
||
**params
|
||
}
|
||
)
|
||
|
||
if not order:
|
||
return {"success": False, "error": "下单返回空", "symbol": symbol, "side": side, "size": size}
|
||
|
||
logger.info(f"✅ Bitget 市价单成功: {symbol} {side} {size}张")
|
||
return {
|
||
"success": True,
|
||
"order_id": str(order.get('id', '')),
|
||
"symbol": symbol,
|
||
"side": side,
|
||
"size": size,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"❌ Bitget 市价单失败: {symbol} {e}")
|
||
return {"success": False, "error": str(e), "symbol": symbol, "side": "buy" if is_buy else "sell", "size": size}
|
||
|
||
def place_limit_order(
|
||
self,
|
||
symbol: str,
|
||
is_buy: bool,
|
||
size: int,
|
||
price: float,
|
||
reduce_only: bool = False
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
限价下单
|
||
|
||
Returns:
|
||
{
|
||
"success": bool,
|
||
"order_status": "resting" | "filled",
|
||
"order_id": str,
|
||
"symbol": str,
|
||
"side": str,
|
||
"size": int,
|
||
"price": float,
|
||
"error"?: str
|
||
}
|
||
"""
|
||
try:
|
||
side = 'buy' if is_buy else 'sell'
|
||
params = {
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
'holdMode': 'oneWay',
|
||
}
|
||
if reduce_only:
|
||
params['reduceOnly'] = True
|
||
|
||
ccxt_symbol = self.trading_api._standardize_symbol(symbol)
|
||
contract_size = self.get_contract_size(symbol)
|
||
actual_amount = size * contract_size
|
||
|
||
order = self.trading_api.exchange.create_order(
|
||
symbol=ccxt_symbol,
|
||
type='limit',
|
||
side=side,
|
||
amount=actual_amount,
|
||
price=price,
|
||
params=params
|
||
)
|
||
|
||
if not order:
|
||
return {
|
||
"success": False,
|
||
"error": "下单返回空",
|
||
"symbol": symbol,
|
||
"side": side,
|
||
"size": size,
|
||
"price": price,
|
||
}
|
||
|
||
# 判断订单状态:open → resting(挂单中),closed → filled(立即成交)
|
||
raw_status = order.get('status', 'open')
|
||
order_status = 'filled' if raw_status == 'closed' else 'resting'
|
||
|
||
logger.info(f"✅ Bitget 限价单: {symbol} {side} {size}张 @ {price} [{order_status}]")
|
||
return {
|
||
"success": True,
|
||
"order_status": order_status,
|
||
"order_id": str(order.get('id', '')),
|
||
"symbol": symbol,
|
||
"side": side,
|
||
"size": size,
|
||
"price": price,
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"❌ Bitget 限价单失败: {symbol} {e}")
|
||
return {
|
||
"success": False,
|
||
"error": str(e),
|
||
"symbol": symbol,
|
||
"side": "buy" if is_buy else "sell",
|
||
"size": size,
|
||
"price": price,
|
||
}
|
||
|
||
# ==================== 止盈止损 ====================
|
||
|
||
def set_tp_sl(
|
||
self,
|
||
symbol: str,
|
||
is_long: bool,
|
||
size: int,
|
||
tp_price: Optional[float] = None,
|
||
sl_price: Optional[float] = None
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
设置止盈止损
|
||
|
||
Returns:
|
||
{"success": bool, "results": [...], "error"?: str}
|
||
"""
|
||
try:
|
||
success = self.trading_api.modify_sl_tp(
|
||
symbol=symbol,
|
||
stop_loss=sl_price,
|
||
take_profit=tp_price,
|
||
)
|
||
if success:
|
||
logger.info(f"✅ Bitget TP/SL 设置成功: {symbol} TP={tp_price} SL={sl_price}")
|
||
return {
|
||
"success": True,
|
||
"results": [
|
||
{"type": "take_profit", "price": tp_price},
|
||
{"type": "stop_loss", "price": sl_price},
|
||
]
|
||
}
|
||
else:
|
||
return {"success": False, "error": "modify_sl_tp 返回 False", "results": []}
|
||
except Exception as e:
|
||
logger.error(f"❌ Bitget 设置 TP/SL 失败: {symbol} {e}")
|
||
return {"success": False, "error": str(e), "results": []}
|
||
|
||
def get_tp_sl_prices(self, symbol: str) -> Dict[str, Optional[float]]:
|
||
"""
|
||
从挂单中读取止盈止损价格
|
||
|
||
Returns:
|
||
{"take_profit": float|None, "stop_loss": float|None}
|
||
"""
|
||
result = {"take_profit": None, "stop_loss": None}
|
||
try:
|
||
orders = self.trading_api.get_open_orders(symbol)
|
||
for order in orders:
|
||
if not order.get('reduceOnly'):
|
||
continue
|
||
order_side = order.get('side', '')
|
||
price = float(order.get('price', 0) or 0)
|
||
order_type = order.get('type', '')
|
||
|
||
# stop 类型通常是止损,limit 类型通常是止盈
|
||
if 'stop' in order_type.lower():
|
||
result['stop_loss'] = price
|
||
elif order_type == 'limit' and price:
|
||
result['take_profit'] = price
|
||
except Exception as e:
|
||
logger.warning(f"Bitget 获取 TP/SL 价格失败: {symbol} {e}")
|
||
return result
|
||
|
||
# ==================== 挂单管理 ====================
|
||
|
||
def get_open_orders(self, symbol: Optional[str] = None) -> List[Dict[str, Any]]:
|
||
"""
|
||
获取挂单列表
|
||
|
||
Returns:
|
||
[{"order_id", "symbol", "side", "size", "price", "is_reduce_only"}]
|
||
"""
|
||
raw_orders = self.trading_api.get_open_orders(symbol)
|
||
result = []
|
||
for order in raw_orders:
|
||
sym_raw = order.get('symbol', '')
|
||
coin = sym_raw.split('/')[0] if '/' in sym_raw else sym_raw
|
||
contracts = float(order.get('amount', 0) or 0)
|
||
contract_size = self.get_contract_size(coin)
|
||
size_in_coins = contracts # ccxt amount 已是币数量
|
||
|
||
result.append({
|
||
"order_id": str(order.get('id', '')),
|
||
"symbol": coin,
|
||
"side": order.get('side', ''),
|
||
"size": size_in_coins,
|
||
"price": float(order.get('price', 0) or 0),
|
||
"is_reduce_only": bool(order.get('reduceOnly', False)),
|
||
"order_type": order.get('type', ''),
|
||
})
|
||
return result
|
||
|
||
def cancel_all_orders(self, symbol: Optional[str] = None) -> Dict[str, Any]:
|
||
"""
|
||
撤销指定币种的所有挂单
|
||
|
||
Returns:
|
||
{"success": bool, "cancelled": int, "error"?: str}
|
||
"""
|
||
try:
|
||
success = self.trading_api.cancel_all_orders(symbol or '')
|
||
if success:
|
||
logger.info(f"✅ Bitget 撤销挂单成功: {symbol or '全部'}")
|
||
return {"success": True, "cancelled": 1}
|
||
else:
|
||
return {"success": False, "cancelled": 0, "error": "cancel_all_orders 返回 False"}
|
||
except Exception as e:
|
||
logger.error(f"❌ Bitget 撤销挂单失败: {symbol} {e}")
|
||
return {"success": False, "cancelled": 0, "error": str(e)}
|
||
|
||
def cancel_tp_sl_orders(self, symbol: str) -> Dict[str, Any]:
|
||
"""撤销止盈止损单(reduce-only 挂单)"""
|
||
return self.cancel_all_orders(symbol)
|
||
|
||
# ==================== 杠杆 ====================
|
||
|
||
def update_leverage(self, symbol: str, leverage: int):
|
||
"""设置杠杆倍数"""
|
||
try:
|
||
self.trading_api.set_leverage(symbol, leverage)
|
||
except Exception as e:
|
||
logger.warning(f"Bitget 设置杠杆失败: {symbol} {leverage}x: {e}")
|
||
|
||
# ==================== 辅助方法 ====================
|
||
|
||
def get_contract_size(self, symbol: str) -> float:
|
||
"""
|
||
获取合约面值(1张合约对应的币数量)
|
||
|
||
优先从硬编码表获取,不存在则查询 ccxt 市场信息。
|
||
"""
|
||
coin = symbol.replace('USDT', '').replace('/', '').upper()
|
||
if coin in CONTRACT_SIZES:
|
||
return CONTRACT_SIZES[coin]
|
||
|
||
# fallback: 从 ccxt market info 获取
|
||
try:
|
||
ccxt_symbol = self.trading_api._standardize_symbol(symbol)
|
||
market = self.trading_api.exchange.market(ccxt_symbol)
|
||
size = float(market.get('contractSize', 1) or 1)
|
||
logger.info(f"Bitget 从市场信息获取合约面值: {coin} = {size}")
|
||
return size
|
||
except Exception:
|
||
logger.warning(f"Bitget 无法获取 {coin} 合约面值,使用默认值 1")
|
||
return 1.0
|
||
|
||
def coins_to_contracts(self, symbol: str, coin_amount: float, price: float = 1.0) -> int:
|
||
"""
|
||
将币数量转换为合约张数(向下取整)
|
||
|
||
Args:
|
||
symbol: 币种
|
||
coin_amount: 要转换的币数量
|
||
price: 当前价格(未使用,保留接口兼容)
|
||
Returns:
|
||
整数合约张数
|
||
"""
|
||
contract_size = self.get_contract_size(symbol)
|
||
if contract_size <= 0:
|
||
return 0
|
||
return math.floor(coin_amount / contract_size)
|
||
|
||
def market_close_all(self) -> Dict[str, Any]:
|
||
"""市价平仓所有持仓"""
|
||
results = []
|
||
positions = self.get_open_positions()
|
||
for pos in positions:
|
||
coin = pos['coin']
|
||
is_long = pos['size'] > 0
|
||
contracts = self.coins_to_contracts(coin, abs(pos['size']))
|
||
if contracts < 1:
|
||
continue
|
||
result = self.place_market_order(coin, is_buy=not is_long, size=contracts, reduce_only=True)
|
||
results.append(result)
|
||
all_ok = all(r.get('success') for r in results)
|
||
return {"success": all_ok, "results": results}
|
||
|
||
|
||
# ==================== 单例工厂 ====================
|
||
|
||
_bitget_live_service: Optional[BitgetLiveTradingService] = None
|
||
|
||
|
||
def get_bitget_live_service() -> Optional[BitgetLiveTradingService]:
|
||
"""
|
||
获取 BitgetLiveTradingService 单例。
|
||
|
||
bitget_trading_enabled=False 时返回 None(功能关闭)。
|
||
"""
|
||
global _bitget_live_service
|
||
|
||
settings = get_settings()
|
||
if not settings.bitget_trading_enabled:
|
||
return None
|
||
|
||
if _bitget_live_service is None:
|
||
try:
|
||
_bitget_live_service = BitgetLiveTradingService()
|
||
except Exception as e:
|
||
logger.error(f"❌ BitgetLiveTradingService 初始化失败: {e}")
|
||
return None
|
||
|
||
return _bitget_live_service
|
||
|
||
|
||
def reset_bitget_live_service():
|
||
"""重置单例(测试用)"""
|
||
global _bitget_live_service
|
||
_bitget_live_service = None
|