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)
|
||||
|
||||
@@ -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):
|
||||
"""记录交易到数据库"""
|
||||
|
||||
Reference in New Issue
Block a user