1004 lines
35 KiB
Python
1004 lines
35 KiB
Python
"""
|
||
Bitget 实盘交易 API (基于 CCXT SDK)
|
||
|
||
处理 Bitget 合约交易的所有 API 调用,包括:
|
||
- 下单(开仓/平仓)
|
||
- 撤单
|
||
- 查询订单
|
||
- 查询持仓
|
||
- 查询账户余额
|
||
- 设置杠杆
|
||
|
||
使用 CCXT 统一交易所接口,提供更稳定的 API 交互。
|
||
"""
|
||
import ccxt
|
||
import math
|
||
from typing import Dict, List, Optional, Any
|
||
from datetime import datetime
|
||
from app.utils.logger import logger
|
||
|
||
|
||
class BitgetTradingAPI:
|
||
"""Bitget 实盘交易 API (基于 CCXT)"""
|
||
|
||
DEFAULT_PRODUCT_TYPE = 'USDT-FUTURES'
|
||
DEFAULT_MARGIN_COIN = 'USDT'
|
||
|
||
# 合约面值表(张 → 币数量),与 bitget_live_trading_service.CONTRACT_SIZES 保持同步
|
||
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,
|
||
}
|
||
|
||
@staticmethod
|
||
def _pick_first_value(entry: Dict[str, Any], *keys: str, default: str = '0') -> str:
|
||
"""兼容 Bitget UTA 不同字段名,返回第一个非空值。"""
|
||
for key in keys:
|
||
value = entry.get(key)
|
||
if value not in (None, ''):
|
||
return str(value)
|
||
return default
|
||
|
||
def __init__(self, api_key: str, api_secret: str, passphrase: str = "", use_testnet: bool = True):
|
||
"""
|
||
初始化 Bitget 交易 API
|
||
|
||
Args:
|
||
api_key: API Key
|
||
api_secret: API Secret
|
||
passphrase: API Passphrase (Bitget 不需要,保留兼容性)
|
||
use_testnet: 是否使用测试网
|
||
"""
|
||
self.api_key = api_key
|
||
self.api_secret = api_secret
|
||
self.use_testnet = use_testnet
|
||
from app.config import get_settings
|
||
self.settings = get_settings()
|
||
self.use_unified_account = getattr(self.settings, 'bitget_use_unified_account', True)
|
||
|
||
# 创建 CCXT Bitget 实例
|
||
config = {
|
||
'apiKey': api_key,
|
||
'secret': api_secret,
|
||
'enableRateLimit': True, # 启用速率限制
|
||
'options': {
|
||
'defaultType': 'swap', # 使用永续合约 API
|
||
}
|
||
}
|
||
|
||
# CCXT Bitget 使用 password 字段存储 passphrase
|
||
# 如果提供了 passphrase,添加到配置中
|
||
if passphrase:
|
||
config['password'] = passphrase
|
||
|
||
self.exchange = ccxt.bitget(config)
|
||
|
||
# 设置测试网/生产网端点
|
||
# Bitget V2 API 使用统一端点 https://api.bitget.com
|
||
# 测试网和生产网使用相同端点,通过 API key 区分
|
||
if use_testnet:
|
||
logger.info("✅ Bitget 测试网模式(使用相同端点,由 API key 区分)")
|
||
|
||
logger.info(f"Bitget 交易 API 初始化完成 ({'测试网' if use_testnet else '生产网'})")
|
||
|
||
# ==================== 订单操作 ====================
|
||
|
||
def place_order(self, symbol: str, side: str, order_type: str,
|
||
size: float, price: float = None, client_order_id: str = None,
|
||
stop_loss: float = None, take_profit: float = None) -> Optional[Dict]:
|
||
"""
|
||
下单
|
||
|
||
Args:
|
||
symbol: 交易对 (如 BTC/USDT:USDT)
|
||
side: 订单方向 (buy/sell)
|
||
order_type: 订单类型 (limit/market)
|
||
size: 数量(张数)
|
||
price: 价格(限价单必需)
|
||
client_order_id: 自定义订单ID
|
||
stop_loss: 止损价格(可选)
|
||
take_profit: 止盈价格(可选)
|
||
|
||
Returns:
|
||
订单信息
|
||
"""
|
||
try:
|
||
# CCXT 标准化交易对格式
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
|
||
# 获取合约面值
|
||
contract_size = self._get_contract_size(symbol)
|
||
|
||
# 计算实际下单数量(张数 × 合约规格)
|
||
# Bitget 的 amount 参数是币的数量,不是张数
|
||
actual_amount = size * contract_size
|
||
|
||
# 构建订单参数
|
||
# 单向持仓模式 + 联合保证金模式
|
||
params = {
|
||
'tdMode': 'cross', # 联合保证金模式(全仓)
|
||
'marginCoin': 'USDT', # 保证金币种
|
||
'holdMode': 'oneWay', # 单向持仓模式
|
||
}
|
||
params = self._with_account_mode_params(params)
|
||
|
||
if client_order_id:
|
||
params['clientOrderId'] = client_order_id
|
||
|
||
# 添加止损止盈参数
|
||
if stop_loss:
|
||
params['stopLoss'] = str(stop_loss)
|
||
if take_profit:
|
||
params['takeProfit'] = str(take_profit)
|
||
|
||
# Bitget 合约交易特殊参数
|
||
params['holdMode'] = 'oneWay' # 单向持仓模式
|
||
|
||
# 调试:打印下单参数
|
||
logger.info(f"下单参数: symbol={ccxt_symbol}, type={order_type}, side={side}, 张数={size}, 实际amount={actual_amount}")
|
||
logger.debug(f"完整参数: {params}")
|
||
|
||
# 下单 - 直接使用通用 create_order 方法
|
||
order = self.exchange.create_order(
|
||
symbol=ccxt_symbol,
|
||
type=order_type,
|
||
side=side,
|
||
amount=actual_amount, # 使用实际币数量(张数 × 合约规格)
|
||
price=price,
|
||
params=params
|
||
)
|
||
|
||
logger.info(f"✅ 下单成功: {symbol} {side} {size}张 @ {price or '市价'}")
|
||
if stop_loss or take_profit:
|
||
logger.info(f" 止损: {stop_loss}, 止盈: {take_profit}")
|
||
logger.debug(f"订单详情: {order}")
|
||
return order
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 下单失败: {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"❌ 下单异常: {e}")
|
||
return None
|
||
|
||
def cancel_order(self, symbol: str, order_id: str = None, client_order_id: str = None) -> bool:
|
||
"""
|
||
撤单
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
order_id: 订单ID
|
||
client_order_id: 自定义订单ID
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
|
||
if order_id:
|
||
self.exchange.cancel_order(order_id, ccxt_symbol, self._with_account_mode_params())
|
||
elif client_order_id:
|
||
self.exchange.cancel_order_by_client_order_id(client_order_id, ccxt_symbol, self._with_account_mode_params())
|
||
else:
|
||
logger.error("必须提供 order_id 或 client_order_id")
|
||
return False
|
||
|
||
logger.info(f"✅ 撤单成功: {symbol} order_id={order_id or client_order_id}")
|
||
return True
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 撤单失败: {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ 撤单异常: {e}")
|
||
return False
|
||
|
||
def cancel_all_orders(self, symbol: str) -> bool:
|
||
"""
|
||
撤销所有挂单
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
self.exchange.cancel_all_orders(ccxt_symbol, self._with_account_mode_params())
|
||
|
||
logger.info(f"✅ 撤销所有挂单成功: {symbol}")
|
||
return True
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 撤销所有挂单失败: {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ 撤销所有挂单异常: {e}")
|
||
return False
|
||
|
||
def close_position(self, symbol: str, side: str = None, size: float = None,
|
||
price: float = None) -> Optional[Dict]:
|
||
"""
|
||
平仓(单向持仓模式)
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
side: 平仓方向(可选,不传则自动判断)
|
||
- None: 自动判断(查询持仓后决定)
|
||
- 'buy': 平空仓
|
||
- 'sell': 平多仓
|
||
size: 平仓数量(不传则全部平仓)- 传张数
|
||
price: 平仓价格(不传则市价)
|
||
|
||
Returns:
|
||
订单信息
|
||
"""
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
|
||
# 获取当前持仓
|
||
positions = self.get_position(symbol)
|
||
if not positions:
|
||
logger.warning(f"没有找到 {symbol} 的持仓")
|
||
return None
|
||
|
||
# 查找有持仓的仓位
|
||
position = None
|
||
for pos in positions:
|
||
# 使用 info.available 字段获取实际持仓量(BTC单位)
|
||
available = float(pos.get('info', {}).get('available', 0))
|
||
if available != 0:
|
||
position = pos
|
||
break
|
||
|
||
if not position:
|
||
logger.warning(f"{symbol} 持仓数量为 0,无需平仓")
|
||
return None
|
||
|
||
# 获取实际持仓量(BTC单位)
|
||
current_size_btc = abs(float(position.get('info', {}).get('available', 0)))
|
||
pos_side = position.get('side') # 'long' or 'short'
|
||
|
||
# 如果没有指定平仓方向,根据持仓方向自动判断
|
||
if side is None:
|
||
# 多仓用 sell 平,空仓用 buy 平
|
||
side = 'sell' if pos_side == 'long' else 'buy'
|
||
|
||
# 如果没有指定平仓数量,则全部平仓
|
||
# 注意:直接使用币数量,不再通过 place_order 转换
|
||
close_size_btc = size * self._get_contract_size(symbol) if size else current_size_btc
|
||
|
||
# 精度处理:从市场信息动态获取精度
|
||
close_size_btc = self._floor_amount(ccxt_symbol, close_size_btc)
|
||
|
||
min_amount = self._get_min_amount(ccxt_symbol)
|
||
if close_size_btc < min_amount:
|
||
logger.warning(f"{symbol} 平仓数量 {close_size_btc} 小于最小交易单位 {min_amount}")
|
||
return None
|
||
|
||
logger.info(f"平仓: {symbol} 持仓方向={pos_side}, 平仓方向={side}, 数量={close_size_btc} BTC")
|
||
|
||
# 直接使用 CCXT 下市价平仓单(绕过 place_order 的张数转换)
|
||
order_type = 'market' if not price else 'limit'
|
||
params = {
|
||
'reduceOnly': True,
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
}
|
||
params = self._with_account_mode_params(params)
|
||
|
||
if price:
|
||
order = self.exchange.create_limit_order(
|
||
symbol=ccxt_symbol,
|
||
side=side,
|
||
amount=close_size_btc,
|
||
price=price,
|
||
params=params
|
||
)
|
||
else:
|
||
order = self.exchange.create_market_order(
|
||
symbol=ccxt_symbol,
|
||
side=side,
|
||
amount=close_size_btc,
|
||
params=params
|
||
)
|
||
|
||
if order:
|
||
logger.info(f"✅ 平仓成功: {symbol} {side} {close_size_btc} BTC")
|
||
return order
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ 平仓异常: {e}")
|
||
return None
|
||
|
||
def set_trailing_stop(self, symbol: str, callback_rate: float = None,
|
||
activation_price: float = None) -> bool:
|
||
"""
|
||
设置移动止损(UTA V3 兼容)
|
||
|
||
通过下一个 trailing_stop 类型的条件单来实现移动止损。
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
callback_rate: 回调比例(如 0.01 表示 1%)
|
||
activation_price: 激活价格
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
|
||
# 获取当前持仓
|
||
positions = self.get_position(symbol)
|
||
if not positions:
|
||
logger.warning(f"没有找到 {symbol} 的持仓")
|
||
return False
|
||
|
||
position = positions[0]
|
||
info = position.get('info', {})
|
||
available = float(info.get('available', 0))
|
||
|
||
if available == 0:
|
||
logger.warning(f"{symbol} 持仓数量为 0")
|
||
return False
|
||
|
||
pos_side = position.get('side', 'long')
|
||
close_side = 'sell' if pos_side == 'long' else 'buy'
|
||
|
||
# 使用 CCXT 统一接口下 trailing stop 条件单
|
||
params = self._with_account_mode_params({
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
'reduceOnly': True,
|
||
'trailingPercent': callback_rate * 100 if callback_rate else None, # CCXT 需要百分比
|
||
})
|
||
|
||
if activation_price:
|
||
params['activationPrice'] = activation_price
|
||
|
||
order = self.exchange.create_order(
|
||
symbol=ccxt_symbol,
|
||
type='trailing_stop_market',
|
||
side=close_side,
|
||
amount=available,
|
||
params=params,
|
||
)
|
||
|
||
logger.info(f"✅ 设置移动止损成功: {symbol} callback={callback_rate}, order={order.get('id')}")
|
||
return True
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 设置移动止损失败: {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ 设置移动止损异常: {e}")
|
||
return False
|
||
|
||
def modify_sl_tp(self, symbol: str, stop_loss: float = None,
|
||
take_profit: float = None) -> bool:
|
||
"""
|
||
修改持仓的止损止盈
|
||
|
||
注意:Bitget 的止损止盈需要在开仓时设置,或者通过下独立的止损/止盈计划订单。
|
||
对于单向持仓模式,我们使用计划订单来实现。
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
stop_loss: 止损价格
|
||
take_profit: 止盈价格
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
|
||
# 获取当前持仓
|
||
positions = self.get_position(symbol)
|
||
if not positions:
|
||
logger.warning(f"没有找到 {symbol} 的持仓")
|
||
return False
|
||
|
||
# 查找有持仓的仓位
|
||
position = None
|
||
for pos in positions:
|
||
if float(pos.get('contracts', 0)) != 0:
|
||
position = pos
|
||
break
|
||
|
||
if not position:
|
||
logger.warning(f"{symbol} 持仓数量为 0")
|
||
return False
|
||
|
||
contracts = float(position.get('contracts', 0))
|
||
pos_side = position.get('side')
|
||
mark_price = float(position.get('markPrice', 0))
|
||
|
||
logger.info(f"当前持仓: {symbol} {pos_side} contracts={contracts}, 标记价={mark_price}")
|
||
|
||
# 验证价格
|
||
if pos_side == 'long':
|
||
if stop_loss and stop_loss >= mark_price:
|
||
logger.error(f"做多止损必须低于当前价格: SL={stop_loss}, Mark={mark_price}")
|
||
return False
|
||
if take_profit and take_profit <= mark_price:
|
||
logger.error(f"做多止盈必须高于当前价格: TP={take_profit}, Mark={mark_price}")
|
||
return False
|
||
else:
|
||
if stop_loss and stop_loss <= mark_price:
|
||
logger.error(f"做空止损必须高于当前价格: SL={stop_loss}, Mark={mark_price}")
|
||
return False
|
||
if take_profit and take_profit >= mark_price:
|
||
logger.error(f"做空止盈必须低于当前价格: TP={take_profit}, Mark={mark_price}")
|
||
return False
|
||
|
||
# 使用独立的止损/止盈计划订单
|
||
# 注意:这种方式需要在平仓时也取消这些计划订单
|
||
|
||
# CCXT 的 contracts 字段对于 Bitget 实际上已经是币数量
|
||
btc_amount = abs(contracts)
|
||
|
||
# 精度处理:使用动态精度
|
||
btc_amount = self._floor_amount(ccxt_symbol, btc_amount)
|
||
|
||
orders_created = []
|
||
|
||
# 止损单
|
||
if stop_loss:
|
||
sl_side = 'sell' if pos_side == 'long' else 'buy'
|
||
try:
|
||
# 使用普通的 create_order 创建止损市价单
|
||
sl_order = self.exchange.create_order(
|
||
symbol=ccxt_symbol,
|
||
type='stop_market',
|
||
side=sl_side,
|
||
amount=btc_amount,
|
||
price=None,
|
||
params={
|
||
'stopPrice': stop_loss,
|
||
'triggerBy': 'mark_price',
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
'reduceOnly': True, # 只平仓
|
||
**self._with_account_mode_params(),
|
||
}
|
||
)
|
||
orders_created.append(('止损', sl_order))
|
||
logger.info(f"✅ 止损单已下: {sl_side} {btc_amount} BTC @ ${stop_loss}")
|
||
except Exception as e:
|
||
logger.warning(f"下止损单失败: {e}")
|
||
|
||
# 止盈单
|
||
if take_profit:
|
||
tp_side = 'sell' if pos_side == 'long' else 'buy'
|
||
try:
|
||
# 使用普通的 create_order 创建止盈限价单
|
||
tp_order = self.exchange.create_order(
|
||
symbol=ccxt_symbol,
|
||
type='limit',
|
||
side=tp_side,
|
||
amount=btc_amount,
|
||
price=take_profit,
|
||
params={
|
||
'tdMode': 'cross',
|
||
'marginCoin': 'USDT',
|
||
'reduceOnly': True, # 只平仓
|
||
**self._with_account_mode_params(),
|
||
}
|
||
)
|
||
orders_created.append(('止盈', tp_order))
|
||
logger.info(f"✅ 止盈单已下: {tp_side} {btc_amount} BTC @ ${take_profit}")
|
||
except Exception as e:
|
||
logger.warning(f"下止盈单失败: {e}")
|
||
|
||
if orders_created:
|
||
logger.info(f"✅ 止损止盈设置完成: SL={stop_loss}, TP={take_profit}")
|
||
logger.info(f" 注意: 止损止盈以独立订单形式存在,平仓时需要同时取消")
|
||
return True
|
||
else:
|
||
logger.warning(f"⚠️ 未能成功下任何止损止盈单")
|
||
return False
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 修改止损止盈失败: {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ 修改止损止盈异常: {e}")
|
||
import traceback
|
||
logger.debug(traceback.format_exc())
|
||
return False
|
||
|
||
# ==================== 查询操作 ====================
|
||
|
||
def get_order(self, symbol: str, order_id: str = None, client_order_id: str = None) -> Optional[Dict]:
|
||
"""
|
||
查询订单
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
order_id: 订单ID
|
||
client_order_id: 自定义订单ID
|
||
|
||
Returns:
|
||
订单信息
|
||
"""
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
|
||
if order_id:
|
||
order = self.exchange.fetch_order(order_id, ccxt_symbol, self._with_account_mode_params())
|
||
elif client_order_id:
|
||
order = self.exchange.fetch_order_by_client_order_id(client_order_id, ccxt_symbol, self._with_account_mode_params())
|
||
else:
|
||
logger.error("必须提供 order_id 或 client_order_id")
|
||
return None
|
||
|
||
return order
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 查询订单失败: {e}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"❌ 查询订单异常: {e}")
|
||
return None
|
||
|
||
def get_open_orders(self, symbol: str = None) -> List[Dict]:
|
||
"""
|
||
查询当前挂单
|
||
|
||
Args:
|
||
symbol: 交易对(不传则查询所有)
|
||
|
||
Returns:
|
||
挂单列表
|
||
"""
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol) if symbol else None
|
||
orders = self.exchange.fetch_open_orders(ccxt_symbol, None, None, self._with_account_mode_params())
|
||
|
||
logger.debug(f"查询到 {len(orders)} 个挂单")
|
||
return orders
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 查询挂单失败: {e}")
|
||
return []
|
||
except Exception as e:
|
||
logger.error(f"❌ 查询挂单异常: {e}")
|
||
return []
|
||
|
||
def get_history_orders(self, symbol: str, limit: int = 100) -> List[Dict]:
|
||
"""
|
||
查询历史订单
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
limit: 返回数量
|
||
|
||
Returns:
|
||
历史订单列表
|
||
"""
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
orders = self.exchange.fetch_my_trades(ccxt_symbol, limit=limit, params=self._with_account_mode_params())
|
||
|
||
logger.debug(f"查询到 {len(orders)} 条历史订单")
|
||
return orders
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 查询历史订单失败: {e}")
|
||
return []
|
||
except Exception as e:
|
||
logger.error(f"❌ 查询历史订单异常: {e}")
|
||
return []
|
||
|
||
# ==================== 持仓操作 ====================
|
||
|
||
def get_position(self, symbol: str = None) -> List[Dict]:
|
||
"""
|
||
查询持仓
|
||
|
||
Args:
|
||
symbol: 交易对(不传则查询所有)
|
||
|
||
Returns:
|
||
持仓列表
|
||
"""
|
||
try:
|
||
# 获取所有持仓
|
||
positions = self.exchange.fetch_positions(None, self._with_account_mode_params())
|
||
|
||
# 筛选非零持仓
|
||
active_positions = []
|
||
for pos in positions:
|
||
size = float(pos.get('contracts', 0))
|
||
if size != 0:
|
||
# 如果指定了交易对,进行过滤
|
||
if symbol:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
if pos['symbol'] == ccxt_symbol:
|
||
active_positions.append(pos)
|
||
else:
|
||
active_positions.append(pos)
|
||
|
||
logger.debug(f"查询到 {len(active_positions)} 个持仓")
|
||
return active_positions
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 查询持仓失败: {e}")
|
||
return []
|
||
except Exception as e:
|
||
logger.error(f"❌ 查询持仓异常: {e}")
|
||
return []
|
||
|
||
def get_single_position(self, symbol: str) -> Optional[Dict]:
|
||
"""
|
||
查询单个交易对的持仓
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
|
||
Returns:
|
||
持仓信息
|
||
"""
|
||
positions = self.get_position(symbol)
|
||
if positions:
|
||
return positions[0]
|
||
return None
|
||
|
||
def get_closed_orders(self, symbol: str = None, limit: int = 100) -> List[Dict]:
|
||
"""
|
||
查询历史成交订单(从交易所获取)
|
||
|
||
Args:
|
||
symbol: 交易对(不传则查询所有)
|
||
limit: 返回数量
|
||
|
||
Returns:
|
||
历史订单列表
|
||
"""
|
||
try:
|
||
# 使用 CCXT 的 fetch_my_trades 获取历史成交记录(包含盈亏信息)
|
||
if symbol:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
trades = self.exchange.fetch_my_trades(ccxt_symbol, limit=limit)
|
||
else:
|
||
trades = self.exchange.fetch_my_trades(limit=limit)
|
||
|
||
logger.debug(f"查询到 {len(trades)} 条历史成交记录")
|
||
return trades
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 查询历史成交失败: {e}")
|
||
return []
|
||
except Exception as e:
|
||
logger.error(f"❌ 查询历史成交异常: {e}")
|
||
return []
|
||
|
||
# ==================== 账户操作 ====================
|
||
|
||
def get_balance(self) -> Dict:
|
||
"""
|
||
查询账户余额
|
||
|
||
Returns:
|
||
余额信息 {USDT: {available: "...", frozen: "...", locked: "...", equity: "..."}}
|
||
|
||
注意:Bitget UTA(统一账户)模式下,ccxt fetch_balance() 会报错
|
||
"Classic Account API is not supported",必须使用 privateUtaGetV3AccountAssets。
|
||
"""
|
||
try:
|
||
if self.use_unified_account:
|
||
response = self.exchange.privateUtaGetV3AccountAssets({
|
||
'coin': self.DEFAULT_MARGIN_COIN,
|
||
})
|
||
data = response.get('data', {})
|
||
if isinstance(data, list):
|
||
assets = data
|
||
else:
|
||
assets = data.get('assets') or data.get('assetList') or data.get('list') or []
|
||
result = {}
|
||
for entry in assets:
|
||
currency = entry.get('coin') or entry.get('marginCoin') or entry.get('asset')
|
||
if not currency:
|
||
continue
|
||
currency = str(currency).upper()
|
||
result[currency] = {
|
||
'available': self._pick_first_value(
|
||
entry,
|
||
'available',
|
||
'availableBalance',
|
||
'crossedMaxAvailable',
|
||
'maxTransferOut',
|
||
'free',
|
||
),
|
||
'frozen': self._pick_first_value(
|
||
entry,
|
||
'locked',
|
||
'frozen',
|
||
'occupied',
|
||
'used',
|
||
),
|
||
'locked': self._pick_first_value(
|
||
entry,
|
||
'locked',
|
||
'frozen',
|
||
'occupied',
|
||
'used',
|
||
),
|
||
'equity': self._pick_first_value(
|
||
entry,
|
||
'equity',
|
||
'accountEquity',
|
||
'usdtEquity',
|
||
'balance',
|
||
'accountBalance',
|
||
'totalEquity',
|
||
),
|
||
}
|
||
logger.debug(f"[UTA] 账户余额: {result}")
|
||
return result
|
||
|
||
response = self.exchange.privateMixGetV2MixAccountAccounts({
|
||
'productType': self.DEFAULT_PRODUCT_TYPE,
|
||
})
|
||
result = {}
|
||
for entry in response.get('data', []) or []:
|
||
currency = entry.get('marginCoin')
|
||
if not currency:
|
||
continue
|
||
result[currency] = {
|
||
'available': str(entry.get('available', '0')),
|
||
'frozen': str(entry.get('locked', '0')),
|
||
'locked': str(entry.get('locked', '0')),
|
||
'equity': str(entry.get('accountEquity', entry.get('usdtEquity', '0'))),
|
||
}
|
||
logger.debug(f"[合约] 账户余额: {result}")
|
||
return result
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 查询余额失败: {e}")
|
||
return {}
|
||
except Exception as e:
|
||
logger.error(f"❌ 查询余额异常: {e}")
|
||
return {}
|
||
|
||
def get_account_info(self) -> Dict:
|
||
"""
|
||
查询账户信息
|
||
|
||
Returns:
|
||
账户信息
|
||
"""
|
||
try:
|
||
if self.use_unified_account:
|
||
return self.exchange.privateUtaGetV3AccountAssets({
|
||
'coin': self.DEFAULT_MARGIN_COIN,
|
||
})
|
||
return self.exchange.privateMixGetV2MixAccountAccounts({
|
||
'productType': self.DEFAULT_PRODUCT_TYPE,
|
||
})
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 查询账户信息失败: {e}")
|
||
return {}
|
||
except Exception as e:
|
||
logger.error(f"❌ 查询账户信息异常: {e}")
|
||
return {}
|
||
|
||
def set_leverage(self, symbol: str, leverage: int, hold_side: str = "long") -> bool:
|
||
"""
|
||
设置杠杆倍数
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
leverage: 杠杆倍数 (1-125)
|
||
hold_side: 持仓方向 (long/short) - CCXT 通常设置为 both
|
||
|
||
Returns:
|
||
是否成功
|
||
"""
|
||
try:
|
||
if self.use_unified_account:
|
||
response = self.exchange.privateUtaPostV3AccountSetLeverage({
|
||
'symbol': self._to_contract_symbol_id(symbol),
|
||
'coin': self.DEFAULT_MARGIN_COIN,
|
||
'category': self.DEFAULT_PRODUCT_TYPE,
|
||
'leverage': str(leverage),
|
||
})
|
||
else:
|
||
response = self.exchange.privateMixPostV2MixAccountSetLeverage({
|
||
'symbol': self._to_contract_symbol_id(symbol),
|
||
'marginCoin': self.DEFAULT_MARGIN_COIN,
|
||
'productType': self.DEFAULT_PRODUCT_TYPE,
|
||
'leverage': str(leverage),
|
||
})
|
||
|
||
if response.get('code') != '00000':
|
||
logger.error(f"❌ 设置杠杆失败: {response}")
|
||
return False
|
||
|
||
logger.info(f"✅ 设置杠杆成功: {symbol} {leverage}x")
|
||
return True
|
||
|
||
except ccxt.BaseError as e:
|
||
logger.error(f"❌ 设置杠杆失败: {e}")
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ 设置杠杆异常: {e}")
|
||
return False
|
||
|
||
# ==================== 辅助方法 ====================
|
||
|
||
def _standardize_symbol(self, symbol: str) -> str:
|
||
"""
|
||
标准化交易对格式为 CCXT 格式
|
||
|
||
Args:
|
||
symbol: 原始交易对 (如 BTCUSDT 或 BTC)
|
||
|
||
Returns:
|
||
CCXT 标准格式 (如 BTC/USDT:USDT)
|
||
"""
|
||
# 如果已经是 CCXT 格式,直接返回
|
||
if '/' in symbol:
|
||
return symbol
|
||
|
||
# 如果以 USDT 结尾,去掉 USDT 后重组
|
||
# 例如:BTCUSDT -> BTC/USDT:USDT
|
||
if symbol.endswith('USDT'):
|
||
base = symbol[:-4] # 去掉 USDT
|
||
return f"{base}/USDT:USDT"
|
||
|
||
# 如果是纯币种(如 BTC、ETH、SOL),直接添加后缀
|
||
# 例如:BTC -> BTC/USDT:USDT
|
||
return f"{symbol}/USDT:USDT"
|
||
|
||
def _to_contract_symbol_id(self, symbol: str) -> str:
|
||
"""转成 Bitget U 本位合约 symbol id,例如 BTCUSDT"""
|
||
normalized = symbol.strip().upper()
|
||
if '/' in normalized:
|
||
base = normalized.split('/')[0]
|
||
return f"{base}USDT"
|
||
if ':' in normalized:
|
||
normalized = normalized.split(':')[0]
|
||
return normalized if normalized.endswith('USDT') else f"{normalized}USDT"
|
||
|
||
def _with_account_mode_params(self, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
merged = dict(params or {})
|
||
if self.use_unified_account:
|
||
merged['uta'] = True
|
||
return merged
|
||
|
||
def _get_contract_size(self, symbol: str) -> float:
|
||
"""
|
||
获取合约面值(每张合约对应的币数量)
|
||
|
||
优先从硬编码表获取,不存在则查询 CCXT 市场信息。
|
||
|
||
Args:
|
||
symbol: 交易对
|
||
|
||
Returns:
|
||
合约面值
|
||
"""
|
||
# 提取纯币种名
|
||
coin = symbol.replace('USDT', '').replace('/USDT:USDT', '').replace('/', '').upper()
|
||
if coin in self.CONTRACT_SIZES:
|
||
return self.CONTRACT_SIZES[coin]
|
||
|
||
# fallback: 从 CCXT market info 获取
|
||
try:
|
||
ccxt_symbol = self._standardize_symbol(symbol)
|
||
market = self.exchange.market(ccxt_symbol)
|
||
size = float(market.get('contractSize', 1) or 1)
|
||
logger.info(f"从市场信息获取合约面值: {coin} = {size}")
|
||
return size
|
||
except Exception:
|
||
logger.warning(f"无法获取 {coin} 合约面值,使用默认值 1")
|
||
return 1.0
|
||
|
||
def _floor_amount(self, ccxt_symbol: str, amount: float) -> float:
|
||
"""根据交易对精度向下取整数量"""
|
||
try:
|
||
market = self.exchange.market(ccxt_symbol)
|
||
precision = market.get('precision', {}).get('amount')
|
||
if precision and precision > 0:
|
||
factor = 10 ** precision
|
||
return math.floor(amount * factor) / factor
|
||
except Exception:
|
||
pass
|
||
return math.floor(amount * 10000) / 10000
|
||
|
||
def _get_min_amount(self, ccxt_symbol: str) -> float:
|
||
"""获取交易对最小下单数量"""
|
||
try:
|
||
market = self.exchange.market(ccxt_symbol)
|
||
limits = market.get('limits', {}).get('amount', {})
|
||
min_amount = limits.get('min')
|
||
if min_amount:
|
||
return float(min_amount)
|
||
except Exception:
|
||
pass
|
||
return 0.0001
|
||
|
||
def test_connection(self) -> bool:
|
||
"""
|
||
测试 API 连接
|
||
|
||
Returns:
|
||
是否连接成功
|
||
"""
|
||
try:
|
||
balance = self.get_balance()
|
||
if balance is not None:
|
||
usdt_balance = balance.get('USDT', {}).get('available', 'N/A')
|
||
logger.info(f"✅ API 连接成功,USDT 余额: {usdt_balance}")
|
||
return True
|
||
return False
|
||
except Exception as e:
|
||
logger.error(f"❌ API 连接失败: {e}")
|
||
return False
|
||
|
||
def close(self):
|
||
"""关闭连接"""
|
||
if self.exchange:
|
||
# CCXT exchange 对象不需要显式关闭
|
||
# 但可以清理 WebSocket 连接(如果有的话)
|
||
try:
|
||
if hasattr(self.exchange, 'close'):
|
||
self.exchange.close()
|
||
except Exception as e:
|
||
logger.debug(f"关闭连接时出错(可忽略): {e}")
|
||
logger.info("Bitget API 连接已关闭")
|
||
|
||
|
||
# 全局实例(延迟初始化)
|
||
_trading_api: Optional[BitgetTradingAPI] = None
|
||
|
||
|
||
def get_bitget_trading_api() -> Optional[BitgetTradingAPI]:
|
||
"""
|
||
获取 Bitget 交易 API 实例(单例)
|
||
|
||
Returns:
|
||
BitgetTradingAPI 实例或 None(如果未配置)
|
||
"""
|
||
global _trading_api
|
||
|
||
if _trading_api:
|
||
return _trading_api
|
||
|
||
from app.config import get_settings
|
||
|
||
settings = get_settings()
|
||
|
||
# 检查是否配置了 API Key
|
||
if not settings.bitget_api_key or not settings.bitget_api_secret:
|
||
logger.warning("Bitget API Key 未配置<E9858D><E7BDAE>实盘交易功能不可用")
|
||
return None
|
||
|
||
# 创建实例
|
||
_trading_api = BitgetTradingAPI(
|
||
api_key=settings.bitget_api_key,
|
||
api_secret=settings.bitget_api_secret,
|
||
passphrase=settings.bitget_passphrase,
|
||
use_testnet=settings.bitget_use_testnet
|
||
)
|
||
|
||
return _trading_api
|
||
|
||
|
||
def reset_bitget_trading_api():
|
||
"""重置全局实例(用于测试或配置更新)"""
|
||
global _trading_api
|
||
if _trading_api:
|
||
_trading_api.close()
|
||
_trading_api = None
|
||
logger.info("Bitget API 实例已重置")
|