Signed-off-by: Dinger <quantdinger@gmail.com>
This commit is contained in:
Dinger
2026-04-06 01:39:25 +08:00
parent 15c901364b
commit 3ca291a346
214 changed files with 2771 additions and 8535 deletions
File diff suppressed because it is too large Load Diff
@@ -19,13 +19,14 @@ from app.services.live_trading.symbols import to_binance_futures_symbol
class BinanceFuturesClient(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0, broker_id: str = ""):
if not base_url:
base_url = "https://demo-fapi.binance.com" if enable_demo_trading else "https://fapi.binance.com"
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.broker_id = (broker_id or "").strip()
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Binance api_key/secret_key")
@@ -162,6 +163,21 @@ class BinanceFuturesClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
if not raw:
return ""
if not broker_id:
return raw[:36]
prefix = f"x-{broker_id}"
if raw.startswith(prefix):
return raw[:36]
suffix_budget = max(0, 36 - len(prefix))
if suffix_budget <= 0:
return prefix[:36]
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
# Use server-accepted timestamp in ms.
@@ -649,8 +665,9 @@ class BinanceFuturesClient(BaseRestClient):
}
if reduce_only:
params["reduceOnly"] = "true"
if client_order_id:
params["newClientOrderId"] = str(client_order_id)
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
# Hedge mode requires explicit positionSide (LONG/SHORT). One-way mode should not use LONG/SHORT.
dual_side = self.get_dual_side_position()
@@ -787,8 +804,9 @@ class BinanceFuturesClient(BaseRestClient):
}
if reduce_only:
params["reduceOnly"] = "true"
if client_order_id:
params["newClientOrderId"] = str(client_order_id)
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
dual_side = self.get_dual_side_position()
pos_norm = self._normalize_position_side(position_side)
@@ -16,13 +16,14 @@ from app.services.live_trading.symbols import to_binance_futures_symbol
class BinanceSpotClient(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = None, enable_demo_trading: bool = False, timeout_sec: float = 15.0, broker_id: str = ""):
if not base_url:
base_url = "https://demo-api.binance.com" if enable_demo_trading else "https://api.binance.com"
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.broker_id = (broker_id or "").strip()
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Binance api_key/secret_key")
@@ -157,6 +158,21 @@ class BinanceSpotClient(BaseRestClient):
def _signed_headers(self) -> Dict[str, str]:
return {"X-MBX-APIKEY": self.api_key}
def _format_client_order_id(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
broker_id = str(self.broker_id or "").strip()
if not raw:
return ""
if not broker_id:
return raw[:36]
prefix = f"x-{broker_id}"
if raw.startswith(prefix):
return raw[:36]
suffix_budget = max(0, 36 - len(prefix))
if suffix_budget <= 0:
return prefix[:36]
return f"{prefix}{raw[:suffix_budget]}"
def _signed_request(self, method: str, path: str, *, params: Dict[str, Any]) -> Dict[str, Any]:
p = dict(params or {})
p["timestamp"] = int(time.time() * 1000)
@@ -382,8 +398,9 @@ class BinanceSpotClient(BaseRestClient):
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
"price": self._dec_str(px_dec),
}
if client_order_id:
params["newClientOrderId"] = str(client_order_id)
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
try:
raw = self._signed_request("POST", "/api/v3/order", params=params)
except LiveTradingError as e:
@@ -423,8 +440,9 @@ class BinanceSpotClient(BaseRestClient):
"type": "MARKET",
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
}
if client_order_id:
params["newClientOrderId"] = str(client_order_id)
client_order_id_norm = self._format_client_order_id(client_order_id)
if client_order_id_norm:
params["newClientOrderId"] = client_order_id_norm
try:
raw = self._signed_request("POST", "/api/v3/order", params=params)
except LiveTradingError as e:
@@ -40,12 +40,14 @@ class BitgetMixClient(BaseRestClient):
base_url: str = "https://api.bitget.com",
timeout_sec: float = 15.0,
channel_api_code: str = "qvz9x",
simulated_trading: bool = False,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip()
self.channel_api_code = (channel_api_code or "").strip()
self.simulated_trading = bool(simulated_trading)
if not self.api_key or not self.secret_key or not self.passphrase:
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
@@ -193,6 +195,8 @@ class BitgetMixClient(BaseRestClient):
"ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
}
if self.simulated_trading:
headers["PAPTRADING"] = "1"
clean_path = str(request_path or "").split("?", 1)[0]
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
headers["X-CHANNEL-API-CODE"] = self.channel_api_code
@@ -41,12 +41,14 @@ class BitgetSpotClient(BaseRestClient):
base_url: str = "https://api.bitget.com",
timeout_sec: float = 15.0,
channel_api_code: str = "qvz9x",
simulated_trading: bool = False,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.passphrase = (passphrase or "").strip()
self.channel_api_code = (channel_api_code or "").strip()
self.simulated_trading = bool(simulated_trading)
if not self.api_key or not self.secret_key or not self.passphrase:
raise LiveTradingError("Missing Bitget api_key/secret_key/passphrase")
@@ -167,6 +169,8 @@ class BitgetSpotClient(BaseRestClient):
"ACCESS-PASSPHRASE": self.passphrase,
"Content-Type": "application/json",
}
if self.simulated_trading:
h["PAPTRADING"] = "1"
clean_path = str(request_path or "").split("?", 1)[0]
if self.channel_api_code and clean_path in self._CHANNEL_API_CODE_ORDER_PATHS:
h["X-CHANNEL-API-CODE"] = self.channel_api_code
@@ -474,4 +478,14 @@ class BitgetSpotClient(BaseRestClient):
"""
return self._signed_request("GET", "/api/v2/spot/account/assets")
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
sym = to_bitget_um_symbol(symbol)
raw = self._public_request("GET", "/api/v2/spot/market/tickers", params={"symbol": sym})
data = raw.get("data") if isinstance(raw, dict) else None
if isinstance(data, list) and data and isinstance(data[0], dict):
return data[0]
if isinstance(data, dict):
return data
return {}
@@ -33,12 +33,15 @@ class BybitClient(BaseRestClient):
timeout_sec: float = 15.0,
category: str = "linear", # "linear" (USDT perpetual) or "spot"
recv_window_ms: int = 5000,
broker_referer: str = "",
hedge_mode: bool = False,
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.category = (category or "linear").strip().lower()
self.broker_referer = self._DEFAULT_BROKER_REFERER
self.broker_referer = (broker_referer or self._DEFAULT_BROKER_REFERER).strip()
self.hedge_mode = bool(hedge_mode)
if self.category not in ("linear", "spot"):
self.category = "linear"
try:
@@ -158,8 +161,9 @@ class BybitClient(BaseRestClient):
def _sign(self, prehash: str) -> str:
return hmac.new(self.secret_key.encode("utf-8"), prehash.encode("utf-8"), hashlib.sha256).hexdigest()
@staticmethod
def _resolve_position_idx(pos_side: str) -> Optional[int]:
def _resolve_position_idx(self, pos_side: str) -> Optional[int]:
if not self.hedge_mode:
return None
ps = str(pos_side or "").strip().lower()
if ps == "long":
return 1
@@ -2,7 +2,7 @@
Translate a strategy signal into a direct-exchange order call.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin, HTX
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
@@ -29,6 +29,9 @@ from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativ
# Lazy import Deepcoin
DeepcoinClient = None
# Lazy import HTX
HtxClient = None
# Lazy import IBKR
IBKRClient = None
@@ -98,6 +101,26 @@ def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
raise LiveTradingError(f"Unsupported signal_type: {signal_type}")
def _quote_amount_from_base_qty(client: BaseRestClient, *, symbol: str, base_qty: float) -> float:
if float(base_qty or 0.0) <= 0:
return 0.0
if not hasattr(client, "get_ticker"):
return float(base_qty or 0.0)
try:
ticker = client.get_ticker(symbol=symbol)
except Exception:
return float(base_qty or 0.0)
if not isinstance(ticker, dict):
return float(base_qty or 0.0)
try:
price = float(ticker.get("last") or ticker.get("lastPr") or ticker.get("lastPrice") or ticker.get("price") or 0.0)
except Exception:
price = 0.0
if price <= 0:
return float(base_qty or 0.0)
return float(base_qty or 0.0) * price
def place_order_from_signal(
client: BaseRestClient,
*,
@@ -171,11 +194,13 @@ def place_order_from_signal(
client_order_id=client_order_id,
)
if isinstance(client, BitgetSpotClient):
# For spot market BUY, Bitget may expect quote size; we pass base size here and let caller override if needed.
spot_size = qty
if side == "buy":
spot_size = _quote_amount_from_base_qty(client, symbol=symbol, base_qty=qty)
return client.place_market_order(
symbol=symbol,
side=side,
size=qty,
size=spot_size,
client_order_id=client_order_id,
)
if isinstance(client, BybitClient):
@@ -192,12 +217,19 @@ def place_order_from_signal(
if isinstance(client, KrakenClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
if isinstance(client, KucoinSpotClient):
# KuCoin market BUY often requires quote funds; this simplified path does not convert.
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id, quote_size=False)
quote_size = False
kucoin_size = qty
if side == "buy":
kucoin_size = _quote_amount_from_base_qty(client, symbol=symbol, base_qty=qty)
quote_size = kucoin_size > 0 and kucoin_size != qty
return client.place_market_order(symbol=symbol, side=side, size=kucoin_size, client_order_id=client_order_id, quote_size=quote_size)
if isinstance(client, KucoinFuturesClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
if isinstance(client, GateSpotClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
gate_size = qty
if side == "buy":
gate_size = _quote_amount_from_base_qty(client, symbol=symbol, base_qty=qty)
return client.place_market_order(symbol=symbol, side=side, size=gate_size, client_order_id=client_order_id)
if isinstance(client, GateUsdtFuturesClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
if isinstance(client, BitfinexClient):
@@ -226,6 +258,24 @@ def place_order_from_signal(
client_order_id=client_order_id,
)
global HtxClient
if HtxClient is None:
try:
from app.services.live_trading.htx import HtxClient as _HtxClient
HtxClient = _HtxClient
except ImportError:
pass
if HtxClient is not None and isinstance(client, HtxClient):
return client.place_market_order(
symbol=symbol,
side=side,
qty=qty,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=client_order_id,
)
# Check for IBKR client (lazy import to avoid circular dependency)
global IBKRClient
if IBKRClient is None:
@@ -2,7 +2,7 @@
Factory for direct exchange clients.
Supports:
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin, HTX
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
- Forex brokers: MetaTrader 5 (MT5)
"""
@@ -25,6 +25,7 @@ from app.services.live_trading.kucoin import KucoinSpotClient, KucoinFuturesClie
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.htx import HtxClient
# Lazy import IBKR to avoid ImportError if ib_insync not installed
IBKRClient = None
@@ -68,14 +69,16 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
is_demo = _demo_enabled(exchange_config)
if exchange_id == "binance":
spot_broker_id = _get(exchange_config, "spot_broker_id", "spotBrokerId", "broker_id", "brokerId") or "A2NAPZAC"
futures_broker_id = _get(exchange_config, "futures_broker_id", "futuresBrokerId", "broker_id", "brokerId") or "HBpUbQjT"
if mt == "spot":
default_url = "https://demo-api.binance.com" if is_demo else "https://api.binance.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_url
return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo)
return BinanceSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo, broker_id=spot_broker_id)
# Default to USDT-M futures
default_url = "https://demo-fapi.binance.com" if is_demo else "https://fapi.binance.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_url
return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo)
return BinanceFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo, broker_id=futures_broker_id)
if exchange_id == "okx":
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://www.okx.com"
broker_code = "56fa80b0ce8cBCDE"
@@ -92,21 +95,48 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com"
if mt == "spot":
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
return BitgetSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
return BitgetSpotClient(
api_key=api_key,
secret_key=secret_key,
passphrase=passphrase,
base_url=base_url,
channel_api_code=channel_api_code,
simulated_trading=is_demo,
)
channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x"
return BitgetMixClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code)
return BitgetMixClient(
api_key=api_key,
secret_key=secret_key,
passphrase=passphrase,
base_url=base_url,
channel_api_code=channel_api_code,
simulated_trading=is_demo,
)
if exchange_id == "bybit":
default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit
category = "spot" if mt == "spot" else "linear"
recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 5000)
broker_referer = _get(exchange_config, "bybit_referer", "broker_referer", "brokerReferer") or "Ri001020"
hedge_mode_raw = exchange_config.get("hedge_mode")
if hedge_mode_raw is None:
hedge_mode_raw = exchange_config.get("hedgeMode")
if hedge_mode_raw is None:
hedge_mode_raw = exchange_config.get("position_mode") or exchange_config.get("positionMode")
hedge_mode = False
if isinstance(hedge_mode_raw, bool):
hedge_mode = hedge_mode_raw
else:
hedge_mode = str(hedge_mode_raw or "").strip().lower() in ("true", "1", "yes", "hedge", "both_side")
return BybitClient(
api_key=api_key,
secret_key=secret_key,
base_url=base_url,
category=category,
recv_window_ms=recv_window_ms,
broker_referer=broker_referer,
hedge_mode=hedge_mode,
)
if exchange_id in ("coinbaseexchange", "coinbase_exchange"):
@@ -135,13 +165,14 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
return KucoinFuturesClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=fut_url)
if exchange_id == "gate":
gate_channel_id = _get(exchange_config, "gate_channel_id", "gateChannelId") or "dinger"
if mt == "spot":
default_gate = "https://api-testnet.gateio.ws" if is_demo else "https://api.gateio.ws"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_gate
return GateSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
default_fut = "https://fx-api-testnet.gateio.ws" if is_demo else "https://api.gateio.ws"
return GateSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id)
default_fut = "https://fx-api-testnet.gateio.ws" if is_demo else "https://fx-api.gateio.ws"
base_url = _get(exchange_config, "base_url", "baseUrl") or default_fut
return GateUsdtFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
return GateUsdtFuturesClient(api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id)
if exchange_id == "bitfinex":
# Same REST host; use keys from Bitfinex paper/sub-account where applicable.
@@ -151,6 +182,8 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
return BitfinexDerivativesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
if exchange_id == "deepcoin":
if is_demo and not (_get(exchange_config, "base_url", "baseUrl")):
raise LiveTradingError("Deepcoin demo/testnet is not configured in this project yet. Please disable demo mode or provide an explicit testnet base_url.")
base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.deepcoin.com"
return DeepcoinClient(
api_key=api_key,
@@ -160,6 +193,21 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
market_type=mt,
)
if exchange_id == "htx":
if is_demo and not (_get(exchange_config, "base_url", "baseUrl") or _get(exchange_config, "futures_base_url", "futuresBaseUrl")):
raise LiveTradingError("HTX demo/testnet is not configured in this project yet. Please disable demo mode or provide explicit testnet base_url/futures_base_url.")
spot_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.huobi.pro"
futures_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or "https://api.hbdm.com"
broker_id = _get(exchange_config, "broker_id", "brokerId") or "AA7b890547"
return HtxClient(
api_key=api_key,
secret_key=secret_key,
base_url=spot_url,
futures_base_url=futures_url,
market_type=mt,
broker_id=broker_id,
)
# Traditional brokers (IBKR for US stocks only)
if exchange_id == "ibkr":
# Note: Market category validation should be done at the caller level
@@ -25,10 +25,11 @@ from app.services.live_trading.symbols import to_gate_currency_pair
class _GateBase(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.channel_id = (channel_id or "").strip()
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Gate api_key/secret_key")
@@ -37,7 +38,25 @@ class _GateBase(BaseRestClient):
return hmac.new(self.secret_key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha512).hexdigest()
def _headers(self, ts: str, sign: str) -> Dict[str, str]:
return {"KEY": self.api_key, "Timestamp": ts, "SIGN": sign, "Content-Type": "application/json"}
headers = {"KEY": self.api_key, "Timestamp": ts, "SIGN": sign, "Content-Type": "application/json"}
if self.channel_id:
headers["X-Gate-Channel-Id"] = self.channel_id[:19]
return headers
def _format_text(self, client_order_id: Optional[str]) -> str:
raw = str(client_order_id or "").strip()
if not raw:
return ""
normalized = []
for ch in raw:
if ch.isalnum() or ch in ("-", "_", "."):
normalized.append(ch)
text = "".join(normalized).strip()
if not text:
return ""
if not text.startswith("t-"):
text = f"t-{text}"
return text[:28]
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
m = str(method or "GET").upper()
@@ -87,8 +106,9 @@ class GateSpotClient(_GateBase):
"price": str(px),
"time_in_force": "gtc",
}
if client_order_id:
body["text"] = str(client_order_id)
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/spot/orders", json_body=body)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -106,8 +126,9 @@ class GateSpotClient(_GateBase):
"type": "market",
"amount": str(qty),
}
if client_order_id:
body["text"] = str(client_order_id)
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/spot/orders", json_body=body)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -162,8 +183,8 @@ class GateSpotClient(_GateBase):
class GateUsdtFuturesClient(_GateBase):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0):
super().__init__(api_key=api_key, secret_key=secret_key, base_url=base_url, timeout_sec=timeout_sec)
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
super().__init__(api_key=api_key, secret_key=secret_key, base_url=base_url, timeout_sec=timeout_sec, channel_id=channel_id)
# Best-effort cache for contract metadata to convert base qty -> contracts.
self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._contract_cache_ttl_sec = 300.0
@@ -263,8 +284,9 @@ class GateUsdtFuturesClient(_GateBase):
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": "0", "tif": "ioc"}
if reduce_only:
body["reduce_only"] = True
if client_order_id:
body["text"] = str(client_order_id)
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -293,8 +315,9 @@ class GateUsdtFuturesClient(_GateBase):
body: Dict[str, Any] = {"contract": contract, "size": signed_size, "price": str(px), "tif": "gtc"}
if reduce_only:
body["reduce_only"] = True
if client_order_id:
body["text"] = str(client_order_id)
text = self._format_text(client_order_id)
if text:
body["text"] = text
raw = self._signed_request("POST", "/api/v4/futures/usdt/orders", json_body=body)
oid = str(raw.get("id") or "") if isinstance(raw, dict) else ""
return LiveOrderResult(exchange_id="gate", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
@@ -0,0 +1,529 @@
"""
HTX (Huobi) direct REST client for spot and USDT-margined perpetual swap.
References:
- Spot base URL: https://api.huobi.pro
- USDT swap base URL: https://api.hbdm.com
- Spot auth: query params with HmacSHA256 signature
- Swap auth: query params with HmacSHA256 signature, request body in JSON
"""
from __future__ import annotations
import base64
import hashlib
import hmac
from decimal import Decimal, ROUND_DOWN
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode, urlparse
import datetime
import time
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_htx_contract_code, to_htx_spot_symbol
class HtxClient(BaseRestClient):
def __init__(
self,
*,
api_key: str,
secret_key: str,
base_url: str = "https://api.huobi.pro",
futures_base_url: str = "https://api.hbdm.com",
timeout_sec: float = 15.0,
market_type: str = "swap",
broker_id: str = "",
):
chosen_base = futures_base_url if str(market_type or "").strip().lower() == "swap" else base_url
super().__init__(base_url=chosen_base, timeout_sec=timeout_sec)
self.spot_base_url = (base_url or "https://api.huobi.pro").rstrip("/")
self.futures_base_url = (futures_base_url or "https://api.hbdm.com").rstrip("/")
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
self.market_type = (market_type or "swap").strip().lower()
self.broker_id = (broker_id or "").strip()
if self.market_type not in ("spot", "swap"):
self.market_type = "swap"
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing HTX api_key/secret_key")
self._spot_account_id: Optional[str] = None
self._contract_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
self._contract_cache_ttl_sec = 300.0
self._lever_cache: Dict[str, int] = {}
def _format_spot_client_order_id(self, client_order_id: Optional[str]) -> str:
prefix = str(self.broker_id or "").strip()
raw = str(client_order_id or "").strip()
if not prefix and not raw:
return ""
if not raw:
raw = str(int(time.time() * 1000))
allowed = []
for ch in raw:
if ch.isalnum() or ch in ("_", "-"):
allowed.append(ch)
suffix = "".join(allowed).strip("-_")
if not suffix:
suffix = str(int(time.time() * 1000))
if prefix:
if suffix.startswith(prefix):
combined = suffix
else:
combined = f"{prefix}-{suffix}"
else:
combined = suffix
return combined[:64]
@staticmethod
def _utc_ts() -> str:
return datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
@staticmethod
def _to_dec(x: Any) -> Decimal:
try:
return Decimal(str(x))
except Exception:
return Decimal("0")
@staticmethod
def _floor_to_int(value: Decimal) -> int:
try:
return int(value.to_integral_value(rounding=ROUND_DOWN))
except Exception:
return 0
def _sign_params(self, *, method: str, base_url: str, path: str, params: Dict[str, Any]) -> Dict[str, Any]:
signed = dict(params or {})
signed["AccessKeyId"] = self.api_key
signed["SignatureMethod"] = "HmacSHA256"
signed["SignatureVersion"] = "2"
signed["Timestamp"] = self._utc_ts()
encoded = urlencode(sorted((str(k), str(v)) for k, v in signed.items()))
host = urlparse(base_url).netloc
payload = "\n".join([str(method or "GET").upper(), host, path, encoded])
digest = hmac.new(self.secret_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).digest()
signed["Signature"] = base64.b64encode(digest).decode("utf-8")
return signed
def _spot_public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
old_base = self.base_url
self.base_url = self.spot_base_url
try:
code, data, text = self._request(method, path, params=params)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX spot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX spot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _spot_private_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
signed_params = self._sign_params(method=method, base_url=self.spot_base_url, path=path, params=params or {})
old_base = self.base_url
self.base_url = self.spot_base_url
try:
code, data, text = self._request(method, path, params=signed_params, json_body=json_body)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX spot HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX spot error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _swap_private_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
signed_params = self._sign_params(method=method, base_url=self.futures_base_url, path=path, params=params or {})
old_base = self.base_url
self.base_url = self.futures_base_url
try:
code, data, text = self._request(method, path, params=signed_params, json_body=json_body)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX swap HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX swap error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def _swap_public_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
old_base = self.base_url
self.base_url = self.futures_base_url
try:
code, data, text = self._request(method, path, params=params)
finally:
self.base_url = old_base
if code >= 400:
raise LiveTradingError(f"HTX swap HTTP {code}: {text[:500]}")
if isinstance(data, dict) and str(data.get("status") or "").lower() == "error":
raise LiveTradingError(f"HTX swap error: {data}")
return data if isinstance(data, dict) else {"raw": data}
def ping(self) -> bool:
try:
if self.market_type == "spot":
self._spot_public_request("GET", "/v1/common/timestamp")
else:
self._swap_public_request("GET", "/linear-swap-api/v1/swap_contract_info")
return True
except Exception:
return False
def _get_spot_account_id(self) -> str:
if self._spot_account_id:
return self._spot_account_id
raw = self._spot_private_request("GET", "/v1/account/accounts")
data = raw.get("data") or []
if isinstance(data, list):
for item in data:
if not isinstance(item, dict):
continue
if str(item.get("type") or "").lower() == "spot" and str(item.get("state") or "").lower() in ("working", ""):
self._spot_account_id = str(item.get("id") or "")
if self._spot_account_id:
return self._spot_account_id
for item in data:
if isinstance(item, dict) and item.get("id"):
self._spot_account_id = str(item.get("id"))
return self._spot_account_id
raise LiveTradingError("HTX spot account id not found")
def get_accounts(self) -> Any:
if self.market_type == "spot":
return self._spot_private_request("GET", "/v1/account/accounts")
return self.get_balance()
def get_balance(self) -> Any:
if self.market_type == "spot":
account_id = self._get_spot_account_id()
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
data = raw.get("data")
if data:
return raw
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_account_info", json_body={})
def get_positions(self, *, symbol: str = "") -> Any:
if self.market_type == "spot":
balance = self.get_balance()
items = (((balance.get("data") or {}).get("list")) if isinstance(balance, dict) else None) or []
base_asset = ""
if symbol:
base_asset = str(symbol).split("/", 1)[0].split(":", 1)[0].strip().upper()
rows = []
for item in items:
if not isinstance(item, dict):
continue
ccy = str(item.get("currency") or "").upper()
if not ccy or (base_asset and ccy != base_asset):
continue
bal = self._to_dec(item.get("balance") or "0")
if bal <= 0:
continue
rows.append({
"symbol": f"{ccy}/USDT",
"bal": float(bal),
"availBal": float(self._to_dec(item.get("balance") or "0")),
"cost_open": 0,
"profit_unreal": 0,
})
return {"data": rows}
body = {"contract_code": to_htx_contract_code(symbol)} if symbol else {}
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_position_info", json_body=body)
data = raw.get("data")
if data:
return raw
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_position_info", json_body=body)
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
if self.market_type == "spot":
raw = self._spot_public_request("GET", "/market/detail/merged", params={"symbol": to_htx_spot_symbol(symbol)})
else:
raw = self._swap_public_request("GET", "/linear-swap-ex/market/detail/merged", params={"contract_code": to_htx_contract_code(symbol)})
tick = raw.get("tick") if isinstance(raw, dict) else {}
return tick if isinstance(tick, dict) else {}
def get_contract_info(self, *, symbol: str) -> Dict[str, Any]:
key = to_htx_contract_code(symbol)
cached = self._contract_cache.get(key)
now = time.time()
if cached:
ts, obj = cached
if obj and (now - float(ts or 0)) <= float(self._contract_cache_ttl_sec or 300):
return obj
raw = self._swap_public_request("GET", "/linear-swap-api/v1/swap_contract_info", params={"contract_code": key})
data = raw.get("data") or []
obj = data[0] if isinstance(data, list) and data and isinstance(data[0], dict) else {}
if obj:
self._contract_cache[key] = (now, obj)
return obj
def _base_to_contracts(self, *, symbol: str, qty: float) -> int:
req = self._to_dec(qty)
if req <= 0:
return 0
info = self.get_contract_info(symbol=symbol) or {}
contract_size = self._to_dec(info.get("contract_size") or info.get("contractSize") or "1")
if contract_size <= 0:
contract_size = Decimal("1")
contracts = req / contract_size
val = self._floor_to_int(contracts)
return val if val > 0 else 1
def set_leverage(self, *, symbol: str, leverage: float) -> bool:
if self.market_type == "spot":
return False
contract_code = to_htx_contract_code(symbol)
try:
lv = int(float(leverage or 1))
except Exception:
lv = 1
if lv < 1:
lv = 1
try:
self._swap_private_request(
"POST",
"/linear-swap-api/v1/swap_cross_switch_lever_rate",
json_body={"contract_code": contract_code, "lever_rate": lv, "margin_account": "USDT"},
)
self._lever_cache[contract_code] = lv
return True
except Exception:
try:
self._swap_private_request(
"POST",
"/linear-swap-api/v1/swap_switch_lever_rate",
json_body={"contract_code": contract_code, "lever_rate": lv},
)
self._lever_cache[contract_code] = lv
return True
except Exception:
return False
def place_market_order(
self,
*,
symbol: str,
side: str,
qty: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
if self.market_type == "spot":
account_id = self._get_spot_account_id()
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
amount = float(qty or 0)
if amount <= 0:
raise LiveTradingError("Invalid qty")
order_type = f"{sd}-market"
if sd == "buy":
tick = self.get_ticker(symbol=symbol)
last = float(tick.get("close") or tick.get("price") or tick.get("lastPrice") or 0)
if last <= 0:
raise LiveTradingError("HTX spot market buy requires latest price for qty->value conversion")
amount = amount * last
body = {
"account-id": account_id,
"symbol": to_htx_spot_symbol(symbol),
"type": order_type,
"amount": f"{amount:.12f}".rstrip("0").rstrip("."),
"source": "spot-api",
}
formatted_client_order_id = self._format_spot_client_order_id(client_order_id)
if formatted_client_order_id:
body["client-order-id"] = formatted_client_order_id
raw = self._spot_private_request("POST", "/v1/order/orders/place", json_body=body)
data = raw.get("data")
oid = str(data or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
contract_code = to_htx_contract_code(symbol)
volume = self._base_to_contracts(symbol=symbol, qty=qty)
if volume <= 0:
raise LiveTradingError("Invalid HTX swap volume")
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
offset = "close" if reduce_only else "open"
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
"offset": offset,
"lever_rate": lever_rate,
"order_price_type": "opponent",
}
if self.broker_id:
body["channel_code"] = self.broker_id
if client_order_id:
body["client_order_id"] = str(client_order_id)[:64]
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def place_limit_order(
self,
*,
symbol: str,
side: str,
size: float,
price: float,
reduce_only: bool = False,
pos_side: str = "",
client_order_id: Optional[str] = None,
) -> LiveOrderResult:
px = float(price or 0)
qty = float(size or 0)
if px <= 0 or qty <= 0:
raise LiveTradingError("Invalid size/price")
sd = str(side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
if self.market_type == "spot":
account_id = self._get_spot_account_id()
body = {
"account-id": account_id,
"symbol": to_htx_spot_symbol(symbol),
"type": f"{sd}-limit",
"amount": f"{qty:.12f}".rstrip("0").rstrip("."),
"price": f"{px:.12f}".rstrip("0").rstrip("."),
"source": "spot-api",
}
formatted_client_order_id = self._format_spot_client_order_id(client_order_id)
if formatted_client_order_id:
body["client-order-id"] = formatted_client_order_id
raw = self._spot_private_request("POST", "/v1/order/orders/place", json_body=body)
data = raw.get("data")
oid = str(data or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
contract_code = to_htx_contract_code(symbol)
volume = self._base_to_contracts(symbol=symbol, qty=qty)
lever_rate = int(self._lever_cache.get(contract_code) or 5)
body = {
"contract_code": contract_code,
"volume": volume,
"direction": sd,
"offset": "close" if reduce_only else "open",
"lever_rate": lever_rate,
"price": px,
"order_price_type": "limit",
}
if self.broker_id:
body["channel_code"] = self.broker_id
if client_order_id:
body["client_order_id"] = str(client_order_id)[:64]
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order", json_body=body)
data = raw.get("data") or {}
oid = str(data.get("order_id_str") or data.get("order_id") or "")
return LiveOrderResult(exchange_id="htx", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
if self.market_type == "spot":
if order_id:
return self._spot_private_request("POST", f"/v1/order/orders/{str(order_id)}/submitcancel")
if client_order_id:
return self._spot_private_request("POST", "/v1/order/orders/submitCancelClientOrder", json_body={"client-order-id": str(client_order_id)})
raise LiveTradingError("HTX cancel_order requires order_id or client_order_id")
body: Dict[str, Any] = {"contract_code": to_htx_contract_code(symbol)}
if order_id:
body["order_id"] = str(order_id)
elif client_order_id:
body["client_order_id"] = str(client_order_id)
else:
raise LiveTradingError("HTX cancel_order requires order_id or client_order_id")
return self._swap_private_request("POST", "/linear-swap-api/v1/swap_cancel", json_body=body)
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
if self.market_type == "spot":
if order_id:
raw = self._spot_private_request("GET", f"/v1/order/orders/{str(order_id)}")
data = raw.get("data") if isinstance(raw, dict) else {}
return data if isinstance(data, dict) else {}
if client_order_id:
raw = self._spot_private_request("GET", "/v1/order/orders/getClientOrder", params={"clientOrderId": str(client_order_id)})
data = raw.get("data") if isinstance(raw, dict) else {}
return data if isinstance(data, dict) else {}
raise LiveTradingError("HTX get_order requires order_id or client_order_id")
body: Dict[str, Any] = {"contract_code": to_htx_contract_code(symbol)}
if order_id:
body["order_id"] = str(order_id)
elif client_order_id:
body["client_order_id"] = str(client_order_id)
else:
raise LiveTradingError("HTX get_order requires order_id or client_order_id")
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_order_info", json_body=body)
data = raw.get("data") or []
if isinstance(data, list) and data and isinstance(data[0], dict):
return data[0]
return {}
def wait_for_fill(
self,
*,
symbol: str,
order_id: str = "",
client_order_id: str = "",
max_wait_sec: float = 3.0,
poll_interval_sec: float = 0.5,
) -> Dict[str, Any]:
end_ts = time.time() + float(max_wait_sec or 0.0)
last: Dict[str, Any] = {}
while True:
try:
last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")) or {}
except Exception:
last = last or {}
filled = 0.0
avg_price = 0.0
fee = 0.0
fee_ccy = "USDT"
status = str(last.get("status") or last.get("state") or "")
try:
filled = float(
last.get("field-amount") or
last.get("filled_amount") or
last.get("trade_volume") or
last.get("trade_volume_avg") or
0.0
)
except Exception:
filled = 0.0
try:
avg_price = float(
last.get("field-cash-amount") or 0.0
)
if filled > 0 and avg_price > 0:
avg_price = avg_price / filled
else:
avg_price = float(last.get("field-avg-price") or last.get("trade_avg_price") or last.get("price") or 0.0)
except Exception:
avg_price = 0.0
try:
fee = abs(float(last.get("fee") or last.get("trade_fee") or 0.0))
except Exception:
fee = 0.0
fee_ccy = str(last.get("fee_asset") or last.get("fee_currency") or fee_ccy or "").strip() or "USDT"
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if str(status).lower() in ("filled", "partial-filled", "submitted", "canceled", "cancelled", "6", "7"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
if time.time() >= end_ts:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
time.sleep(float(poll_interval_sec or 0.5))
@@ -86,6 +86,11 @@ class KucoinSpotClient(BaseRestClient):
def get_accounts(self) -> Any:
return self._signed_request("GET", "/api/v1/accounts")
def get_ticker(self, *, symbol: str) -> Dict[str, Any]:
raw = self._public_request("GET", "/api/v1/market/orderbook/level1", params={"symbol": to_kucoin_symbol(symbol)})
data = raw.get("data") if isinstance(raw, dict) else None
return data if isinstance(data, dict) else {}
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
@@ -236,3 +236,28 @@ def to_deepcoin_swap_symbol(symbol: str) -> str:
return base_symbol
return f"{base_symbol}-SWAP"
def to_htx_spot_symbol(symbol: str) -> str:
"""
HTX spot symbol format: lowercase concatenated, e.g. btcusdt.
"""
base, quote = _split_base_quote(symbol)
if not base or not quote:
return str(symbol or "").replace("/", "").replace(":", "").lower()
return f"{base}{quote}".lower()
def to_htx_contract_code(symbol: str) -> str:
"""
HTX USDT-margined swap contract code: BASE-QUOTE, e.g. BTC-USDT.
"""
s = str(symbol or "").strip()
if not s:
return s
if "-" in s and "/" not in s:
return s.upper()
base, quote = _split_base_quote(symbol)
if not base or not quote:
return s.replace("/", "-").replace(":", "-").upper()
return f"{base}-{quote}"
@@ -34,6 +34,8 @@ from app.services.live_trading.kucoin import KucoinFuturesClient
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.bitfinex import BitfinexClient
from app.services.live_trading.bitfinex import BitfinexDerivativesClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.htx import HtxClient
from app.services.live_trading.symbols import to_okx_swap_inst_id
from app.services.live_trading.symbols import to_gate_currency_pair
from app.utils.db import get_db_connection
@@ -1478,6 +1480,31 @@ class PendingOrderWorker:
price=limit_price,
client_order_id=limit_client_oid,
)
elif isinstance(client, DeepcoinClient):
res1 = client.place_limit_order(
symbol=str(symbol),
side=side,
qty=remaining,
price=limit_price,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=limit_client_oid,
)
elif isinstance(client, HtxClient):
if market_type == "swap":
try:
client.set_leverage(symbol=str(symbol), leverage=leverage)
except Exception:
pass
res1 = client.place_limit_order(
symbol=str(symbol),
side=side,
size=remaining,
price=limit_price,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=limit_client_oid,
)
else:
raise LiveTradingError(f"Unsupported client type: {type(client)}")
@@ -1563,6 +1590,16 @@ class PendingOrderWorker:
phases["limit_query"] = q
_apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0))
_apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or ""))
elif isinstance(client, DeepcoinClient):
q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec)
phases["limit_query"] = q
_apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0))
_apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or ""))
elif isinstance(client, HtxClient):
q = client.wait_for_fill(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid, max_wait_sec=maker_wait_sec)
phases["limit_query"] = q
_apply_fill(float(q.get("filled") or 0.0), float(q.get("avg_price") or 0.0))
_apply_fee(float(q.get("fee") or 0.0), str(q.get("fee_ccy") or ""))
remaining = max(0.0, float(amount or 0.0) - total_base)
@@ -1625,6 +1662,10 @@ class PendingOrderWorker:
phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid)
elif isinstance(client, BitfinexDerivativesClient):
phases["limit_cancel"] = client.cancel_order(order_id=limit_order_id, client_order_id=limit_client_oid)
elif isinstance(client, DeepcoinClient):
phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid)
elif isinstance(client, HtxClient):
phases["limit_cancel"] = client.cancel_order(symbol=str(symbol), order_id=limit_order_id, client_order_id=limit_client_oid)
except Exception:
pass
except LiveTradingError as e:
@@ -1776,10 +1817,13 @@ class PendingOrderWorker:
client_order_id=market_client_oid,
)
elif isinstance(client, GateSpotClient):
mkt_size = remaining
if side == "buy" and ref_price > 0:
mkt_size = remaining * ref_price
res2 = client.place_market_order(
symbol=str(symbol),
side=side,
size=remaining,
size=mkt_size,
client_order_id=market_client_oid,
)
elif isinstance(client, GateUsdtFuturesClient):
@@ -1803,6 +1847,34 @@ class PendingOrderWorker:
)
elif isinstance(client, BitfinexDerivativesClient):
res2 = client.place_market_order(symbol=str(symbol), side=side, size=remaining, client_order_id=market_client_oid)
elif isinstance(client, DeepcoinClient):
if market_type == "swap":
try:
client.set_leverage(symbol=str(symbol), leverage=leverage)
except Exception:
pass
res2 = client.place_market_order(
symbol=str(symbol),
side=side,
qty=remaining,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=market_client_oid,
)
elif isinstance(client, HtxClient):
if market_type == "swap":
try:
client.set_leverage(symbol=str(symbol), leverage=leverage)
except Exception:
pass
res2 = client.place_market_order(
symbol=str(symbol),
side=side,
qty=remaining,
reduce_only=reduce_only,
pos_side=pos_side,
client_order_id=market_client_oid,
)
else:
raise LiveTradingError(f"Unsupported client type: {type(client)}")
@@ -1889,6 +1961,16 @@ class PendingOrderWorker:
phases["market_query"] = q2
_apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0))
_apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or ""))
elif isinstance(client, DeepcoinClient):
q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0)
phases["market_query"] = q2
_apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0))
_apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or ""))
elif isinstance(client, HtxClient):
q2 = client.wait_for_fill(symbol=str(symbol), order_id=market_order_id, client_order_id=market_client_oid, max_wait_sec=3.0)
phases["market_query"] = q2
_apply_fill(float(q2.get("filled") or 0.0), float(q2.get("avg_price") or 0.0))
_apply_fee(float(q2.get("fee") or 0.0), str(q2.get("fee_ccy") or ""))
except LiveTradingError as e:
logger.warning(f"live market phase failed: pending_id={order_id}, strategy_id={strategy_id}, cfg={safe_cfg}, err={e}")
phases["market_error"] = str(e)
+146 -102
View File
@@ -257,7 +257,7 @@ class StrategyService:
logger.error(f"Failed to fetch symbols: {str(e)}")
return {'success': False, 'message': f'Failed to get trading pairs: {str(e)}', 'symbols': []}
def test_exchange_connection(self, exchange_config: Dict[str, Any]) -> Dict[str, Any]:
def test_exchange_connection(self, exchange_config: Dict[str, Any], user_id: int = 1) -> Dict[str, Any]:
"""
Test exchange connection via direct REST clients (no ccxt).
@@ -284,8 +284,9 @@ class StrategyService:
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.htx import HtxClient
resolved = resolve_exchange_config(exchange_config or {})
resolved = resolve_exchange_config(exchange_config or {}, user_id=user_id)
safe_cfg = safe_exchange_config_for_log(resolved)
exchange_id = (resolved.get("exchange_id") or "").strip().lower()
@@ -370,13 +371,6 @@ class StrategyService:
'data': {'exchange': safe_cfg}
}
# IMPORTANT:
# Test connection should respect configured market_type (spot vs swap).
# Otherwise Binance will default to futures endpoints (fapi) and spot-only keys will fail with -2015.
market_type = str(resolved.get("market_type") or resolved.get("defaultType") or "swap").strip().lower()
client = create_client(resolved, market_type=market_type)
client_kind = type(client).__name__
# Best-effort detect current egress IP (for Binance IP whitelist debugging).
egress_ip = ""
try:
@@ -385,113 +379,163 @@ class StrategyService:
except Exception:
egress_ip = ""
# 1) Public connectivity
ok_public = False
try:
ok_public = bool(getattr(client, "ping")())
except Exception:
ok_public = False
if not ok_public:
return {
'success': False,
'message': f'Public ping failed: {exchange_id}',
'data': {'exchange': safe_cfg, 'client': client_kind, 'market_type': market_type, 'egress_ip': egress_ip},
}
# 2) Private credential validation (best-effort)
priv_data = None
try:
def _validate_private(client, market_type: str):
if isinstance(client, BinanceFuturesClient):
priv_data = client.get_account()
elif isinstance(client, BinanceSpotClient):
priv_data = client.get_account()
elif isinstance(client, OkxClient):
priv_data = client.get_balance()
elif isinstance(client, BitgetMixClient):
return client.get_account()
if isinstance(client, BinanceSpotClient):
return client.get_account()
if isinstance(client, OkxClient):
return client.get_balance()
if isinstance(client, BitgetMixClient):
product_type = str(resolved.get("product_type") or resolved.get("productType") or "USDT-FUTURES")
priv_data = client.get_accounts(product_type=product_type)
elif isinstance(client, BitgetSpotClient):
priv_data = client.get_assets()
elif isinstance(client, BybitClient):
priv_data = client.get_wallet_balance()
elif isinstance(client, CoinbaseExchangeClient):
priv_data = client.get_accounts()
elif isinstance(client, KrakenClient):
priv_data = client.get_balance()
elif isinstance(client, KrakenFuturesClient):
priv_data = client.get_accounts()
elif isinstance(client, KucoinSpotClient):
priv_data = client.get_accounts()
elif isinstance(client, KucoinFuturesClient):
priv_data = client.get_accounts()
elif isinstance(client, GateSpotClient):
priv_data = client.get_accounts()
elif isinstance(client, GateUsdtFuturesClient):
priv_data = client.get_accounts()
elif isinstance(client, BitfinexClient):
priv_data = client.get_wallets()
elif isinstance(client, BitfinexDerivativesClient):
priv_data = client.get_wallets()
elif isinstance(client, DeepcoinClient):
priv_data = client.get_balance()
except Exception as e:
msg = str(e)
# Add actionable hints for the most common Binance auth error.
if exchange_id == "binance" and ("-2015" in msg or "Invalid API-key, IP, or permissions" in msg):
# Auto A/B test: try the other market_type once to pinpoint permission mismatch.
alt_market_type = "spot" if market_type != "spot" else "swap"
alt_client_kind = ""
alt_base_url = ""
alt_ok = False
try:
alt_client = create_client(resolved, market_type=alt_market_type)
alt_client_kind = type(alt_client).__name__
alt_base_url = getattr(alt_client, "base_url", "") or ""
if isinstance(alt_client, BinanceFuturesClient) or isinstance(alt_client, BinanceSpotClient):
_ = alt_client.get_account()
alt_ok = True
except Exception:
alt_ok = False
return client.get_accounts(product_type=product_type)
if isinstance(client, BitgetSpotClient):
return client.get_assets()
if isinstance(client, BybitClient):
return client.get_wallet_balance()
if isinstance(client, CoinbaseExchangeClient):
return client.get_accounts()
if isinstance(client, KrakenClient):
return client.get_balance()
if isinstance(client, KrakenFuturesClient):
return client.get_accounts()
if isinstance(client, KucoinSpotClient):
return client.get_accounts()
if isinstance(client, KucoinFuturesClient):
return client.get_accounts()
if isinstance(client, GateSpotClient):
return client.get_accounts()
if isinstance(client, GateUsdtFuturesClient):
return client.get_accounts()
if isinstance(client, BitfinexClient):
return client.get_wallets()
if isinstance(client, BitfinexDerivativesClient):
return client.get_wallets()
if isinstance(client, DeepcoinClient):
return client.get_balance()
if isinstance(client, HtxClient):
return client.get_balance()
return None
base_url = getattr(client, "base_url", "") or ""
hint = (
f"Binance auth failed (-2015). Verify: "
f"(1) IP whitelist includes this server egress IP={egress_ip or 'unknown'}, "
f"(2) API key permissions match market_type={market_type} "
f"(spot requires Spot permissions; swap requires Futures permissions), "
f"(3) you're using binance.com keys for base_url={base_url or 'unknown'}."
)
if alt_ok:
hint += (
f" Auto-check: your key works for market_type={alt_market_type} "
f"(client={alt_client_kind}, base_url={alt_base_url or 'unknown'}) "
f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch."
def _probe_market_type(market_type: str):
try:
client = create_client(resolved, market_type=market_type)
except Exception as e:
return {
'success': False,
'message': f'Create client failed: {str(e)}',
'data': {
'exchange': safe_cfg,
'market_type': market_type,
'egress_ip': egress_ip,
},
}
client_kind = type(client).__name__
ok_public = False
try:
ok_public = bool(getattr(client, "ping")())
except Exception:
ok_public = False
if not ok_public:
return {
'success': False,
'message': f'Public ping failed: {exchange_id}',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
},
}
try:
priv_data = _validate_private(client, market_type)
except Exception as e:
msg = str(e)
if exchange_id == "binance" and ("-2015" in msg or "Invalid API-key, IP, or permissions" in msg):
alt_market_type = "spot" if market_type != "spot" else "swap"
alt_client_kind = ""
alt_base_url = ""
alt_ok = False
try:
alt_client = create_client(resolved, market_type=alt_market_type)
alt_client_kind = type(alt_client).__name__
alt_base_url = getattr(alt_client, "base_url", "") or ""
if isinstance(alt_client, (BinanceFuturesClient, BinanceSpotClient)):
_ = alt_client.get_account()
alt_ok = True
except Exception:
alt_ok = False
base_url = getattr(client, "base_url", "") or ""
is_demo = str(resolved.get("enable_demo_trading") or resolved.get("enableDemoTrading") or "").strip().lower() in ("true", "1", "yes")
hint = (
f"Binance auth failed (-2015). Verify: "
f"(1) IP whitelist includes this server egress IP={egress_ip or 'unknown'}, "
f"(2) API key permissions match market_type={market_type} "
f"(spot requires Spot permissions; swap requires Futures permissions), "
f"(3) you're using the correct key set for base_url={base_url or 'unknown'}."
)
msg = f"{msg} | {hint}"
if is_demo:
hint += " Demo mode is enabled, so you must use Binance demo/testnet API keys instead of mainnet keys."
else:
hint += " Mainnet mode is enabled, so you must use binance.com mainnet keys."
if alt_ok:
hint += (
f" Auto-check: your key works for market_type={alt_market_type} "
f"(client={alt_client_kind}, base_url={alt_base_url or 'unknown'}) "
f"but fails for market_type={market_type}. This is almost always a permissions/product mismatch."
)
msg = f"{msg} | {hint}"
return {
'success': False,
'message': f'Auth failed: {msg}',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
},
}
return {
'success': False,
'message': f'Auth failed: {msg}',
'success': True,
'message': 'Connection OK',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
'private': priv_data,
},
}
return {
'success': True,
'message': 'Connection OK',
'data': {
'exchange': safe_cfg,
'client': client_kind,
'market_type': market_type,
'egress_ip': egress_ip,
'base_url': getattr(client, "base_url", "") or "",
'private': priv_data,
},
}
raw_market_type = str(resolved.get("market_type") or resolved.get("defaultType") or "").strip().lower()
if raw_market_type in ("futures", "future", "perp", "perpetual"):
raw_market_type = "swap"
explicit_market_type = raw_market_type in ("spot", "swap")
if exchange_id in ("coinbaseexchange", "coinbase_exchange"):
market_candidates = ["spot"]
else:
market_candidates = [raw_market_type] if explicit_market_type else ["spot", "swap"]
last_failure = None
for market_type in market_candidates:
result = _probe_market_type(market_type)
if result.get('success'):
if not explicit_market_type and len(market_candidates) > 1:
result['message'] = f"Connection OK ({market_type})"
return result
last_failure = result
if last_failure and not explicit_market_type and len(market_candidates) > 1:
tried = "/".join(market_candidates)
last_failure['message'] = f"{last_failure.get('message')}. Tried market_type={tried}"
return last_failure or {'success': False, 'message': 'Connection failed', 'data': None}
except Exception as e:
logger.error(f"test_exchange_connection failed: {str(e)}")
return {'success': False, 'message': f'Connection failed: {str(e)}', 'data': None}
@@ -0,0 +1,204 @@
import json
from typing import Any, Dict, Optional
from app.utils.db import get_db_connection
class StrategySnapshotResolver:
"""Resolve stored strategy rows into backtest-ready snapshots."""
def __init__(self, user_id: int = 1):
self.user_id = int(user_id or 1)
def _safe_dict(self, value: Any) -> Dict[str, Any]:
if isinstance(value, dict):
return dict(value)
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except Exception:
pass
return {}
def _to_bool(self, value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
def _to_float(self, value: Any, default: float = 0.0) -> float:
try:
return float(value)
except Exception:
return float(default or 0.0)
def _to_int(self, value: Any, default: int = 0) -> int:
try:
return int(value)
except Exception:
return int(default or 0)
def _percent_to_ratio(self, value: Any, default: float = 0.0) -> float:
raw = self._to_float(value, default)
if raw <= 0:
return 0.0
if raw > 100:
raw = 100.0
return raw / 100.0
def _build_strategy_config(self, trading_config: Dict[str, Any]) -> Dict[str, Any]:
tc = trading_config or {}
signal_mode = str(tc.get("signal_mode") or "confirmed").strip().lower()
signal_timing = "next_bar_open"
if signal_mode in ("current_bar_close", "close", "same_bar_close"):
signal_timing = "same_bar_close"
return {
"risk": {
"stopLossPct": self._percent_to_ratio(tc.get("stop_loss_pct")),
"takeProfitPct": self._percent_to_ratio(tc.get("take_profit_pct")),
"trailing": {
"enabled": self._to_bool(tc.get("trailing_enabled") or tc.get("trailing_stop")),
"pct": self._percent_to_ratio(tc.get("trailing_stop_pct")),
"activationPct": self._percent_to_ratio(tc.get("trailing_activation_pct")),
},
},
"position": {
"entryPct": self._percent_to_ratio(tc.get("entry_pct") if tc.get("entry_pct") is not None else 100),
},
"scale": {
"trendAdd": {
"enabled": self._to_bool(tc.get("trend_add_enabled")),
"stepPct": self._percent_to_ratio(tc.get("trend_add_step_pct")),
"sizePct": self._percent_to_ratio(tc.get("trend_add_size_pct")),
"maxTimes": self._to_int(tc.get("trend_add_max_times")),
},
"dcaAdd": {
"enabled": self._to_bool(tc.get("dca_add_enabled")),
"stepPct": self._percent_to_ratio(tc.get("dca_add_step_pct")),
"sizePct": self._percent_to_ratio(tc.get("dca_add_size_pct")),
"maxTimes": self._to_int(tc.get("dca_add_max_times")),
},
"trendReduce": {
"enabled": self._to_bool(tc.get("trend_reduce_enabled")),
"stepPct": self._percent_to_ratio(tc.get("trend_reduce_step_pct")),
"sizePct": self._percent_to_ratio(tc.get("trend_reduce_size_pct")),
"maxTimes": self._to_int(tc.get("trend_reduce_max_times")),
},
"adverseReduce": {
"enabled": self._to_bool(tc.get("adverse_reduce_enabled")),
"stepPct": self._percent_to_ratio(tc.get("adverse_reduce_step_pct")),
"sizePct": self._percent_to_ratio(tc.get("adverse_reduce_size_pct")),
"maxTimes": self._to_int(tc.get("adverse_reduce_max_times")),
},
},
"execution": {
"signalTiming": signal_timing,
},
}
def _fetch_indicator_code(self, indicator_id: Optional[int]) -> str:
if not indicator_id:
return ""
try:
with get_db_connection() as db:
cur = db.cursor()
cur.execute("SELECT code FROM qd_indicator_codes WHERE id = ?", (int(indicator_id),))
row = cur.fetchone()
cur.close()
return (row or {}).get("code") or ""
except Exception:
return ""
def resolve(self, strategy: Dict[str, Any], override_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
if not strategy:
raise ValueError("strategy not found")
override = override_config or {}
indicator_config = self._safe_dict(strategy.get("indicator_config"))
trading_config = self._safe_dict(strategy.get("trading_config"))
cs_type = str(trading_config.get("cs_strategy_type") or trading_config.get("strategy_type") or "single").strip().lower()
if cs_type == "cross_sectional":
raise ValueError("Cross-sectional strategies are not supported in strategy backtest yet")
symbol = str(override.get("symbol") or trading_config.get("symbol") or strategy.get("symbol") or "").strip()
market = str(override.get("market") or strategy.get("market_category") or trading_config.get("market_category") or "Crypto").strip() or "Crypto"
if ":" in symbol and "market" not in override:
maybe_market, maybe_symbol = symbol.split(":", 1)
market = maybe_market or market
symbol = maybe_symbol or symbol
timeframe = str(override.get("timeframe") or trading_config.get("timeframe") or strategy.get("timeframe") or "1D").strip() or "1D"
initial_capital = self._to_float(override.get("initialCapital", trading_config.get("initial_capital", strategy.get("initial_capital", 10000))), 10000.0)
leverage = self._to_int(override.get("leverage", trading_config.get("leverage", strategy.get("leverage", 1))), 1)
commission = self._percent_to_ratio(override.get("commission", trading_config.get("commission", 0)))
slippage = self._percent_to_ratio(override.get("slippage", trading_config.get("slippage", 0)))
trade_direction = str(trading_config.get("trade_direction") or "long").strip().lower() or "long"
enable_mtf = self._to_bool(override.get("enableMtf", market.lower() == "crypto"))
strategy_type = str(strategy.get("strategy_type") or "IndicatorStrategy").strip() or "IndicatorStrategy"
strategy_mode = str(strategy.get("strategy_mode") or "signal").strip() or "signal"
is_script = strategy_type == "ScriptStrategy" or strategy_mode == "script"
indicator_id = indicator_config.get("indicator_id") or strategy.get("indicator_id")
indicator_name = indicator_config.get("indicator_name") or ""
code = (strategy.get("strategy_code") or "").strip() if is_script else (indicator_config.get("indicator_code") or "").strip()
if not code and indicator_id and not is_script:
code = self._fetch_indicator_code(indicator_id)
if not symbol:
raise ValueError("Strategy symbol is required for backtest")
if not code:
raise ValueError("Strategy code is empty and cannot be backtested")
strategy_config = self._build_strategy_config(trading_config)
snapshot = {
"strategy_id": strategy.get("id"),
"strategy_name": strategy.get("strategy_name") or f"Strategy #{strategy.get('id')}",
"strategy_type": strategy_type,
"strategy_mode": strategy_mode,
"run_type": "strategy_script" if is_script else "strategy_indicator",
"market": market,
"symbol": symbol,
"timeframe": timeframe,
"initial_capital": initial_capital,
"commission": commission,
"slippage": slippage,
"leverage": leverage,
"trade_direction": trade_direction,
"enable_mtf": enable_mtf,
"indicator_id": int(indicator_id) if str(indicator_id or "").isdigit() else None,
"indicator_name": indicator_name,
"indicator_params": trading_config.get("indicator_params") or {},
"code": code,
"strategy_config": strategy_config,
"config_snapshot": {
"strategyMeta": {
"strategyId": strategy.get("id"),
"strategyName": strategy.get("strategy_name"),
"strategyType": strategy_type,
"strategyMode": strategy_mode,
"runType": "strategy_script" if is_script else "strategy_indicator",
},
"marketConfig": {
"market": market,
"symbol": symbol,
"timeframe": timeframe,
},
"signalConfig": {
"indicatorId": int(indicator_id) if str(indicator_id or "").isdigit() else None,
"indicatorName": indicator_name,
"indicatorParams": trading_config.get("indicator_params") or {},
"scriptSource": "strategy_code" if is_script else "indicator_code",
},
"riskConfig": strategy_config.get("risk") or {},
"positionConfig": strategy_config.get("position") or {},
"scaleConfig": strategy_config.get("scale") or {},
"executionConfig": strategy_config.get("execution") or {},
},
}
return snapshot
@@ -2039,7 +2039,13 @@ class TradingExecutor:
return False
# 2. 计算下单数量
available_capital = self._get_available_capital(strategy_id, initial_capital)
available_capital = self._get_available_capital(
strategy_id,
initial_capital,
current_positions=current_positions,
current_price=current_price,
symbol=symbol,
)
amount = 0.0
@@ -2655,12 +2661,83 @@ class TradingExecutor:
def _place_stop_loss_order(self, *args, **kwargs):
pass
def _get_available_capital(self, strategy_id: int, initial_capital: float) -> float:
"""获取可用资金"""
return initial_capital
def _get_available_capital(
self,
strategy_id: int,
initial_capital: float,
current_positions: Optional[List[Dict[str, Any]]] = None,
current_price: Optional[float] = None,
symbol: str = "",
) -> float:
"""获取当前策略可用于仓位计算的净值口径资金。"""
return self._calculate_current_equity(
strategy_id,
initial_capital,
current_positions=current_positions,
current_price=current_price,
symbol=symbol,
)
def _calculate_current_equity(self, strategy_id: int, initial_capital: float) -> float:
return initial_capital
def _calculate_current_equity(
self,
strategy_id: int,
initial_capital: float,
current_positions: Optional[List[Dict[str, Any]]] = None,
current_price: Optional[float] = None,
symbol: str = "",
) -> float:
realized_pnl = 0.0
unrealized_pnl = 0.0
try:
with get_db_connection() as db:
cursor = db.cursor()
cursor.execute(
"""
SELECT COALESCE(SUM(COALESCE(profit, 0) - COALESCE(commission, 0)), 0) AS realized_pnl
FROM qd_strategy_trades
WHERE strategy_id = %s
""",
(strategy_id,)
)
row = cursor.fetchone() or {}
realized_pnl = float(row.get('realized_pnl') or 0.0)
cursor.close()
except Exception as e:
logger.warning(f"Failed to calculate realized pnl for strategy {strategy_id}: {e}")
positions = list(current_positions or [])
if not positions:
try:
positions = self._get_all_positions(strategy_id) or []
except Exception:
positions = []
normalized_symbol = (symbol or "").split(':')[0]
for pos in positions:
try:
side = str(pos.get('side') or '').strip().lower()
size = float(pos.get('size') or 0.0)
entry_price = float(pos.get('entry_price') or 0.0)
if size <= 0 or entry_price <= 0 or side not in ('long', 'short'):
continue
mark_price = pos.get('current_price')
pos_symbol = str(pos.get('symbol') or '')
if current_price and normalized_symbol and pos_symbol.split(':')[0] == normalized_symbol:
mark_price = current_price
mark_price = float(mark_price or 0.0)
if mark_price <= 0:
continue
if side == 'long':
unrealized_pnl += (mark_price - entry_price) * size
else:
unrealized_pnl += (entry_price - mark_price) * size
except Exception:
continue
equity = float(initial_capital or 0.0) + realized_pnl + unrealized_pnl
return max(0.0, equity)
def _record_trade(self, strategy_id: int, symbol: str, type: str, price: float, amount: float, value: float, profit: float = None, commission: float = None):
"""记录交易到数据库"""