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:
@@ -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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user