840 lines
32 KiB
Python
840 lines
32 KiB
Python
"""
|
||
Bitget 实盘交易服务
|
||
|
||
封装 Bitget U 本位合约实盘交易能力,供 crypto_agent.py 的执行层使用。
|
||
"""
|
||
import math
|
||
from datetime import datetime
|
||
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 实盘交易服务
|
||
|
||
Bitget 实盘交易服务,统一处理账户、持仓、下单和保护单。
|
||
"""
|
||
|
||
@staticmethod
|
||
def _safe_float(*values: Any) -> float:
|
||
for value in values:
|
||
if value in (None, ''):
|
||
continue
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return 0.0
|
||
|
||
def __init__(self, account_id: str = "default", trading_api: Any = None):
|
||
self.account_id = (account_id or "default").strip() or "default"
|
||
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.account_max_drawdown
|
||
|
||
self.trading_api = trading_api or get_bitget_trading_api(self.account_id)
|
||
if not self.trading_api:
|
||
raise RuntimeError(f"Bitget 交易 API 初始化失败,请检查账号 {self.account_id} 的 API Key 配置")
|
||
|
||
# 初始余额(用于回撤计算)
|
||
self.initial_balance: Optional[float] = None
|
||
self._initialize_account()
|
||
|
||
logger.info(
|
||
f"✅ BitgetLiveTradingService 初始化完成 "
|
||
f"(account={self.account_id}, "
|
||
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()
|
||
account_tag = f"[Bitget:{getattr(self, 'account_id', 'default')}]"
|
||
if not balance:
|
||
logger.warning(f"{account_tag} get_balance() 返回空,API 调用可能失败")
|
||
else:
|
||
logger.debug(f"{account_tag} get_balance 原始返回: {balance}")
|
||
|
||
usdt = balance.get('USDT') or balance.get('usdt') or {}
|
||
if not usdt:
|
||
for currency, asset in balance.items():
|
||
if str(currency).upper() == 'USDT':
|
||
usdt = asset
|
||
break
|
||
|
||
available = self._safe_float(
|
||
usdt.get('available'),
|
||
usdt.get('availableBalance'),
|
||
usdt.get('crossedMaxAvailable'),
|
||
usdt.get('maxTransferOut'),
|
||
usdt.get('free'),
|
||
)
|
||
frozen = self._safe_float(
|
||
usdt.get('frozen'),
|
||
usdt.get('locked'),
|
||
usdt.get('occupied'),
|
||
usdt.get('used'),
|
||
)
|
||
equity = self._safe_float(
|
||
usdt.get('equity'),
|
||
usdt.get('accountEquity'),
|
||
usdt.get('usdtEquity'),
|
||
usdt.get('balance'),
|
||
usdt.get('accountBalance'),
|
||
usdt.get('totalEquity'),
|
||
)
|
||
account_value = equity if equity > 0 else (available + frozen)
|
||
|
||
if available <= 0 and account_value > 0:
|
||
inferred_available = max(account_value - frozen, 0.0)
|
||
if inferred_available > 0:
|
||
logger.warning(
|
||
f"{account_tag} 可用余额字段缺失,使用 account_value - frozen 回退: "
|
||
f"${account_value:.2f} - ${frozen:.2f} = ${inferred_available:.2f}"
|
||
)
|
||
available = inferred_available
|
||
|
||
logger.info(
|
||
f"{account_tag} 账户状态: available=${available:.2f}, "
|
||
f"frozen=${frozen:.2f}, equity=${equity:.2f}, account_value=${account_value:.2f}"
|
||
)
|
||
|
||
return {
|
||
"account_value": account_value,
|
||
"current_balance": 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:
|
||
# 使用 info.available 获取实际持仓量(币数量)
|
||
# 注意:CCXT 的 'contracts' 字段对于 Bitget 实际上已经是币数量,不是张数
|
||
info = pos.get('info', {})
|
||
available = float(info.get('available', 0))
|
||
if available == 0:
|
||
continue
|
||
|
||
symbol_raw = pos.get('symbol', '') # e.g. "BTC/USDT:USDT"
|
||
coin = symbol_raw.split('/')[0] if '/' in symbol_raw else symbol_raw.replace('/USDT:USDT', '')
|
||
|
||
# available 已经是币数量,不需要再乘以 contract_size
|
||
coin_amount = available
|
||
|
||
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
|
||
|
||
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=self.trading_api._with_account_mode_params(
|
||
{
|
||
'hedged': True,
|
||
'marginCoin': 'USDT',
|
||
**({'reduceOnly': True} if reduce_only else {}),
|
||
} if self.trading_api.use_unified_account else {
|
||
'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'
|
||
|
||
if self.trading_api.use_unified_account:
|
||
order_params = {
|
||
'hedged': True,
|
||
'marginCoin': 'USDT',
|
||
}
|
||
if reduce_only:
|
||
order_params['reduceOnly'] = True
|
||
else:
|
||
order_params = {
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
'holdMode': 'oneWay',
|
||
}
|
||
if reduce_only:
|
||
order_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=self.trading_api._with_account_mode_params(order_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, "tp_set": bool, "sl_set": bool, "errors": [...]}
|
||
"""
|
||
try:
|
||
result = self.trading_api.modify_sl_tp(
|
||
symbol=symbol,
|
||
stop_loss=sl_price,
|
||
take_profit=tp_price,
|
||
)
|
||
if result.get("success"):
|
||
logger.info(f"✅ Bitget TP/SL 设置成功: {symbol} TP={tp_price} SL={sl_price}")
|
||
else:
|
||
errors = result.get("errors", [])
|
||
tp_set = result.get("tp_set", False)
|
||
sl_set = result.get("sl_set", False)
|
||
if tp_set or sl_set:
|
||
logger.warning(f"⚠️ Bitget TP/SL 部分成功: {symbol} tp_set={tp_set} sl_set={sl_set} errors={errors}")
|
||
else:
|
||
logger.error(f"❌ Bitget TP/SL 设置失败: {symbol} errors={errors}")
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"❌ Bitget 设置 TP/SL 失败: {symbol} {e}")
|
||
return {"success": False, "tp_set": False, "sl_set": False, "errors": [str(e)]}
|
||
|
||
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', '')
|
||
info = order.get('info') or {}
|
||
price = self._safe_float(
|
||
order.get('price'),
|
||
order.get('stopPrice'),
|
||
order.get('triggerPrice'),
|
||
info.get('triggerPrice'),
|
||
info.get('executePrice'),
|
||
info.get('stopSurplusTriggerPrice'),
|
||
info.get('stopLossTriggerPrice'),
|
||
info.get('takeProfit'),
|
||
info.get('stopLoss'),
|
||
)
|
||
order_type = order.get('type', '')
|
||
plan_type = str(info.get('planType') or info.get('strategyType') or '').lower()
|
||
|
||
take_profit_price = self._safe_float(
|
||
info.get('takeProfit'),
|
||
info.get('tpTriggerPrice'),
|
||
info.get('stopSurplusTriggerPrice'),
|
||
)
|
||
stop_loss_price = self._safe_float(
|
||
info.get('stopLoss'),
|
||
info.get('slTriggerPrice'),
|
||
info.get('stopLossTriggerPrice'),
|
||
)
|
||
|
||
if take_profit_price:
|
||
result['take_profit'] = take_profit_price
|
||
if stop_loss_price:
|
||
result['stop_loss'] = stop_loss_price
|
||
|
||
# 兼容独立 TP/SL 策略单和经典账户 reduce-only 挂单。
|
||
if 'loss' in plan_type or 'stop_loss' in order_type.lower() or 'stop-loss' in order_type.lower():
|
||
result['stop_loss'] = stop_loss_price or price
|
||
elif 'profit' in plan_type or 'take_profit' in order_type.lower() or 'take-profit' in order_type.lower():
|
||
result['take_profit'] = take_profit_price or price
|
||
elif order_type == 'uta_tpsl':
|
||
continue
|
||
elif '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 已是币数量
|
||
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
|
||
|
||
info = order.get('info') or {}
|
||
display_price = self._safe_float(
|
||
order.get('price'),
|
||
order.get('stopPrice'),
|
||
order.get('triggerPrice'),
|
||
info.get('takeProfit'),
|
||
info.get('stopLoss'),
|
||
)
|
||
|
||
result.append({
|
||
"order_id": str(order.get('id', '')),
|
||
"symbol": coin,
|
||
"side": order.get('side', ''),
|
||
"size": size_in_coins,
|
||
"price": display_price,
|
||
"is_reduce_only": bool(order.get('reduceOnly', False)),
|
||
"order_type": order.get('type', ''),
|
||
"take_profit": info.get('takeProfit') or info.get('tpTriggerPrice'),
|
||
"stop_loss": info.get('stopLoss') or info.get('slTriggerPrice'),
|
||
"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:
|
||
open_orders = self.get_open_orders(symbol)
|
||
normalized_order_id = str(order_id)
|
||
matched_order = next((o for o in open_orders if str(o.get('order_id', '')) == normalized_order_id), None)
|
||
if not matched_order:
|
||
logger.info(f"ℹ️ Bitget 挂单已不存在,视为撤单完成: {symbol} #{order_id}")
|
||
return {
|
||
"success": True,
|
||
"order_id": normalized_order_id,
|
||
"symbol": symbol,
|
||
"already_closed": True,
|
||
"message": "订单已不在挂单列表,可能已成交、已撤销或已失效",
|
||
}
|
||
|
||
order_type = str(matched_order.get('order_type') or '').lower()
|
||
if order_type == 'uta_tpsl':
|
||
success = self.trading_api.cancel_strategy_order(symbol=symbol, order_id=order_id)
|
||
else:
|
||
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": normalized_order_id, "symbol": symbol}
|
||
|
||
# 某些场景下,交易所会返回 False,但订单实际上已经成交/撤销/失效。
|
||
# 为了避免误报,再做一次挂单列表确认;若已不在 open orders 中,视为幂等成功。
|
||
refreshed_open_orders = self.get_open_orders(symbol)
|
||
still_exists = any(str(o.get('order_id', '')) == normalized_order_id for o in refreshed_open_orders)
|
||
if not still_exists:
|
||
logger.info(f"ℹ️ Bitget 撤单返回 False,但订单已不在挂单列表,视为完成: {symbol} #{order_id}")
|
||
return {
|
||
"success": True,
|
||
"order_id": normalized_order_id,
|
||
"symbol": symbol,
|
||
"already_closed": True,
|
||
"message": "订单撤单请求后已不在挂单列表,可能已成交、已撤销或已失效",
|
||
}
|
||
|
||
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]:
|
||
"""
|
||
撤销指定币种的所有挂单
|
||
|
||
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) -> bool:
|
||
"""设置杠杆倍数"""
|
||
try:
|
||
return self.trading_api.set_leverage(symbol, leverage)
|
||
except Exception as e:
|
||
logger.warning(f"Bitget 设置杠杆失败: {symbol} {leverage}x: {e}")
|
||
return False
|
||
|
||
def sync_default_leverage(self, symbols: List[str], leverage: Optional[int] = None) -> Dict[str, Any]:
|
||
"""将默认杠杆同步到一组交易对"""
|
||
target_leverage = int(leverage or self.settings.bitget_default_leverage or 10)
|
||
normalized_symbols = []
|
||
for symbol in symbols:
|
||
if not symbol:
|
||
continue
|
||
normalized = symbol.replace("/", "").strip().upper()
|
||
if normalized.endswith("USDT"):
|
||
normalized = normalized[:-4]
|
||
if normalized:
|
||
normalized_symbols.append(normalized)
|
||
|
||
if not normalized_symbols:
|
||
logger.info("Bitget 默认杠杆同步跳过:没有可用交易对")
|
||
return {"success": True, "leverage": target_leverage, "results": {}}
|
||
|
||
deduped_symbols = list(dict.fromkeys(normalized_symbols))
|
||
logger.info(f"开始同步 Bitget 默认杠杆: {target_leverage}x -> {deduped_symbols}")
|
||
results: Dict[str, bool] = {}
|
||
for symbol in deduped_symbols:
|
||
results[symbol] = self.update_leverage(symbol, target_leverage)
|
||
|
||
success = all(results.values()) if results else True
|
||
if success:
|
||
logger.info(f"Bitget 默认杠杆同步完成: {target_leverage}x")
|
||
else:
|
||
failed_symbols = [symbol for symbol, ok in results.items() if not ok]
|
||
logger.warning(f"Bitget 默认杠杆同步存在失败: {failed_symbols}")
|
||
|
||
return {
|
||
"success": success,
|
||
"leverage": target_leverage,
|
||
"results": results,
|
||
}
|
||
|
||
# ==================== 辅助方法 ====================
|
||
|
||
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:
|
||
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 _floor_amount(self, symbol: str, amount: float) -> float:
|
||
"""根据交易对精度向下取整数量(使用 CCXT 内置精度处理)"""
|
||
try:
|
||
ccxt_symbol = self.trading_api._standardize_symbol(symbol)
|
||
return float(self.trading_api.exchange.amount_to_precision(ccxt_symbol, amount))
|
||
except Exception:
|
||
pass
|
||
# fallback: 4 位小数截断
|
||
return math.floor(amount * 10000) / 10000
|
||
|
||
def market_close_position(self, symbol: str) -> Dict[str, Any]:
|
||
"""按交易对市价平仓单个持仓"""
|
||
|
||
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 = self._floor_amount(coin + 'USDT', abs(position['size']))
|
||
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'
|
||
if self.trading_api.use_unified_account:
|
||
close_params = {
|
||
'hedged': True,
|
||
'reduceOnly': True,
|
||
'marginCoin': 'USDT',
|
||
}
|
||
else:
|
||
close_params = {
|
||
'reduceOnly': True,
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
}
|
||
order = self.trading_api.exchange.create_market_order(
|
||
symbol=ccxt_symbol,
|
||
side=side,
|
||
amount=coin_amount,
|
||
params=self.trading_api._with_account_mode_params(close_params)
|
||
)
|
||
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)}
|
||
|
||
|
||
# ==================== 单例工厂 ====================
|
||
|
||
_bitget_live_services: Dict[str, BitgetLiveTradingService] = {}
|
||
|
||
|
||
def get_bitget_live_service(account_id: str = "default") -> Optional[BitgetLiveTradingService]:
|
||
"""
|
||
获取 BitgetLiveTradingService 单例(按账号)。
|
||
|
||
bitget_trading_enabled=False 时返回 None(功能关闭)。
|
||
"""
|
||
global _bitget_live_services
|
||
|
||
settings = get_settings()
|
||
if not settings.bitget_trading_enabled:
|
||
return None
|
||
|
||
normalized_account_id = (account_id or "default").strip() or "default"
|
||
existing = _bitget_live_services.get(normalized_account_id)
|
||
if existing is not None:
|
||
return existing
|
||
|
||
try:
|
||
service = BitgetLiveTradingService(account_id=normalized_account_id)
|
||
_bitget_live_services[normalized_account_id] = service
|
||
return service
|
||
except Exception as e:
|
||
logger.error(f"❌ BitgetLiveTradingService 初始化失败: account={normalized_account_id} error={e}")
|
||
return None
|
||
|
||
|
||
def get_all_bitget_live_services() -> Dict[str, BitgetLiveTradingService]:
|
||
"""获取所有已启用 Bitget 账号的服务实例。"""
|
||
settings = get_settings()
|
||
services: Dict[str, BitgetLiveTradingService] = {}
|
||
for account in settings.get_enabled_bitget_accounts():
|
||
account_id = account.get("account_id") or "default"
|
||
service = get_bitget_live_service(account_id)
|
||
if service:
|
||
services[account_id] = service
|
||
return services
|
||
|
||
|
||
def reset_bitget_live_service(account_id: Optional[str] = None):
|
||
"""重置单例(测试用)"""
|
||
global _bitget_live_services
|
||
|
||
if account_id is None:
|
||
_bitget_live_services = {}
|
||
return
|
||
|
||
normalized_account_id = (account_id or "default").strip() or "default"
|
||
_bitget_live_services.pop(normalized_account_id, None)
|