stock-ai-agent/backend/app/services/bitget_live_trading_service.py
2026-03-25 22:23:38 +08:00

538 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Bitget 实盘交易服务
提供与 HyperliquidTradingService 一致的接口,底层调用 BitgetTradingAPIccxt
供 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