Refactor code for improved readability and consistency

- Cleaned up whitespace and formatting in various files including http.py, language.py, logger.py, safe_exec.py, and SQL migration scripts.
- Consolidated import statements and removed unnecessary blank lines.
- Updated logging configuration for better clarity.
- Enhanced the safe execution code with improved error handling and logging.
- Removed commented-out code and unnecessary variables in backfill_zero_trades.py and other scripts.
- Added a pyproject.toml for Ruff and Vulture configuration.
- Introduced requirements-dev.txt for development dependencies.
- Removed commented-out stock entries in init.sql for cleaner migration scripts.
This commit is contained in:
dienakdz
2026-04-09 14:30:51 +07:00
parent 103055b3df
commit 87f2845483
157 changed files with 19026 additions and 17773 deletions
@@ -3,5 +3,3 @@ Live trading (direct exchange REST) clients.
This package intentionally does NOT use ccxt.
"""
@@ -149,5 +149,3 @@ class BaseRestClient:
@staticmethod
def _json_dumps(obj: Any) -> str:
return json.dumps(obj, ensure_ascii=False, separators=(",", ":"))
@@ -7,10 +7,10 @@ API docs (reference):
from __future__ import annotations
import hmac
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
@@ -19,7 +19,16 @@ 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, broker_id: str = ""):
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"
@@ -57,7 +66,7 @@ class BinanceFuturesClient(BaseRestClient):
Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required precision.
Args:
d: Decimal value to format
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
@@ -68,7 +77,7 @@ class BinanceFuturesClient(BaseRestClient):
return "0"
# Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize()
# If strict_precision is provided, use it and strictly limit decimal places
# This ensures we match the stepSize requirement exactly
if strict_precision is not None:
@@ -84,18 +93,18 @@ class BinanceFuturesClient(BaseRestClient):
# Format with exact precision - this will produce at most 'prec' decimal places
s = format(quantized, f".{prec}f")
# Remove trailing zeros and decimal point if not needed
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
# Fallback to original logic if strict_precision not provided or failed
# Convert to string using fixed-point notation
s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
# Fallback: try to convert safely
@@ -108,21 +117,21 @@ class BinanceFuturesClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
# Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
# Last resort: convert to string
s = str(d)
# Try to remove scientific notation if present
if 'e' in s.lower() or 'E' in s:
if "e" in s.lower() or "E" in s:
try:
f = float(s)
if strict_precision is not None:
@@ -130,14 +139,14 @@ class BinanceFuturesClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
except Exception:
pass
return s if s else "0"
@@ -360,7 +369,7 @@ class BinanceFuturesClient(BaseRestClient):
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Tuple[Decimal, Optional[int]]:
"""
Normalize futures order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort).
Returns:
Tuple of (normalized_quantity, precision) where precision is the number of decimal places required.
"""
@@ -381,7 +390,7 @@ class BinanceFuturesClient(BaseRestClient):
if step > 0:
q = self._floor_to_step(q, step)
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
# First try to get precision from metadata
qty_precision = None
@@ -391,7 +400,7 @@ class BinanceFuturesClient(BaseRestClient):
qty_precision = meta.get("quantityPrecision")
except Exception:
pass
# If precision not available, infer from stepSize
if qty_precision is None and step > 0:
try:
@@ -399,9 +408,9 @@ class BinanceFuturesClient(BaseRestClient):
# Use normalize() to remove trailing zeros, then count decimal places
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
if "." in step_str:
# Count decimal places after removing trailing zeros
decimal_part = step_str.split('.')[1]
decimal_part = step_str.split(".")[1]
qty_precision = len(decimal_part)
# Ensure precision is at least 0 and at most 18
if qty_precision < 0:
@@ -413,11 +422,11 @@ class BinanceFuturesClient(BaseRestClient):
qty_precision = 0
except Exception:
pass
# Apply precision limit
if qty_precision is not None:
q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty:
return (Decimal("0"), qty_precision)
return (q, qty_precision)
@@ -539,7 +548,7 @@ class BinanceFuturesClient(BaseRestClient):
s = str(err or "")
except Exception:
s = ""
return f'\"code\":{int(code)}' in s or f"'code': {int(code)}" in s or f"'code':{int(code)}" in s
return f'"code":{int(code)}' in s or f"'code': {int(code)}" in s or f"'code':{int(code)}" in s
@staticmethod
def _normalize_position_side(pos_side: Optional[str]) -> str:
@@ -613,7 +622,9 @@ class BinanceFuturesClient(BaseRestClient):
while True:
try:
last = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
last = self.get_order(
symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")
)
except Exception:
last = last or {}
@@ -712,7 +723,11 @@ class BinanceFuturesClient(BaseRestClient):
dual_side = self.get_dual_side_position()
pos_norm = self._normalize_position_side(position_side)
if dual_side is True:
params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
params["positionSide"] = (
pos_norm
if pos_norm in ("LONG", "SHORT")
else self._infer_position_side(side=sd, reduce_only=reduce_only)
)
elif dual_side is False:
# Keep default (BOTH) by omitting positionSide.
params.pop("positionSide", None)
@@ -748,7 +763,11 @@ class BinanceFuturesClient(BaseRestClient):
pass
else:
# Likely hedge mode; retry with inferred positionSide.
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
params2["positionSide"] = (
pos_norm
if pos_norm in ("LONG", "SHORT")
else self._infer_position_side(side=sd, reduce_only=reduce_only)
)
params2.pop("reduceOnly", None)
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
@@ -853,7 +872,11 @@ class BinanceFuturesClient(BaseRestClient):
dual_side = self.get_dual_side_position()
pos_norm = self._normalize_position_side(position_side)
if dual_side is True:
params["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
params["positionSide"] = (
pos_norm
if pos_norm in ("LONG", "SHORT")
else self._infer_position_side(side=sd, reduce_only=reduce_only)
)
elif dual_side is False:
params.pop("positionSide", None)
else:
@@ -881,7 +904,11 @@ class BinanceFuturesClient(BaseRestClient):
except Exception:
pass
else:
params2["positionSide"] = (pos_norm if pos_norm in ("LONG", "SHORT") else self._infer_position_side(side=sd, reduce_only=reduce_only))
params2["positionSide"] = (
pos_norm
if pos_norm in ("LONG", "SHORT")
else self._infer_position_side(side=sd, reduce_only=reduce_only)
)
params2.pop("reduceOnly", None)
try:
raw = self._signed_request("POST", "/fapi/v1/order", params=params2)
@@ -903,7 +930,9 @@ class BinanceFuturesClient(BaseRestClient):
exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "")
filled = float(raw.get("executedQty") or 0.0)
avg_price = float(raw.get("avgPrice") or raw.get("price") or 0.0)
return LiveOrderResult(exchange_id="binance", exchange_order_id=exchange_order_id, filled=filled, avg_price=avg_price, raw=raw)
return LiveOrderResult(
exchange_id="binance", exchange_order_id=exchange_order_id, filled=filled, avg_price=avg_price, raw=raw
)
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
sym = to_binance_futures_symbol(symbol)
@@ -952,5 +981,3 @@ class BinanceFuturesClient(BaseRestClient):
return rows
sym = to_binance_futures_symbol(want)
return [p for p in rows if isinstance(p, dict) and str(p.get("symbol") or "") == sym]
@@ -4,10 +4,10 @@ Binance Spot (direct REST) client.
from __future__ import annotations
import hmac
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
@@ -16,7 +16,16 @@ 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, broker_id: str = ""):
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"
@@ -47,7 +56,7 @@ class BinanceSpotClient(BaseRestClient):
Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required precision.
Args:
d: Decimal value to format
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
@@ -58,7 +67,7 @@ class BinanceSpotClient(BaseRestClient):
return "0"
# Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize()
# If strict_precision is provided, use it and strictly limit decimal places
# This ensures we match the stepSize requirement exactly
if strict_precision is not None:
@@ -76,18 +85,18 @@ class BinanceSpotClient(BaseRestClient):
s = format(quantized, f".{prec}f")
# Remove trailing zeros and decimal point if not needed
# This is safe because we've already quantized to the correct precision
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
# Fallback to original logic if strict_precision not provided or failed
# Convert to string using fixed-point notation
s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
# Fallback: try to convert safely
@@ -103,21 +112,21 @@ class BinanceSpotClient(BaseRestClient):
if prec > 18:
prec = 18
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
# Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
# Last resort: convert to string
s = str(d)
# Try to remove scientific notation if present
if 'e' in s.lower() or 'E' in s:
if "e" in s.lower() or "E" in s:
try:
f = float(s)
if strict_precision is not None:
@@ -125,14 +134,14 @@ class BinanceSpotClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
except Exception:
pass
return s if s else "0"
@@ -343,7 +352,7 @@ class BinanceSpotClient(BaseRestClient):
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Tuple[Decimal, Optional[int]]:
"""
Normalize spot order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort).
Returns:
Tuple of (normalized_quantity, precision) where precision is the number of decimal places required.
"""
@@ -364,7 +373,7 @@ class BinanceSpotClient(BaseRestClient):
if step > 0:
q = self._floor_to_step(q, step)
# Enforce quantity precision cap (Binance may reject quantities with too many decimals: -1111).
# First try to get precision from metadata
qty_precision = None
@@ -374,7 +383,7 @@ class BinanceSpotClient(BaseRestClient):
qty_precision = meta.get("quantityPrecision")
except Exception:
pass
# If precision not available, infer from stepSize
if qty_precision is None and step > 0:
try:
@@ -382,9 +391,9 @@ class BinanceSpotClient(BaseRestClient):
# Use normalize() to remove trailing zeros, then count decimal places
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
if "." in step_str:
# Count decimal places after removing trailing zeros
decimal_part = step_str.split('.')[1]
decimal_part = step_str.split(".")[1]
qty_precision = len(decimal_part)
# Ensure precision is at least 0 and at most 18
if qty_precision < 0:
@@ -396,11 +405,11 @@ class BinanceSpotClient(BaseRestClient):
qty_precision = 0
except Exception:
pass
# Apply precision limit
if qty_precision is not None:
q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty:
return (Decimal("0"), qty_precision)
return (q, qty_precision)
@@ -452,7 +461,9 @@ class BinanceSpotClient(BaseRestClient):
exchange_id="binance",
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
filled=float(raw.get("executedQty") or 0.0),
avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0,
avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0)
if float(raw.get("executedQty") or 0.0) > 0
else 0.0,
raw=raw,
)
@@ -493,7 +504,9 @@ class BinanceSpotClient(BaseRestClient):
exchange_id="binance",
exchange_order_id=str(raw.get("orderId") or raw.get("clientOrderId") or ""),
filled=float(raw.get("executedQty") or 0.0),
avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0) if float(raw.get("executedQty") or 0.0) > 0 else 0.0,
avg_price=float(raw.get("cummulativeQuoteQty") or 0.0) / float(raw.get("executedQty") or 1.0)
if float(raw.get("executedQty") or 0.0) > 0
else 0.0,
raw=raw,
)
@@ -589,7 +602,9 @@ class BinanceSpotClient(BaseRestClient):
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 ""))
last = self.get_order(
symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")
)
except Exception:
last = last or {}
@@ -613,5 +628,3 @@ class BinanceSpotClient(BaseRestClient):
if time.time() >= end_ts:
return {"filled": filled, "avg_price": avg_price, "status": status, "order": last}
time.sleep(float(poll_interval_sec or 0.5))
@@ -19,12 +19,13 @@ import time
from typing import Any, Dict, Optional
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_bitfinex_spot_symbol
from app.services.live_trading.symbols import to_bitfinex_perp_symbol
from app.services.live_trading.symbols import to_bitfinex_perp_symbol, to_bitfinex_spot_symbol
class BitfinexClient(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.bitfinex.com", timeout_sec: float = 15.0):
def __init__(
self, *, api_key: str, secret_key: str, base_url: str = "https://api.bitfinex.com", timeout_sec: float = 15.0
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
@@ -40,14 +41,21 @@ class BitfinexClient(BaseRestClient):
return hmac.new(self.secret_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha384).hexdigest()
def _headers(self, nonce: str, sign: str) -> Dict[str, str]:
return {"bfx-apikey": self.api_key, "bfx-nonce": nonce, "bfx-signature": sign, "content-type": "application/json"}
return {
"bfx-apikey": self.api_key,
"bfx-nonce": nonce,
"bfx-signature": sign,
"content-type": "application/json",
}
def _signed_request(self, method: str, path: str, *, json_body: Optional[Dict[str, Any]] = None) -> Any:
m = str(method or "POST").upper()
nonce = self._nonce()
body_str = self._json_dumps(json_body) if json_body is not None else ""
sign = self._sign(path, nonce, body_str)
code, data, text = self._request(m, path, params=None, data=body_str if body_str else None, headers=self._headers(nonce, sign))
code, data, text = self._request(
m, path, params=None, data=body_str if body_str else None, headers=self._headers(nonce, sign)
)
if code >= 400:
raise LiveTradingError(f"Bitfinex HTTP {code}: {text[:500]}")
return data
@@ -71,77 +79,9 @@ class BitfinexClient(BaseRestClient):
"""
return self._signed_request("POST", "/v2/auth/r/wallets", json_body={})
class BitfinexDerivativesClient(BitfinexClient):
"""
Bitfinex derivatives/perpetual client (best-effort).
Differences vs spot:
- Symbol uses tBASEF0:QUOTEF0 (e.g. tBTCF0:USTF0)
- Order type typically uses MARKET/LIMIT (not EXCHANGE MARKET/LIMIT)
"""
def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
qty = float(size or 0.0)
if qty <= 0:
raise LiveTradingError("Invalid size")
sym = to_bitfinex_perp_symbol(symbol)
amt = qty if sd == "buy" else -qty
body: Dict[str, Any] = {"type": "MARKET", "symbol": sym, "amount": str(amt)}
if client_order_id:
try:
cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0")
if cid > 0:
body["cid"] = cid
except Exception:
pass
raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body)
oid = ""
try:
if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]:
order = raw[3][0]
if isinstance(order, list) and order:
oid = str(order[0])
except Exception:
oid = ""
return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw})
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"):
raise LiveTradingError(f"Invalid side: {side}")
qty = float(size or 0.0)
px = float(price or 0.0)
if qty <= 0 or px <= 0:
raise LiveTradingError("Invalid size/price")
sym = to_bitfinex_perp_symbol(symbol)
amt = qty if sd == "buy" else -qty
body: Dict[str, Any] = {"type": "LIMIT", "symbol": sym, "amount": str(amt), "price": str(px)}
if client_order_id:
try:
cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0")
if cid > 0:
body["cid"] = cid
except Exception:
pass
raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body)
oid = ""
try:
if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]:
order = raw[3][0]
if isinstance(order, list) and order:
oid = str(order[0])
except Exception:
oid = ""
return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw})
def get_positions(self) -> Any:
return self._signed_request("POST", "/v2/auth/r/positions", json_body={})
def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
def place_market_order(
self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None
) -> LiveOrderResult:
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
@@ -169,9 +109,13 @@ class BitfinexDerivativesClient(BitfinexClient):
oid = str(order[0])
except Exception:
oid = ""
return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw})
return LiveOrderResult(
exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw}
)
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
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"):
raise LiveTradingError(f"Invalid side: {side}")
@@ -198,7 +142,9 @@ class BitfinexDerivativesClient(BitfinexClient):
oid = str(order[0])
except Exception:
oid = ""
return LiveOrderResult(exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw})
return LiveOrderResult(
exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw}
)
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
if order_id:
@@ -224,7 +170,9 @@ class BitfinexDerivativesClient(BitfinexClient):
# Bitfinex v2 order status endpoint
return self._signed_request("POST", f"/v2/auth/r/order/{oid}")
def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]:
def wait_for_fill(
self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5
) -> Dict[str, Any]:
end_ts = time.time() + float(max_wait_sec or 0.0)
last: Any = None
while True:
@@ -251,11 +199,108 @@ class BitfinexDerivativesClient(BitfinexClient):
# Note: Bitfinex order response doesn't include fee; fee is typically in trades.
# We return 0.0 here; actual fee can be fetched via trades endpoint if needed.
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
if isinstance(status, str) and ("EXECUTED" in status.upper() or "CANCELED" in status.upper()):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
class BitfinexDerivativesClient(BitfinexClient):
"""
Bitfinex derivatives/perpetual client (best-effort).
Differences vs spot:
- Symbol uses tBASEF0:QUOTEF0 (e.g. tBTCF0:USTF0)
- Order type typically uses MARKET/LIMIT (not EXCHANGE MARKET/LIMIT)
"""
def place_market_order(
self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None
) -> LiveOrderResult:
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
qty = float(size or 0.0)
if qty <= 0:
raise LiveTradingError("Invalid size")
sym = to_bitfinex_perp_symbol(symbol)
amt = qty if sd == "buy" else -qty
body: Dict[str, Any] = {"type": "MARKET", "symbol": sym, "amount": str(amt)}
if client_order_id:
try:
cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0")
if cid > 0:
body["cid"] = cid
except Exception:
pass
raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body)
oid = ""
try:
if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]:
order = raw[3][0]
if isinstance(order, list) and order:
oid = str(order[0])
except Exception:
oid = ""
return LiveOrderResult(
exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw}
)
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"):
raise LiveTradingError(f"Invalid side: {side}")
qty = float(size or 0.0)
px = float(price or 0.0)
if qty <= 0 or px <= 0:
raise LiveTradingError("Invalid size/price")
sym = to_bitfinex_perp_symbol(symbol)
amt = qty if sd == "buy" else -qty
body: Dict[str, Any] = {"type": "LIMIT", "symbol": sym, "amount": str(amt), "price": str(px)}
if client_order_id:
try:
cid = int("".join([c for c in str(client_order_id) if c.isdigit()])[:18] or "0")
if cid > 0:
body["cid"] = cid
except Exception:
pass
raw = self._signed_request("POST", "/v2/auth/w/order/submit", json_body=body)
oid = ""
try:
if isinstance(raw, list) and len(raw) >= 4 and isinstance(raw[3], list) and raw[3]:
order = raw[3][0]
if isinstance(order, list) and order:
oid = str(order[0])
except Exception:
oid = ""
return LiveOrderResult(
exchange_id="bitfinex", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw={"raw": raw}
)
def get_positions(self) -> Any:
return self._signed_request("POST", "/v2/auth/r/positions", json_body={})
@@ -11,7 +11,7 @@ import base64
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
@@ -77,7 +77,7 @@ class BitgetMixClient(BaseRestClient):
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match sizeStep/sizePlace precision.
Args:
d: Decimal value to format
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
@@ -87,7 +87,7 @@ class BitgetMixClient(BaseRestClient):
if d == 0:
return "0"
normalized = d.normalize()
if strict_precision is not None:
try:
prec = int(strict_precision)
@@ -95,15 +95,15 @@ class BitgetMixClient(BaseRestClient):
q = Decimal("1").scaleb(-prec)
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
s = format(quantized, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
try:
@@ -115,18 +115,18 @@ class BitgetMixClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
if "e" in s.lower() or "E" in s:
try:
f = float(s)
if strict_precision is not None:
@@ -134,14 +134,14 @@ class BitgetMixClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
except Exception:
pass
return s if s else "0"
@@ -379,9 +379,7 @@ class BitgetMixClient(BaseRestClient):
sd = (side or "").lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
pos_mode = self.get_account_pos_mode(
symbol=symbol, margin_coin=margin_coin, product_type=product_type
)
pos_mode = self.get_account_pos_mode(symbol=symbol, margin_coin=margin_coin, product_type=product_type)
hedge = pos_mode == "hedge_mode"
if hedge:
# Mirror CCXT: hedge close flips side; hedge open keeps side + tradeSide open.
@@ -426,7 +424,7 @@ class BitgetMixClient(BaseRestClient):
This system computes `amount` as base-asset quantity (e.g. BTC amount).
Bitget mix `size` is typically in contracts; convert using contractSize if available,
then align to size step / min trade number (best-effort).
Returns:
Tuple of (normalized_size, precision) where precision is the number of decimal places required.
"""
@@ -447,7 +445,9 @@ class BitgetMixClient(BaseRestClient):
qty = req_base / ct
# Determine step size.
step = self._to_dec(contract.get("sizeMultiplier") or contract.get("sizeStep") or contract.get("lotSize") or "0")
step = self._to_dec(
contract.get("sizeMultiplier") or contract.get("sizeStep") or contract.get("lotSize") or "0"
)
size_precision = None
if step <= 0:
sp = contract.get("sizePlace")
@@ -466,8 +466,8 @@ class BitgetMixClient(BaseRestClient):
try:
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
decimal_part = step_str.split('.')[1]
if "." in step_str:
decimal_part = step_str.split(".")[1]
size_precision = len(decimal_part)
if size_precision < 0:
size_precision = 0
@@ -492,7 +492,9 @@ class BitgetMixClient(BaseRestClient):
"""
Private endpoint to validate credentials (best-effort).
"""
return self._signed_request("GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")})
return self._signed_request(
"GET", "/api/v2/mix/account/accounts", params={"productType": str(product_type or "USDT-FUTURES")}
)
def get_positions(self, *, product_type: str = "USDT-FUTURES", symbol: str = "") -> Dict[str, Any]:
"""
@@ -515,10 +517,7 @@ class BitgetMixClient(BaseRestClient):
data = resp.get("data")
if not isinstance(data, list):
return resp
filtered = [
p for p in data
if isinstance(p, dict) and str(p.get("symbol") or "").strip().upper() == sym_key
]
filtered = [p for p in data if isinstance(p, dict) and str(p.get("symbol") or "").strip().upper() == sym_key]
out = dict(resp)
out["data"] = filtered
return out
@@ -688,9 +687,13 @@ class BitgetMixClient(BaseRestClient):
raw = self._post_mix_place_order(body, original_side=sd, reduce_only=reduce_only)
data = raw.get("data") if isinstance(raw, dict) else None
exchange_order_id = str(data.get("orderId") or data.get("clientOid") or "") if isinstance(data, dict) else ""
return LiveOrderResult(exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
return LiveOrderResult(
exchange_id="bitget", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw
)
def cancel_order(self, *, symbol: str, product_type: str, margin_coin: str = "USDT", order_id: str = "", client_oid: str = "") -> Dict[str, Any]:
def cancel_order(
self, *, symbol: str, product_type: str, margin_coin: str = "USDT", order_id: str = "", client_oid: str = ""
) -> Dict[str, Any]:
body: Dict[str, Any] = {
"symbol": to_bitget_um_symbol(symbol),
"productType": str(product_type or "USDT-FUTURES"),
@@ -771,7 +774,9 @@ class BitgetMixClient(BaseRestClient):
ct = Decimal("0")
try:
contract = self.get_contract(symbol=symbol, product_type=product_type) or {}
ct = self._to_dec(contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0")
ct = self._to_dec(
contract.get("contractSize") or contract.get("contractSz") or contract.get("ctVal") or "0"
)
except Exception:
ct = Decimal("0")
@@ -839,17 +844,47 @@ class BitgetMixClient(BaseRestClient):
d = last_detail.get("data") if isinstance(last_detail, dict) else None
if isinstance(d, dict):
state = str(d.get("state") or d.get("status") or "")
avg = float(d.get("priceAvg") or d.get("fillPrice") or 0.0) if (d.get("priceAvg") or d.get("fillPrice")) else 0.0
filled = float(d.get("baseVolume") or d.get("filledQty") or 0.0) if (d.get("baseVolume") or d.get("filledQty")) else 0.0
avg = (
float(d.get("priceAvg") or d.get("fillPrice") or 0.0)
if (d.get("priceAvg") or d.get("fillPrice"))
else 0.0
)
filled = (
float(d.get("baseVolume") or d.get("filledQty") or 0.0)
if (d.get("baseVolume") or d.get("filledQty"))
else 0.0
)
if filled > 0 and avg > 0:
return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
return {
"filled": filled,
"avg_price": avg,
"fee": 0.0,
"fee_ccy": "",
"state": state,
"detail": last_detail,
"fills": last_fills,
}
if state in ("filled", "canceled", "cancelled"):
return {"filled": filled, "avg_price": avg, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
return {
"filled": filled,
"avg_price": avg,
"fee": 0.0,
"fee_ccy": "",
"state": state,
"detail": last_detail,
"fills": last_fills,
}
except Exception:
pass
if time.time() >= end_ts:
return {"filled": 0.0, "avg_price": 0.0, "fee": 0.0, "fee_ccy": "", "state": state, "detail": last_detail, "fills": last_fills}
return {
"filled": 0.0,
"avg_price": 0.0,
"fee": 0.0,
"fee_ccy": "",
"state": state,
"detail": last_detail,
"fills": last_fills,
}
time.sleep(float(poll_interval_sec or 0.5))
@@ -14,7 +14,7 @@ import base64
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
@@ -69,7 +69,7 @@ class BitgetSpotClient(BaseRestClient):
"""
Convert Decimal to string with controlled precision.
Bitget requires quantities to match quantityStep/quantityScale precision.
Args:
d: Decimal value to format
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
@@ -79,7 +79,7 @@ class BitgetSpotClient(BaseRestClient):
if d == 0:
return "0"
normalized = d.normalize()
if strict_precision is not None:
try:
prec = int(strict_precision)
@@ -87,15 +87,15 @@ class BitgetSpotClient(BaseRestClient):
q = Decimal("1").scaleb(-prec)
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
s = format(quantized, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
try:
@@ -107,18 +107,18 @@ class BitgetSpotClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
if "e" in s.lower() or "E" in s:
try:
f = float(s)
if strict_precision is not None:
@@ -126,14 +126,14 @@ class BitgetSpotClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
except Exception:
pass
return s if s else "0"
@@ -256,7 +256,7 @@ class BitgetSpotClient(BaseRestClient):
def _normalize_base_size(self, *, symbol: str, base_size: float) -> Tuple[Decimal, Optional[int]]:
"""
Normalize spot base size to lot/step constraints (best-effort).
Returns:
Tuple of (normalized_size, precision) where precision is the number of decimal places required.
"""
@@ -271,7 +271,13 @@ class BitgetSpotClient(BaseRestClient):
meta = {}
# Try common fields. If unavailable, keep as-is.
step = self._to_dec(meta.get("quantityScale") or meta.get("quantityStep") or meta.get("sizeStep") or meta.get("minTradeIncrement") or "0")
step = self._to_dec(
meta.get("quantityScale")
or meta.get("quantityStep")
or meta.get("sizeStep")
or meta.get("minTradeIncrement")
or "0"
)
size_precision = None
if step <= 0:
# Some endpoints expose decimals instead of step.
@@ -291,8 +297,8 @@ class BitgetSpotClient(BaseRestClient):
try:
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
decimal_part = step_str.split('.')[1]
if "." in step_str:
decimal_part = step_str.split(".")[1]
size_precision = len(decimal_part)
if size_precision < 0:
size_precision = 0
@@ -303,12 +309,16 @@ class BitgetSpotClient(BaseRestClient):
except Exception:
pass
mn = self._to_dec(meta.get("minTradeAmount") or meta.get("minTradeNum") or meta.get("minQty") or meta.get("minSize") or "0")
mn = self._to_dec(
meta.get("minTradeAmount") or meta.get("minTradeNum") or meta.get("minQty") or meta.get("minSize") or "0"
)
if mn > 0 and req < mn:
return (Decimal("0"), size_precision)
return (req, size_precision)
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
def place_limit_order(
self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None
) -> LiveOrderResult:
sym = to_bitget_um_symbol(symbol)
sd = (side or "").lower()
if sd not in ("buy", "sell"):
@@ -336,7 +346,9 @@ class BitgetSpotClient(BaseRestClient):
order_id = str(data.get("orderId") or "") if isinstance(data, dict) else ""
return LiveOrderResult(exchange_id="bitget", exchange_order_id=order_id, filled=0.0, avg_price=0.0, raw=raw)
def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
def place_market_order(
self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None
) -> LiveOrderResult:
"""
NOTE: Bitget spot market BUY may expect quote amount. We accept `size` as base size,
but the caller can also pass a quote-sized value if desired.
@@ -438,7 +450,9 @@ class BitgetSpotClient(BaseRestClient):
fee = float(fee_v or 0.0)
except Exception:
fee = 0.0
ccy = str(f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or f.get("fillFeeCcy") or "").strip()
ccy = str(
f.get("feeCoin") or f.get("feeCcy") or f.get("fillFeeCoin") or f.get("fillFeeCcy") or ""
).strip()
if fee != 0.0:
total_fee += abs(float(fee))
if (not fee_ccy) and ccy:
@@ -453,13 +467,15 @@ class BitgetSpotClient(BaseRestClient):
"fee_ccy": str(fee_ccy or ""),
"state": state,
"order": last_order,
"fills": last_fills
"fills": last_fills,
}
except Exception:
pass
try:
last_order = self.get_order(symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or ""))
last_order = self.get_order(
symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")
)
od = last_order.get("data") if isinstance(last_order, dict) else None
if isinstance(od, dict):
state = str(od.get("status") or od.get("state") or "")
@@ -487,5 +503,3 @@ class BitgetSpotClient(BaseRestClient):
if isinstance(data, dict):
return data
return {}
@@ -13,7 +13,7 @@ from __future__ import annotations
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
@@ -79,7 +79,7 @@ class BybitClient(BaseRestClient):
"""
Convert Decimal to string with controlled precision.
Bybit requires quantities to match qtyStep precision.
Args:
d: Decimal value to format
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
@@ -89,7 +89,7 @@ class BybitClient(BaseRestClient):
if d == 0:
return "0"
normalized = d.normalize()
if strict_precision is not None:
try:
prec = int(strict_precision)
@@ -97,15 +97,15 @@ class BybitClient(BaseRestClient):
q = Decimal("1").scaleb(-prec)
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
s = format(quantized, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
try:
@@ -117,18 +117,18 @@ class BybitClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
if "e" in s.lower() or "E" in s:
try:
f = float(s)
if strict_precision is not None:
@@ -136,14 +136,14 @@ class BybitClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
except Exception:
pass
return s if s else "0"
@@ -414,7 +414,9 @@ class BybitClient(BaseRestClient):
return {}
def get_wallet_balance(self, *, account_type: str = "UNIFIED") -> Dict[str, Any]:
return self._signed_request("GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")})
return self._signed_request(
"GET", "/v5/account/wallet-balance", params={"accountType": str(account_type or "UNIFIED")}
)
def get_instrument_info(self, *, category: str, symbol: str) -> Dict[str, Any]:
cat = str(category or self.category or "linear").strip().lower()
@@ -449,15 +451,15 @@ class BybitClient(BaseRestClient):
mn = self._to_dec((lot or {}).get("minOrderQty") or "0")
if step > 0:
q = self._floor_to_step(q, step)
# Infer precision from qtyStep
qty_precision = None
if step > 0:
try:
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
decimal_part = step_str.split('.')[1]
if "." in step_str:
decimal_part = step_str.split(".")[1]
qty_precision = len(decimal_part)
if qty_precision < 0:
qty_precision = 0
@@ -467,7 +469,7 @@ class BybitClient(BaseRestClient):
qty_precision = 0
except Exception:
pass
if mn > 0 and q < mn:
return (Decimal("0"), qty_precision)
return (q, qty_precision)
@@ -627,7 +629,9 @@ class BybitClient(BaseRestClient):
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 ""))
last = self.get_order(
symbol=symbol, order_id=str(order_id or ""), client_order_id=str(client_order_id or "")
)
except Exception:
last = last or {}
status = str(last.get("orderStatus") or last.get("order_status") or "")
@@ -666,11 +670,32 @@ class BybitClient(BaseRestClient):
if fee > 0 and self.category == "linear":
fee_ccy = "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}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
if status.lower() in ("filled", "cancelled", "canceled", "rejected"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
def get_positions(
@@ -714,5 +739,3 @@ class BybitClient(BaseRestClient):
return bool(ok)
except Exception:
return False
@@ -55,7 +55,14 @@ class CoinbaseExchangeClient(BaseRestClient):
"Content-Type": "application/json",
}
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
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()
ts = str(int(time.time()))
body_str = self._json_dumps(json_body) if json_body is not None else ""
@@ -75,7 +82,9 @@ class CoinbaseExchangeClient(BaseRestClient):
signed_path = f"{path}?{'&'.join(items)}"
prehash = f"{ts}{m}{signed_path}{body_str}"
sign = self._sign(prehash)
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign))
code, data, text = self._request(
m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts, sign)
)
if code >= 400:
raise LiveTradingError(f"CoinbaseExchange HTTP {code}: {text[:500]}")
return data
@@ -96,7 +105,9 @@ class CoinbaseExchangeClient(BaseRestClient):
def get_accounts(self) -> Any:
return self._signed_request("GET", "/accounts")
def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
def place_market_order(
self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None
) -> LiveOrderResult:
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
@@ -113,9 +124,17 @@ class CoinbaseExchangeClient(BaseRestClient):
body["client_oid"] = str(client_order_id)
raw = self._signed_request("POST", "/orders", json_body=body)
oid = str(raw.get("id") or raw.get("order_id") or raw.get("client_oid") or "")
return LiveOrderResult(exchange_id="coinbaseexchange", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
return LiveOrderResult(
exchange_id="coinbaseexchange",
exchange_order_id=oid,
filled=0.0,
avg_price=0.0,
raw=raw if isinstance(raw, dict) else {"raw": raw},
)
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
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"):
raise LiveTradingError(f"Invalid side: {side}")
@@ -135,7 +154,13 @@ class CoinbaseExchangeClient(BaseRestClient):
body["client_oid"] = str(client_order_id)
raw = self._signed_request("POST", "/orders", json_body=body)
oid = str(raw.get("id") or raw.get("order_id") or raw.get("client_oid") or "")
return LiveOrderResult(exchange_id="coinbaseexchange", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
return LiveOrderResult(
exchange_id="coinbaseexchange",
exchange_order_id=oid,
filled=0.0,
avg_price=0.0,
raw=raw if isinstance(raw, dict) else {"raw": raw},
)
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
if order_id:
@@ -191,11 +216,30 @@ class CoinbaseExchangeClient(BaseRestClient):
if fee > 0:
fee_ccy = "USD"
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
if status.lower() in ("done", "rejected", "canceled", "cancelled"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
@@ -18,9 +18,8 @@ import hashlib
import hmac
import json
import time
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
import requests
@@ -31,11 +30,11 @@ from app.services.live_trading.symbols import to_deepcoin_symbol
class DeepcoinClient(BaseRestClient):
"""
Deepcoin REST client for spot and perpetual swap trading.
Based on official Deepcoin Python SDK.
Supports both spot and swap (perpetual futures) markets.
"""
def __init__(
self,
*,
@@ -53,7 +52,7 @@ class DeepcoinClient(BaseRestClient):
self.market_type = (market_type or "swap").strip().lower()
if self.market_type not in ("swap", "spot"):
self.market_type = "swap"
if not self.api_key or not self.secret_key:
raise LiveTradingError("Missing Deepcoin api_key/secret_key")
@@ -78,7 +77,7 @@ class DeepcoinClient(BaseRestClient):
"""
Convert Decimal to string with controlled precision.
Deepcoin requires quantities to match lotSz/qtyStep precision.
Args:
d: Decimal value to format
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
@@ -88,24 +87,25 @@ class DeepcoinClient(BaseRestClient):
if d == 0:
return "0"
normalized = d.normalize()
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
from decimal import ROUND_DOWN
q = Decimal("1").scaleb(-prec)
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
s = format(quantized, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
try:
@@ -117,18 +117,18 @@ class DeepcoinClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
if "e" in s.lower() or "E" in s:
try:
f = float(s)
if strict_precision is not None:
@@ -136,14 +136,14 @@ class DeepcoinClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
except Exception:
pass
return s if s else "0"
@@ -194,23 +194,23 @@ class DeepcoinClient(BaseRestClient):
def _sign(self, iso_time: str, method: str, uri: str, data: Optional[Dict[str, Any]] = None) -> str:
"""
Generate HMAC-SHA256 signature for request authentication.
For POST: message = timestamp + method + uri + json_body
For GET: message = timestamp + method + uri (with query params)
"""
method_upper = method.upper()
if method_upper == "POST" and data:
# Convert dict to JSON string with double quotes
data_str = json.dumps(data, separators=(',', ':'))
data_str = json.dumps(data, separators=(",", ":"))
message = f"{iso_time}{method_upper}{uri}{data_str}"
else:
message = f"{iso_time}{method_upper}{uri}"
message_bytes = message.encode('utf-8')
key_bytes = self.secret_key.encode('utf-8')
sign = base64.b64encode(
hmac.new(key=key_bytes, msg=message_bytes, digestmod=hashlib.sha256).digest()
).decode('utf-8')
message_bytes = message.encode("utf-8")
key_bytes = self.secret_key.encode("utf-8")
sign = base64.b64encode(hmac.new(key=key_bytes, msg=message_bytes, digestmod=hashlib.sha256).digest()).decode(
"utf-8"
)
return sign
def _headers(self, iso_time: str, sign: str) -> Dict[str, str]:
@@ -233,16 +233,16 @@ class DeepcoinClient(BaseRestClient):
"""
full_uri = self._build_uri_with_params(uri, params, method)
url = f"{self.base_url}{full_uri}"
try:
if method.upper() == "GET":
resp = requests.get(url=url, timeout=self.timeout_sec)
else:
resp = requests.post(url=url, json=params, timeout=self.timeout_sec)
if resp.status_code >= 400:
raise LiveTradingError(f"Deepcoin HTTP {resp.status_code}: {resp.text[:500]}")
data = resp.json()
if isinstance(data, dict):
code = data.get("code") or data.get("retCode")
@@ -261,35 +261,35 @@ class DeepcoinClient(BaseRestClient):
) -> Dict[str, Any]:
"""
Make authenticated API request following Deepcoin signing spec.
For GET requests: params are appended to URI as query string
For POST requests: params are sent as JSON body
"""
iso_time = self._get_iso_time()
method_upper = method.upper()
# Build full URI (with query params for GET)
full_uri = self._build_uri_with_params(uri, params, method)
# Generate signature
if method_upper == "POST":
sign = self._sign(iso_time, method_upper, uri, params)
else:
sign = self._sign(iso_time, method_upper, full_uri, None)
headers = self._headers(iso_time, sign)
url = f"{self.base_url}{full_uri}"
try:
if method_upper == "POST":
body_str = json.dumps(params, separators=(',', ':')) if params else ""
body_str = json.dumps(params, separators=(",", ":")) if params else ""
resp = requests.post(url=url, headers=headers, data=body_str, timeout=self.timeout_sec)
else:
resp = requests.get(url=url, headers=headers, timeout=self.timeout_sec)
if resp.status_code >= 400:
raise LiveTradingError(f"Deepcoin HTTP {resp.status_code}: {resp.text[:500]}")
data = resp.json()
if isinstance(data, dict):
code = data.get("code") or data.get("retCode")
@@ -314,7 +314,7 @@ class DeepcoinClient(BaseRestClient):
def get_balance(self) -> Dict[str, Any]:
"""
Get account balance.
Endpoint: GET /deepcoin/account/balances
"""
params = {"instType": "SWAP" if self.market_type == "swap" else "SPOT"}
@@ -323,7 +323,7 @@ class DeepcoinClient(BaseRestClient):
def get_positions(self, *, symbol: str = "") -> Dict[str, Any]:
"""
Get open positions.
Endpoint: GET /deepcoin/account/positions
"""
params: Dict[str, Any] = {"instType": "SWAP" if self.market_type == "swap" else "SPOT"}
@@ -334,20 +334,20 @@ class DeepcoinClient(BaseRestClient):
def set_leverage(self, *, symbol: str, leverage: float, mgn_mode: str = "cross") -> bool:
"""
Set leverage for a trading pair.
Endpoint: POST /deepcoin/account/set-leverage
"""
sym = to_deepcoin_symbol(symbol)
if not sym:
return False
try:
lv = int(float(leverage or 1.0))
except Exception:
lv = 1
if lv < 1:
lv = 1
mm = str(mgn_mode or "cross").strip().lower()
if mm not in ("cross", "isolated"):
mm = "cross"
@@ -367,7 +367,7 @@ class DeepcoinClient(BaseRestClient):
"mgnMode": mm,
"mrgPosition": "merge",
}
try:
self._signed_request("POST", "/deepcoin/account/set-leverage", params=params)
self._lev_cache[cache_key] = (now, True)
@@ -378,13 +378,13 @@ class DeepcoinClient(BaseRestClient):
def get_instrument_info(self, *, symbol: str) -> Dict[str, Any]:
"""
Get instrument metadata (min qty, qty step, etc.).
Endpoint: GET /deepcoin/market/instruments
"""
sym = to_deepcoin_symbol(symbol)
if not sym:
return {}
key = f"{self.market_type}:{sym}"
now = time.time()
cached = self._inst_cache.get(key)
@@ -395,7 +395,7 @@ class DeepcoinClient(BaseRestClient):
inst_type = "SWAP" if self.market_type == "swap" else "SPOT"
params = {"instType": inst_type, "instId": sym}
try:
raw = self._public_request("GET", "/deepcoin/market/instruments", params=params)
data = (raw.get("data") or []) if isinstance(raw, dict) else []
@@ -409,35 +409,35 @@ class DeepcoinClient(BaseRestClient):
def _normalize_qty(self, *, symbol: str, qty: float) -> Tuple[Decimal, Optional[int]]:
"""
Normalize order quantity to exchange requirements.
Returns:
Tuple of (normalized_quantity, precision) where precision is the number of decimal places required.
"""
q = self._to_dec(qty)
if q <= 0:
return (Decimal("0"), None)
sym = to_deepcoin_symbol(symbol)
try:
info = self.get_instrument_info(symbol=sym) or {}
except Exception:
info = {}
# Extract lot size filter
step = self._to_dec(info.get("lotSz") or info.get("qtyStep") or "0")
mn = self._to_dec(info.get("minSz") or info.get("minOrderQty") or "0")
if step > 0:
q = self._floor_to_step(q, step)
# Infer precision from step
qty_precision = None
if step > 0:
try:
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
decimal_part = step_str.split('.')[1]
if "." in step_str:
decimal_part = step_str.split(".")[1]
qty_precision = len(decimal_part)
if qty_precision < 0:
qty_precision = 0
@@ -447,7 +447,7 @@ class DeepcoinClient(BaseRestClient):
qty_precision = 0
except Exception:
pass
if mn > 0 and q < mn:
return (Decimal("0"), qty_precision)
return (q, qty_precision)
@@ -464,9 +464,9 @@ class DeepcoinClient(BaseRestClient):
) -> LiveOrderResult:
"""
Place a market order.
Endpoint: POST /deepcoin/trade/order
Args:
symbol: Trading pair (e.g., "BTC/USDT:USDT" or "BTCUSDT")
side: "buy" or "sell"
@@ -479,7 +479,7 @@ class DeepcoinClient(BaseRestClient):
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
q_req = float(qty or 0.0)
q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
if float(q_dec or 0) <= 0:
@@ -492,7 +492,7 @@ class DeepcoinClient(BaseRestClient):
"ordType": "market",
"sz": self._dec_str(q_dec, strict_precision=qty_precision),
}
if self.market_type != "spot":
ps = (pos_side or "").strip().lower()
if ps in ("long", "short", "net"):
@@ -507,7 +507,7 @@ class DeepcoinClient(BaseRestClient):
data = (raw.get("data") or []) if isinstance(raw, dict) else []
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
oid = str(first.get("ordId") or first.get("orderId") or first.get("clOrdId") or "")
return LiveOrderResult(
exchange_id="deepcoin",
exchange_order_id=oid,
@@ -529,19 +529,19 @@ class DeepcoinClient(BaseRestClient):
) -> LiveOrderResult:
"""
Place a limit order.
Endpoint: POST /deepcoin/trade/order
"""
sym = to_deepcoin_symbol(symbol)
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
q_req = float(qty or 0.0)
px = float(price or 0.0)
if q_req <= 0 or px <= 0:
raise LiveTradingError("Invalid qty/price")
q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
@@ -554,7 +554,7 @@ class DeepcoinClient(BaseRestClient):
"sz": self._dec_str(q_dec, strict_precision=qty_precision),
"px": str(px),
}
if self.market_type != "spot":
ps = (pos_side or "").strip().lower()
if ps in ("long", "short", "net"):
@@ -569,7 +569,7 @@ class DeepcoinClient(BaseRestClient):
data = (raw.get("data") or []) if isinstance(raw, dict) else []
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
oid = str(first.get("ordId") or first.get("orderId") or first.get("clOrdId") or "")
return LiveOrderResult(
exchange_id="deepcoin",
exchange_order_id=oid,
@@ -581,12 +581,12 @@ class DeepcoinClient(BaseRestClient):
def cancel_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
"""
Cancel an order.
Endpoint: POST /deepcoin/trade/cancel-order
"""
sym = to_deepcoin_symbol(symbol)
params: Dict[str, Any] = {"instId": sym}
if order_id:
params["ordId"] = str(order_id)
elif client_order_id:
@@ -599,12 +599,12 @@ class DeepcoinClient(BaseRestClient):
def get_order(self, *, symbol: str, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
"""
Get order details.
Endpoint: GET /deepcoin/trade/order
"""
sym = to_deepcoin_symbol(symbol)
params: Dict[str, Any] = {"instId": sym}
if order_id:
params["ordId"] = str(order_id)
elif client_order_id:
@@ -620,7 +620,7 @@ class DeepcoinClient(BaseRestClient):
def get_open_orders(self, *, symbol: str = "") -> Dict[str, Any]:
"""
Get open orders.
Endpoint: GET /deepcoin/trade/orders-pending
"""
params: Dict[str, Any] = {"instType": "SWAP" if self.market_type == "swap" else "SPOT"}
@@ -631,7 +631,7 @@ class DeepcoinClient(BaseRestClient):
def get_order_history(self, *, symbol: str = "", limit: int = 100) -> Dict[str, Any]:
"""
Get order history.
Endpoint: GET /deepcoin/trade/orders-history
"""
params: Dict[str, Any] = {
@@ -653,7 +653,7 @@ class DeepcoinClient(BaseRestClient):
) -> Dict[str, Any]:
"""
Poll order status until filled or timeout.
Returns:
{
"filled": float,
@@ -666,7 +666,7 @@ class DeepcoinClient(BaseRestClient):
"""
end_ts = time.time() + float(max_wait_sec or 0.0)
last: Dict[str, Any] = {}
while True:
try:
last = self.get_order(
@@ -676,19 +676,19 @@ class DeepcoinClient(BaseRestClient):
)
except Exception:
last = last or {}
status = str(last.get("state") or last.get("status") or last.get("orderStatus") or "")
try:
filled = float(last.get("accFillSz") or last.get("fillSz") or last.get("cumExecQty") or 0.0)
except Exception:
filled = 0.0
try:
avg_price = float(last.get("avgPx") or last.get("fillPx") or last.get("avgPrice") or 0.0)
except Exception:
avg_price = 0.0
# Extract fee
fee = 0.0
fee_ccy = ""
@@ -697,7 +697,7 @@ class DeepcoinClient(BaseRestClient):
fee_ccy = str(last.get("feeCcy") or "")
except Exception:
pass
if filled > 0 and avg_price > 0:
return {
"filled": filled,
@@ -707,7 +707,7 @@ class DeepcoinClient(BaseRestClient):
"status": status,
"order": last,
}
if status.lower() in ("filled", "cancelled", "canceled", "rejected"):
return {
"filled": filled,
@@ -717,7 +717,7 @@ class DeepcoinClient(BaseRestClient):
"status": status,
"order": last,
}
if time.time() >= end_ts:
return {
"filled": filled,
@@ -727,5 +727,5 @@ class DeepcoinClient(BaseRestClient):
"status": status,
"order": last,
}
time.sleep(float(poll_interval_sec or 0.5))
@@ -14,17 +14,16 @@ from typing import Any, Dict, Optional, Tuple
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
from app.services.live_trading.okx import OkxClient
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
from app.services.live_trading.bitget import BitgetMixClient
from app.services.live_trading.bitget_spot import BitgetSpotClient
from app.services.live_trading.bybit import BybitClient
from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.kraken import KrakenClient
from app.services.live_trading.kraken_futures import KrakenFuturesClient
from app.services.live_trading.kucoin import KucoinSpotClient
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, BitfinexDerivativesClient
from app.services.live_trading.kucoin import KucoinFuturesClient, KucoinSpotClient
from app.services.live_trading.okx import OkxClient
# Lazy import Deepcoin
DeepcoinClient = None
@@ -42,43 +41,43 @@ MT5Client = None
def _normalize_symbol_for_order(symbol: str, market_type: str = "swap") -> str:
"""
Standardize symbol formats to ensure symbols comply with exchange requirements.
Handles various input formats:
- BTC/USDT -> BTC/USDT
- BTCUSDT -> BTC/USDT
- BTC/USDT:USDT -> BTC/USDT
- PI, TRX -> PI/USDT, TRX/USDT (/USDT is added by default)
Args:
symbol: original symbol
market_type: market type (spot/swap)
Returns:
normalized symbols
"""
if not symbol:
return symbol
sym = symbol.strip()
# Remove swap/futures suffix
if ':' in sym:
sym = sym.split(':', 1)[0]
if ":" in sym:
sym = sym.split(":", 1)[0]
sym = sym.upper()
# If there is already a separator, return it directly (assuming the format is correct)
if '/' in sym:
if "/" in sym:
return sym
# Try to identify from common quote currencies
common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC']
common_quotes = ["USDT", "USD", "BTC", "ETH", "BUSD", "USDC"]
for quote in common_quotes:
if sym.endswith(quote) and len(sym) > len(quote):
base = sym[:-len(quote)]
base = sym[: -len(quote)]
if base:
return f"{base}/{quote}"
# If not recognized, USDT will be used by default.
return f"{sym}/USDT"
@@ -113,7 +112,9 @@ def _quote_amount_from_base_qty(client: BaseRestClient, *, symbol: str, base_qty
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)
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:
@@ -147,7 +148,7 @@ def place_order_from_signal(
# Spot does not support short signals in this system.
if mt == "spot" and ("short" in (signal_type or "").lower()):
raise LiveTradingError("spot market does not support short signals")
# Standardized symbol format (unified processing of bare symbols such as PI, TRX, etc.)
symbol = _normalize_symbol_for_order(symbol, market_type=mt)
@@ -161,7 +162,7 @@ def place_order_from_signal(
client_order_id=client_order_id,
)
if isinstance(client, OkxClient):
td_mode = (cfg.get("margin_mode") or cfg.get("td_mode") or "cross")
td_mode = cfg.get("margin_mode") or cfg.get("td_mode") or "cross"
return client.place_market_order(
symbol=symbol,
side=side,
@@ -222,28 +223,37 @@ def place_order_from_signal(
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)
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)
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):
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)
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):
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
if isinstance(client, BitfinexDerivativesClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
if isinstance(client, KrakenFuturesClient):
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
return client.place_market_order(
symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id
)
# Check for Deepcoin client (lazy import to avoid circular dependency)
global DeepcoinClient
if DeepcoinClient is None:
try:
from app.services.live_trading.deepcoin import DeepcoinClient as _DeepcoinClient
DeepcoinClient = _DeepcoinClient
except ImportError:
pass
@@ -262,6 +272,7 @@ def place_order_from_signal(
if HtxClient is None:
try:
from app.services.live_trading.htx import HtxClient as _HtxClient
HtxClient = _HtxClient
except ImportError:
pass
@@ -281,6 +292,7 @@ def place_order_from_signal(
if IBKRClient is None:
try:
from app.services.ibkr_trading import IBKRClient as _IBKRClient
IBKRClient = _IBKRClient
except ImportError:
pass
@@ -299,6 +311,7 @@ def place_order_from_signal(
if MT5Client is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client
MT5Client = _MT5Client
except ImportError:
pass
@@ -404,8 +417,9 @@ def _place_mt5_order(
# Normalize symbol before placing order (MT5 requires specific format)
from app.services.mt5_trading.symbols import normalize_symbol
normalized_symbol = normalize_symbol(symbol)
# Place market order
result = client.place_market_order(
symbol=normalized_symbol,
@@ -427,4 +441,3 @@ def _place_mt5_order(
"raw": result.raw,
},
)
@@ -9,23 +9,23 @@ Supports:
from __future__ import annotations
from typing import Any, Dict, Union
from typing import Any, Dict
from app.services.live_trading.base import BaseRestClient, LiveTradingError
from app.services.live_trading.binance import BinanceFuturesClient
from app.services.live_trading.binance_spot import BinanceSpotClient
from app.services.live_trading.okx import OkxClient
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
from app.services.live_trading.bitget import BitgetMixClient
from app.services.live_trading.bitget_spot import BitgetSpotClient
from app.services.live_trading.bybit import BybitClient
from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient
from app.services.live_trading.deepcoin import DeepcoinClient
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
from app.services.live_trading.htx import HtxClient
from app.services.live_trading.kraken import KrakenClient
from app.services.live_trading.kraken_futures import KrakenFuturesClient
from app.services.live_trading.kucoin import KucoinSpotClient, KucoinFuturesClient
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
from app.services.live_trading.kucoin import KucoinFuturesClient, KucoinSpotClient
from app.services.live_trading.okx import OkxClient
# Lazy import IBKR to avoid ImportError if ib_insync not installed
IBKRClient = None
@@ -62,7 +62,11 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
secret_key = _get(exchange_config, "secret_key", "secret")
passphrase = _get(exchange_config, "passphrase", "password")
mt = (market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap").strip().lower()
mt = (
(market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap")
.strip()
.lower()
)
if mt in ("futures", "future", "perp", "perpetual"):
mt = "swap"
@@ -70,15 +74,29 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
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"
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, broker_id=spot_broker_id)
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, broker_id=futures_broker_id)
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"
@@ -140,7 +158,9 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
)
if exchange_id in ("coinbaseexchange", "coinbase_exchange"):
default_cb = "https://api-public.sandbox.exchange.coinbase.com" if is_demo else "https://api.exchange.coinbase.com"
default_cb = (
"https://api-public.sandbox.exchange.coinbase.com" if is_demo else "https://api.exchange.coinbase.com"
)
base_url = _get(exchange_config, "base_url", "baseUrl") or default_cb
if mt != "spot":
raise LiveTradingError("CoinbaseExchange only supports spot market_type in this project")
@@ -172,7 +192,9 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
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, channel_id=gate_channel_id)
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.
@@ -183,7 +205,9 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
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.")
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,
@@ -194,8 +218,12 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
)
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.")
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"
@@ -238,7 +266,9 @@ def create_ibkr_client(exchange_config: Dict[str, Any]):
# Lazy import to avoid ImportError if ib_insync not installed
if IBKRClient is None or IBKRConfig is None:
try:
from app.services.ibkr_trading import IBKRClient as _IBKRClient, IBKRConfig as _IBKRConfig
from app.services.ibkr_trading import IBKRClient as _IBKRClient
from app.services.ibkr_trading import IBKRConfig as _IBKRConfig
IBKRClient = _IBKRClient
IBKRConfig = _IBKRConfig
except ImportError:
@@ -276,7 +306,7 @@ def create_mt5_client(exchange_config: Dict[str, Any]):
- mt5_server: Broker server name (e.g., "ICMarkets-Demo")
- mt5_terminal_path: Optional path to terminal64.exe
- market_category: Must be "Forex" (validated)
Note: MT5 is ONLY for Forex trading, not for Crypto or Stocks.
"""
global MT5Client, MT5Config
@@ -292,7 +322,9 @@ def create_mt5_client(exchange_config: Dict[str, Any]):
# Lazy import to avoid ImportError if MetaTrader5 not installed
if MT5Client is None or MT5Config is None:
try:
from app.services.mt5_trading import MT5Client as _MT5Client, MT5Config as _MT5Config
from app.services.mt5_trading import MT5Client as _MT5Client
from app.services.mt5_trading import MT5Config as _MT5Config
MT5Client = _MT5Client
MT5Config = _MT5Config
except ImportError:
@@ -311,7 +343,7 @@ def create_mt5_client(exchange_config: Dict[str, Any]):
login = int(str(login_raw).strip())
except (ValueError, TypeError):
login = 0
password = str(exchange_config.get("mt5_password") or "").strip()
server = str(exchange_config.get("mt5_server") or "").strip()
terminal_path = str(exchange_config.get("mt5_terminal_path") or "").strip()
@@ -338,5 +370,3 @@ def create_mt5_client(exchange_config: Dict[str, Any]):
)
return client
@@ -17,8 +17,8 @@ import hashlib
import hmac
import logging
import time
from decimal import Decimal, ROUND_DOWN, ROUND_UP
from typing import Any, Dict, Optional, Tuple, Union
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
@@ -53,7 +53,15 @@ def _gate_ticker_response_to_normalized(raw: Any) -> Dict[str, Any]:
class _GateBase(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.gateio.ws", timeout_sec: float = 15.0, channel_id: str = ""):
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()
@@ -137,7 +145,9 @@ class GateSpotClient(_GateBase):
def get_accounts(self) -> Any:
return self._signed_request("GET", "/api/v4/spot/accounts")
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
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"):
raise LiveTradingError(f"Invalid side: {side}")
@@ -158,9 +168,17 @@ class GateSpotClient(_GateBase):
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})
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},
)
def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
def place_market_order(
self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None
) -> LiveOrderResult:
sd = (side or "").strip().lower()
if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}")
@@ -178,7 +196,13 @@ class GateSpotClient(_GateBase):
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})
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},
)
def cancel_order(self, *, order_id: str) -> Any:
if not order_id:
@@ -190,7 +214,9 @@ class GateSpotClient(_GateBase):
raise LiveTradingError("Gate spot get_order requires order_id")
return self._signed_request("GET", f"/api/v4/spot/orders/{str(order_id)}")
def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]:
def wait_for_fill(
self, *, order_id: str, max_wait_sec: float = 10.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:
@@ -221,17 +247,48 @@ class GateSpotClient(_GateBase):
fee = 0.0
fee_ccy = str(last.get("fee_currency") or "").strip()
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
if status.lower() in ("closed", "cancelled", "canceled"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
class GateUsdtFuturesClient(_GateBase):
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)
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
@@ -276,9 +333,12 @@ class GateUsdtFuturesClient(_GateBase):
if obj and (now - float(ts or 0.0)) <= float(self._contract_cache_ttl_sec or 300.0):
return obj
code, data, text = self._request(
"GET", f"/api/v4/futures/usdt/contracts/{c}",
params=None, headers={"X-Gate-Size-Decimal": "1"},
json_body=None, data=None,
"GET",
f"/api/v4/futures/usdt/contracts/{c}",
params=None,
headers={"X-Gate-Size-Decimal": "1"},
json_body=None,
data=None,
)
if code >= 400:
raise LiveTradingError(f"Gate HTTP {code}: {text[:500]}")
@@ -293,7 +353,9 @@ class GateUsdtFuturesClient(_GateBase):
sign, digits, exponent = d.as_tuple()
return max(0, -int(exponent))
def _resolve_order_size(self, *, contract: str, side: str, base_size: float) -> Tuple[str, Optional[Dict[str, str]]]:
def _resolve_order_size(
self, *, contract: str, side: str, base_size: float
) -> Tuple[str, Optional[Dict[str, str]]]:
"""
Convert base-asset qty to a signed Gate ``size`` string and determine whether to use
the ``X-Gate-Size-Decimal`` header.
@@ -337,8 +399,7 @@ class GateUsdtFuturesClient(_GateBase):
s = format(signed_q, "f")
if "." in s:
s = s.rstrip("0").rstrip(".")
return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0",
{"X-Gate-Size-Decimal": "1"})
return (s if s and s not in ("-", "+", "-0", "+0", "0") else "0", {"X-Gate-Size-Decimal": "1"})
else:
iv = int(self._floor(contracts))
int_min = max(1, int(order_min))
@@ -388,7 +449,8 @@ class GateUsdtFuturesClient(_GateBase):
def get_positions(self) -> Any:
return self._signed_request(
"GET", "/api/v4/futures/usdt/positions",
"GET",
"/api/v4/futures/usdt/positions",
extra_headers={"X-Gate-Size-Decimal": "1"},
)
@@ -442,8 +504,14 @@ class GateUsdtFuturesClient(_GateBase):
size_str, extra_headers = self._resolve_order_size(contract=contract, side=sd, base_size=base_qty)
if size_str in ("0", "-0", ""):
raise LiveTradingError("Invalid size (resolved contracts == 0)")
logger.info("Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s",
contract, sd, base_qty, size_str, extra_headers is not None)
logger.info(
"Gate futures market: contract=%s side=%s base_qty=%s size_str=%s decimal_hdr=%s",
contract,
sd,
base_qty,
size_str,
extra_headers is not None,
)
body: Dict[str, Any] = {"contract": contract, "size": size_str, "price": "0", "tif": "ioc"}
if reduce_only:
body["reduce_only"] = True
@@ -457,7 +525,13 @@ class GateUsdtFuturesClient(_GateBase):
extra_headers=extra_headers,
)
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})
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},
)
def place_limit_order(
self,
@@ -495,7 +569,13 @@ class GateUsdtFuturesClient(_GateBase):
extra_headers=extra_headers,
)
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})
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},
)
def cancel_order(self, *, order_id: str) -> Any:
if not order_id:
@@ -507,7 +587,9 @@ class GateUsdtFuturesClient(_GateBase):
raise LiveTradingError("Gate futures get_order requires order_id")
return self._signed_request("GET", f"/api/v4/futures/usdt/orders/{str(order_id)}")
def wait_for_fill(self, *, order_id: str, contract: str, max_wait_sec: float = 3.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]:
def wait_for_fill(
self, *, order_id: str, contract: 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] = {}
qm = Decimal("1")
@@ -548,11 +630,30 @@ class GateUsdtFuturesClient(_GateBase):
if fee > 0:
fee_ccy = "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}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
if str(status).lower() in ("finished", "cancelled", "canceled"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
@@ -11,15 +11,14 @@ References:
from __future__ import annotations
import base64
import datetime
import hashlib
import hmac
from decimal import Decimal, ROUND_DOWN
import logging
import time
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode, urlparse
import datetime
import time
import logging
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
@@ -125,7 +124,9 @@ class HtxClient(BaseRestClient):
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]:
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:
@@ -138,7 +139,14 @@ class HtxClient(BaseRestClient):
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]:
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
@@ -152,7 +160,14 @@ class HtxClient(BaseRestClient):
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]:
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
@@ -166,7 +181,9 @@ class HtxClient(BaseRestClient):
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]:
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:
@@ -198,7 +215,10 @@ class HtxClient(BaseRestClient):
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", ""):
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
@@ -219,7 +239,9 @@ class HtxClient(BaseRestClient):
return self._spot_private_request("GET", f"/v1/account/accounts/{account_id}/balance")
# 1) v1 cross
try:
raw = self._swap_private_request("POST", "/linear-swap-api/v1/swap_cross_account_info", json_body={"margin_account": "USDT"})
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
@@ -262,13 +284,15 @@ class HtxClient(BaseRestClient):
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,
})
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 {}
@@ -304,9 +328,13 @@ class HtxClient(BaseRestClient):
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)})
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)})
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 {}
@@ -516,7 +544,11 @@ class HtxClient(BaseRestClient):
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)})
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)}
@@ -535,7 +567,9 @@ class HtxClient(BaseRestClient):
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)})
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")
@@ -566,7 +600,12 @@ class HtxClient(BaseRestClient):
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 {}
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 {}
@@ -577,22 +616,22 @@ class HtxClient(BaseRestClient):
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
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
)
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)
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:
@@ -602,9 +641,30 @@ class HtxClient(BaseRestClient):
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}
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}
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}
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))
@@ -24,7 +24,9 @@ from app.services.live_trading.symbols import to_kraken_pair
class KrakenClient(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://api.kraken.com", timeout_sec: float = 15.0):
def __init__(
self, *, api_key: str, secret_key: str, base_url: str = "https://api.kraken.com", timeout_sec: float = 15.0
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
@@ -105,9 +107,17 @@ class KrakenClient(BaseRestClient):
pass
return self._signed_request("POST", "/0/private/AddOrder", data=body)
def place_market_order(self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
def place_market_order(
self, *, symbol: str, side: str, size: float, client_order_id: Optional[str] = None
) -> LiveOrderResult:
pair = to_kraken_pair(symbol)
raw = self.add_order(pair=pair, side=side, ordertype="market", volume=float(size or 0.0), client_order_id=str(client_order_id or ""))
raw = self.add_order(
pair=pair,
side=side,
ordertype="market",
volume=float(size or 0.0),
client_order_id=str(client_order_id or ""),
)
txid = ""
try:
tx = ((raw.get("result") or {}).get("txid")) if isinstance(raw, dict) else None
@@ -117,9 +127,18 @@ class KrakenClient(BaseRestClient):
txid = ""
return LiveOrderResult(exchange_id="kraken", exchange_order_id=txid, filled=0.0, avg_price=0.0, raw=raw)
def place_limit_order(self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None) -> LiveOrderResult:
def place_limit_order(
self, *, symbol: str, side: str, size: float, price: float, client_order_id: Optional[str] = None
) -> LiveOrderResult:
pair = to_kraken_pair(symbol)
raw = self.add_order(pair=pair, side=side, ordertype="limit", volume=float(size or 0.0), price=float(price or 0.0), client_order_id=str(client_order_id or ""))
raw = self.add_order(
pair=pair,
side=side,
ordertype="limit",
volume=float(size or 0.0),
price=float(price or 0.0),
client_order_id=str(client_order_id or ""),
)
txid = ""
try:
tx = ((raw.get("result") or {}).get("txid")) if isinstance(raw, dict) else None
@@ -142,7 +161,9 @@ class KrakenClient(BaseRestClient):
od = (res.get(str(order_id)) if isinstance(res, dict) else None) or {}
return od if isinstance(od, dict) else {}
def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]:
def wait_for_fill(
self, *, order_id: str, max_wait_sec: float = 10.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:
@@ -175,11 +196,30 @@ class KrakenClient(BaseRestClient):
if fee > 0:
fee_ccy = "USD"
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
if status.lower() in ("closed", "canceled", "cancelled", "expired"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
@@ -29,7 +29,9 @@ from app.services.live_trading.symbols import to_kraken_futures_symbol
class KrakenFuturesClient(BaseRestClient):
def __init__(self, *, api_key: str, secret_key: str, base_url: str = "https://futures.kraken.com", timeout_sec: float = 15.0):
def __init__(
self, *, api_key: str, secret_key: str, base_url: str = "https://futures.kraken.com", timeout_sec: float = 15.0
):
super().__init__(base_url=base_url, timeout_sec=timeout_sec)
self.api_key = (api_key or "").strip()
self.secret_key = (secret_key or "").strip()
@@ -41,7 +43,12 @@ class KrakenFuturesClient(BaseRestClient):
return base64.b64encode(mac).decode("utf-8")
def _headers(self, nonce: str, authent: str) -> Dict[str, str]:
return {"APIKey": self.api_key, "Nonce": nonce, "Authent": authent, "Content-Type": "application/x-www-form-urlencoded"}
return {
"APIKey": self.api_key,
"Nonce": nonce,
"Authent": authent,
"Content-Type": "application/x-www-form-urlencoded",
}
def _signed_request(self, method: str, path: str, *, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
m = str(method or "POST").upper()
@@ -52,7 +59,14 @@ class KrakenFuturesClient(BaseRestClient):
# Sign with endpoint path (not including domain)
prehash = f"{nonce}{postdata}{path}"
authent = self._b64_hmac_sha256(prehash)
code, resp, text = self._request(m, path, params=None, json_body=None, data=postdata if postdata else None, headers=self._headers(nonce, authent))
code, resp, text = self._request(
m,
path,
params=None,
json_body=None,
data=postdata if postdata else None,
headers=self._headers(nonce, authent),
)
if code >= 400:
raise LiveTradingError(f"KrakenFutures HTTP {code}: {text[:500]}")
if isinstance(resp, dict):
@@ -109,7 +123,11 @@ class KrakenFuturesClient(BaseRestClient):
if client_order_id:
body["cliOrdId"] = str(client_order_id)[:32]
raw = self._signed_request("POST", "/derivatives/api/v3/sendorder", data=body)
oid = str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or "")) if isinstance(raw, dict) else ""
oid = (
str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or ""))
if isinstance(raw, dict)
else ""
)
return LiveOrderResult(exchange_id="kraken", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def place_limit_order(
@@ -145,7 +163,11 @@ class KrakenFuturesClient(BaseRestClient):
if client_order_id:
body["cliOrdId"] = str(client_order_id)[:32]
raw = self._signed_request("POST", "/derivatives/api/v3/sendorder", data=body)
oid = str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or "")) if isinstance(raw, dict) else ""
oid = (
str((raw.get("sendStatus") or {}).get("order_id") or (raw.get("order_id") or ""))
if isinstance(raw, dict)
else ""
)
return LiveOrderResult(exchange_id="kraken", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw)
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Dict[str, Any]:
@@ -205,11 +227,30 @@ class KrakenFuturesClient(BaseRestClient):
if fee > 0:
fee_ccy = "USD"
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
if status.lower() in ("filled", "cancelled", "canceled", "rejected"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
@@ -13,7 +13,7 @@ import base64
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
@@ -54,7 +54,14 @@ class KucoinSpotClient(BaseRestClient):
"Content-Type": "application/json",
}
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
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()
ts_ms = str(int(time.time() * 1000))
body_str = self._json_dumps(json_body) if json_body is not None else ""
@@ -65,7 +72,9 @@ class KucoinSpotClient(BaseRestClient):
signed_path = f"{path}?{qs}" if qs else path
prehash = f"{ts_ms}{m}{signed_path}{body_str}"
sign = self._b64_hmac_sha256(self.secret_key, prehash)
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign))
code, data, text = self._request(
m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign)
)
if code >= 400:
raise LiveTradingError(f"KuCoin HTTP {code}: {text[:500]}")
return data
@@ -87,11 +96,15 @@ class KucoinSpotClient(BaseRestClient):
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)})
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:
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"):
raise LiveTradingError(f"Invalid side: {side}")
@@ -116,7 +129,13 @@ class KucoinSpotClient(BaseRestClient):
oid = str(d.get("orderId") or "")
elif isinstance(d, str):
oid = str(d)
return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
return LiveOrderResult(
exchange_id="kucoin",
exchange_order_id=oid,
filled=0.0,
avg_price=0.0,
raw=raw if isinstance(raw, dict) else {"raw": raw},
)
def place_market_order(
self,
@@ -156,7 +175,13 @@ class KucoinSpotClient(BaseRestClient):
oid = str(d.get("orderId") or "")
elif isinstance(d, str):
oid = str(d)
return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
return LiveOrderResult(
exchange_id="kucoin",
exchange_order_id=oid,
filled=0.0,
avg_price=0.0,
raw=raw if isinstance(raw, dict) else {"raw": raw},
)
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
if order_id:
@@ -175,7 +200,9 @@ class KucoinSpotClient(BaseRestClient):
def get_fills(self, *, order_id: str) -> Any:
return self._signed_request("GET", "/api/v1/fills", params={"orderId": str(order_id)})
def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 10.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]:
def wait_for_fill(
self, *, order_id: str, max_wait_sec: float = 10.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:
@@ -207,16 +234,37 @@ class KucoinSpotClient(BaseRestClient):
fee = 0.0
fee_ccy = str(od.get("feeCurrency") or "").strip()
if filled > 0 and avg_price > 0:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
# If order is inactive, consider it terminal
try:
is_active = bool(od.get("isActive"))
except Exception:
is_active = False
if not is_active:
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
@@ -266,7 +314,14 @@ class KucoinFuturesClient(BaseRestClient):
"Content-Type": "application/json",
}
def _signed_request(self, method: str, path: str, *, params: Optional[Dict[str, Any]] = None, json_body: Optional[Dict[str, Any]] = None) -> Any:
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()
ts_ms = str(int(time.time() * 1000))
body_str = self._json_dumps(json_body) if json_body is not None else ""
@@ -277,7 +332,9 @@ class KucoinFuturesClient(BaseRestClient):
signed_path = f"{path}?{qs}" if qs else path
prehash = f"{ts_ms}{m}{signed_path}{body_str}"
sign = self._b64_hmac_sha256(self.secret_key, prehash)
code, data, text = self._request(m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign))
code, data, text = self._request(
m, path, params=params, data=body_str if body_str else None, headers=self._headers(ts_ms, sign)
)
if code >= 400:
raise LiveTradingError(f"KuCoinFutures HTTP {code}: {text[:500]}")
return data
@@ -406,7 +463,13 @@ class KucoinFuturesClient(BaseRestClient):
oid = str(d.get("orderId") or "")
elif isinstance(d, str):
oid = str(d)
return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
return LiveOrderResult(
exchange_id="kucoin",
exchange_order_id=oid,
filled=0.0,
avg_price=0.0,
raw=raw if isinstance(raw, dict) else {"raw": raw},
)
def place_limit_order(
self,
@@ -451,7 +514,13 @@ class KucoinFuturesClient(BaseRestClient):
oid = str(d.get("orderId") or "")
elif isinstance(d, str):
oid = str(d)
return LiveOrderResult(exchange_id="kucoin", exchange_order_id=oid, filled=0.0, avg_price=0.0, raw=raw if isinstance(raw, dict) else {"raw": raw})
return LiveOrderResult(
exchange_id="kucoin",
exchange_order_id=oid,
filled=0.0,
avg_price=0.0,
raw=raw if isinstance(raw, dict) else {"raw": raw},
)
def cancel_order(self, *, order_id: str = "", client_order_id: str = "") -> Any:
if order_id:
@@ -464,10 +533,12 @@ class KucoinFuturesClient(BaseRestClient):
if order_id:
return self._signed_request("GET", f"/api/v1/orders/{str(order_id)}")
if client_order_id:
return self._signed_request("GET", f"/api/v1/orders/byClientOid", params={"clientOid": str(client_order_id)})
return self._signed_request("GET", "/api/v1/orders/byClientOid", params={"clientOid": str(client_order_id)})
raise LiveTradingError("KuCoinFutures get_order requires order_id or client_order_id")
def wait_for_fill(self, *, order_id: str, max_wait_sec: float = 3.0, poll_interval_sec: float = 0.5) -> Dict[str, Any]:
def wait_for_fill(
self, *, 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:
@@ -513,11 +584,30 @@ class KucoinFuturesClient(BaseRestClient):
if fee > 0:
fee_ccy = "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}
return {
"filled": filled,
"avg_price": avg_price,
"fee": fee,
"fee_ccy": fee_ccy,
"status": status,
"order": last,
}
if status.lower() in ("done", "canceled", "cancelled", "filled"):
return {"filled": filled, "avg_price": avg_price, "fee": fee, "fee_ccy": fee_ccy, "status": status, "order": last}
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}
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))
@@ -11,12 +11,12 @@ import base64
import hashlib
import hmac
import time
from decimal import Decimal, ROUND_DOWN
from decimal import ROUND_DOWN, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import urlencode
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
from app.services.live_trading.symbols import to_okx_swap_inst_id, to_okx_spot_inst_id
from app.services.live_trading.symbols import to_okx_spot_inst_id, to_okx_swap_inst_id
class OkxClient(BaseRestClient):
@@ -63,7 +63,7 @@ class OkxClient(BaseRestClient):
"""
Convert Decimal to a non-scientific string with controlled precision.
OKX expects plain decimal strings matching lotSz precision.
Args:
d: Decimal value to format
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
@@ -74,7 +74,7 @@ class OkxClient(BaseRestClient):
return "0"
# Normalize to remove unnecessary trailing zeros
normalized = d.normalize()
# If strict_precision is provided, use it and strictly limit decimal places
if strict_precision is not None:
try:
@@ -85,19 +85,20 @@ class OkxClient(BaseRestClient):
prec = 18
# Use quantize to ensure exact precision
from decimal import ROUND_DOWN
q = Decimal("1").scaleb(-prec)
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
s = format(quantized, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
# Format with max_decimals and remove trailing zeros
s = format(normalized, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
try:
@@ -109,18 +110,18 @@ class OkxClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
s = str(d)
if 'e' in s.lower() or 'E' in s:
if "e" in s.lower() or "E" in s:
try:
f = float(s)
if strict_precision is not None:
@@ -128,14 +129,14 @@ class OkxClient(BaseRestClient):
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
if "." in s:
s = s.rstrip("0").rstrip(".")
except Exception:
pass
return s if s else "0"
@@ -205,7 +206,7 @@ class OkxClient(BaseRestClient):
- Swap: OKX sz is in contracts; convert base qty -> contracts using ctVal, then align to lotSz/minSz.
Note: this system passes `amount` around as base-asset quantity across exchanges.
Returns:
Tuple of (normalized_size, precision) where precision is the number of decimal places required.
"""
@@ -235,15 +236,15 @@ class OkxClient(BaseRestClient):
# Align to lot size step.
if lot_sz > 0:
req = self._floor_to_step(req, lot_sz)
# Infer precision from lotSz
size_precision = None
if lot_sz > 0:
try:
lot_sz_normalized = lot_sz.normalize()
lot_sz_str = str(lot_sz_normalized)
if '.' in lot_sz_str:
decimal_part = lot_sz_str.split('.')[1]
if "." in lot_sz_str:
decimal_part = lot_sz_str.split(".")[1]
size_precision = len(decimal_part)
if size_precision < 0:
size_precision = 0
@@ -312,7 +313,7 @@ class OkxClient(BaseRestClient):
signed_path = f"{path}?{qs}" if qs else path
sign = self._sign(ts, method, signed_path, body_str)
# For GET requests with query params, we need to ensure the actual request URL matches the signed path
# OKX requires exact match between signed path and actual request path
if method.upper() == "GET" and qs:
@@ -323,7 +324,7 @@ class OkxClient(BaseRestClient):
else:
request_path = path
request_params = params
code, data, text = self._request(
method,
request_path,
@@ -348,14 +349,14 @@ class OkxClient(BaseRestClient):
if isinstance(data, dict) and str(data.get("code") or "") not in ("0", ""):
error_code = str(data.get("code") or "")
error_msg = str(data.get("msg") or data)
# Check for specific error codes in data array
data_array = data.get("data", [])
if isinstance(data_array, list) and data_array:
first_item = data_array[0] if isinstance(data_array[0], dict) else {}
s_code = str(first_item.get("sCode") or "")
s_msg = str(first_item.get("sMsg") or "")
# Error code 51008: Insufficient margin
if s_code == "51008" or "insufficient" in s_msg.lower() or "margin" in s_msg.lower():
raise LiveTradingError(
@@ -369,7 +370,7 @@ class OkxClient(BaseRestClient):
f"Solution: Please enable 'Trade' permission for your API key in OKX account.\n"
f"Path: OKX website -> API Management -> Edit API Key -> Enable 'Trade' permission"
)
# Fallback for permission errors
if error_code == "50120" or "permission" in str(error_msg).lower():
raise LiveTradingError(
@@ -387,7 +388,7 @@ class OkxClient(BaseRestClient):
def get_ticker(self, *, inst_id: str) -> Dict[str, Any]:
"""
Get ticker price for an instrument.
Endpoint: GET /api/v5/market/ticker?instId=...
"""
if not inst_id:
@@ -406,7 +407,7 @@ class OkxClient(BaseRestClient):
def get_positions(self, *, inst_id: str = "", inst_type: str = "SWAP") -> Dict[str, Any]:
"""
Get positions (best-effort).
Args:
inst_id: Instrument ID (optional, for filtering)
inst_type: Instrument type - "SPOT" or "SWAP" (default: "SWAP")
@@ -417,7 +418,7 @@ class OkxClient(BaseRestClient):
it = str(inst_type or "SWAP").strip().upper()
if it not in ("SPOT", "SWAP", "FUTURES", "OPTION"):
it = "SWAP"
params: Dict[str, Any] = {"instType": it}
# Only add instId if it's not empty
if inst_id and str(inst_id).strip():
@@ -662,7 +663,9 @@ class OkxClient(BaseRestClient):
data = (raw.get("data") or []) if isinstance(raw, dict) else []
first: Dict[str, Any] = data[0] if isinstance(data, list) and data else {}
exchange_order_id = str(first.get("ordId") or first.get("clOrdId") or "")
return LiveOrderResult(exchange_id="okx", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw)
return LiveOrderResult(
exchange_id="okx", exchange_order_id=exchange_order_id, filled=0.0, avg_price=0.0, raw=raw
)
def cancel_order(self, *, market_type: str, symbol: str, ord_id: str = "", cl_ord_id: str = "") -> Dict[str, Any]:
mt = (market_type or "swap").strip().lower()
@@ -836,10 +839,24 @@ class OkxClient(BaseRestClient):
# Terminal states: return whatever we have.
if state in ("filled", "canceled", "cancelled"):
if time.time() >= end_ts:
return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills}
return {
"filled": filled,
"avg_price": avg_price,
"fee": 0.0,
"fee_ccy": "",
"state": state,
"order": last_order,
"fills": last_fills,
}
if time.time() >= end_ts:
return {"filled": filled, "avg_price": avg_price, "fee": 0.0, "fee_ccy": "", "state": state, "order": last_order, "fills": last_fills}
return {
"filled": filled,
"avg_price": avg_price,
"fee": 0.0,
"fee_ccy": "",
"state": state,
"order": last_order,
"fills": last_fills,
}
time.sleep(float(poll_interval_sec or 0.5))
@@ -8,7 +8,6 @@ Important:
from __future__ import annotations
import time
from typing import Any, Dict, Optional, Tuple
from app.utils.db import get_db_connection
@@ -22,7 +21,7 @@ def _get_user_id_from_strategy(strategy_id: int) -> int:
cur.execute("SELECT user_id FROM qd_strategies_trading WHERE id = %s", (strategy_id,))
row = cur.fetchone()
cur.close()
return int((row or {}).get('user_id') or 1)
return int((row or {}).get("user_id") or 1)
except Exception:
return 1
@@ -121,7 +120,17 @@ def upsert_position(
lowest_price = CASE WHEN excluded.lowest_price > 0 THEN excluded.lowest_price ELSE qd_strategy_positions.lowest_price END,
updated_at = NOW()
""",
(int(user_id), int(strategy_id), str(symbol), str(side), float(size or 0.0), float(entry_price or 0.0), float(current_price or 0.0), float(highest_price or 0.0), float(lowest_price or 0.0)),
(
int(user_id),
int(strategy_id),
str(symbol),
str(side),
float(size or 0.0),
float(entry_price or 0.0),
float(current_price or 0.0),
float(highest_price or 0.0),
float(lowest_price or 0.0),
),
)
db.commit()
cur.close()
@@ -217,5 +226,3 @@ def apply_fill_to_local_position(
return profit, _fetch_position(strategy_id, symbol, side)
return None, None
@@ -16,7 +16,7 @@ from typing import Dict, Tuple
def _split_base_quote(symbol: str) -> Tuple[str, str]:
"""
The split symbols are base currency and quote currency.
Handle various formats:
- BTC/USDT -> (BTC, USDT)
- BTCUSDT -> (BTCUSDT, "") - requires further processing
@@ -28,10 +28,10 @@ def _split_base_quote(symbol: str) -> Tuple[str, str]:
if "/" not in s:
# Try to identify the quote currency (common format: BASEQUOTE)
s_upper = s.upper()
common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC', 'BNB']
common_quotes = ["USDT", "USD", "BTC", "ETH", "BUSD", "USDC", "BNB"]
for quote in common_quotes:
if s_upper.endswith(quote) and len(s_upper) > len(quote):
base = s_upper[:-len(quote)]
base = s_upper[: -len(quote)]
if base:
return base, quote
# Unrecognized, the original symbol and empty quote are returned.
@@ -207,22 +207,22 @@ def to_deepcoin_symbol(symbol: str) -> str:
Examples:
- Spot: BTC-USDT
- Perpetual: BTC-USDT-SWAP
If symbol already contains '-', return as-is (already in Deepcoin format).
"""
s = (symbol or "").strip()
if not s:
return s
# Already in Deepcoin format
if "-" in s:
return s.upper()
base, quote = _split_base_quote(symbol)
if not base or not quote:
# Best effort: remove slashes and colons
return s.replace("/", "-").replace(":", "-").upper()
# Return BASE-QUOTE format (caller adds -SWAP if needed for futures)
return f"{base}-{quote}"
@@ -260,4 +260,3 @@ def to_htx_contract_code(symbol: str) -> str:
if not base or not quote:
return s.replace("/", "-").replace(":", "-").upper()
return f"{base}-{quote}"