fix: Improve decimal precision handling across all exchange clients

- Add strict_precision parameter to _dec_str methods
- Modify quantity normalization methods to return (Decimal, precision) tuple
- Infer precision from stepSize/lotSz/qtyStep for accurate formatting
- Update all order placement methods to use precision information
- Fix LOT_SIZE filter errors by strictly limiting decimal places

Affected exchanges:
- Binance Spot & Futures
- OKX
- Bybit
- Bitget Spot & Futures
- Deepcoin

This ensures order quantities are formatted with correct precision matching exchange requirements.
This commit is contained in:
TIANHE
2026-02-11 18:28:06 +08:00
parent d875acd522
commit e5bb37bcbb
7 changed files with 505 additions and 82 deletions
@@ -47,33 +47,67 @@ class BinanceFuturesClient(BaseRestClient):
return Decimal("0") return Decimal("0")
@staticmethod @staticmethod
def _dec_str(d: Decimal, max_decimals: int = 18) -> str: def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
""" """
Convert Decimal to string with controlled precision. Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision. Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required 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)
strict_precision: If provided, strictly limit to this many decimal places (no trailing zero removal)
""" """
try: try:
if d == 0: if d == 0:
return "0" return "0"
# Normalize to remove unnecessary trailing zeros from internal representation # Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize() 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:
try:
prec = int(strict_precision)
if prec < 0:
prec = 0
if prec > 18:
prec = 18
# Use quantize to ensure exact precision (round down to match stepSize)
q = Decimal("1").scaleb(-prec)
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
# 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('.')
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 # Convert to string using fixed-point notation
# Use a reasonable max_decimals to avoid excessive precision
# Binance typically uses 8 decimal places for most symbols
s = format(normalized, f".{max_decimals}f") s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed # Remove trailing zeros and decimal point if not needed
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
return s if s else "0" return s if s else "0"
except Exception: except Exception:
# Fallback: try to convert safely # Fallback: try to convert safely
try: try:
# If Decimal conversion fails, try float with limited precision
f = float(d) f = float(d)
if f == 0: if f == 0:
return "0" return "0"
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
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 # Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
@@ -86,6 +120,16 @@ class BinanceFuturesClient(BaseRestClient):
if 'e' in s.lower() or 'E' in s: if 'e' in s.lower() or 'E' in s:
try: try:
f = float(s) f = float(s)
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -256,13 +300,16 @@ class BinanceFuturesClient(BaseRestClient):
return Decimal("0") return Decimal("0")
return px return px
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal: 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). 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.
""" """
q = self._to_dec(quantity) q = self._to_dec(quantity)
if q <= 0: if q <= 0:
return Decimal("0") return (Decimal("0"), None)
fdict: Dict[str, Any] = {} fdict: Dict[str, Any] = {}
try: try:
fdict = self.get_symbol_filters(symbol=symbol) or {} fdict = self.get_symbol_filters(symbol=symbol) or {}
@@ -292,9 +339,18 @@ class BinanceFuturesClient(BaseRestClient):
if qty_precision is None and step > 0: if qty_precision is None and step > 0:
try: try:
# stepSize like "0.001" means 3 decimal places # stepSize like "0.001" means 3 decimal places
step_str = str(step).rstrip('0') # 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:
qty_precision = len(step_str.split('.')[1]) # Count decimal places after removing trailing zeros
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:
qty_precision = 0
if qty_precision > 18:
qty_precision = 18
else: else:
# If stepSize is 1 or larger, precision is 0 # If stepSize is 1 or larger, precision is 0
qty_precision = 0 qty_precision = 0
@@ -306,8 +362,8 @@ class BinanceFuturesClient(BaseRestClient):
q = self._floor_to_precision(q, qty_precision) q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty: if min_qty > 0 and q < min_qty:
return Decimal("0") return (Decimal("0"), qty_precision)
return q return (q, qty_precision)
def ping(self) -> bool: def ping(self) -> bool:
code, data, _ = self._request("GET", "/fapi/v1/time") code, data, _ = self._request("GET", "/fapi/v1/time")
@@ -555,7 +611,7 @@ class BinanceFuturesClient(BaseRestClient):
if sd not in ("BUY", "SELL"): if sd not in ("BUY", "SELL"):
raise LiveTradingError(f"Invalid side: {side}") raise LiveTradingError(f"Invalid side: {side}")
q_req = float(quantity or 0.0) q_req = float(quantity or 0.0)
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True) q_dec, qty_precision = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True)
if float(q_dec or 0) <= 0: if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}") raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
@@ -575,7 +631,7 @@ class BinanceFuturesClient(BaseRestClient):
if notional < min_notional: if notional < min_notional:
raise LiveTradingError( raise LiveTradingError(
"Order notional is below MIN_NOTIONAL. " "Order notional is below MIN_NOTIONAL. "
f"symbol={sym} side={sd} qty={self._dec_str(q_dec)} " f"symbol={sym} side={sd} qty={self._dec_str(q_dec, strict_precision=qty_precision)} "
f"markPrice={mark_price} notional={self._dec_str(notional)} " f"markPrice={mark_price} notional={self._dec_str(notional)} "
f"minNotional={self._dec_str(min_notional)}" f"minNotional={self._dec_str(min_notional)}"
) )
@@ -589,7 +645,7 @@ class BinanceFuturesClient(BaseRestClient):
"symbol": sym, "symbol": sym,
"side": sd, "side": sd,
"type": "MARKET", "type": "MARKET",
"quantity": self._dec_str(q_dec), "quantity": self._dec_str(q_dec, strict_precision=qty_precision),
} }
if reduce_only: if reduce_only:
params["reduceOnly"] = "true" params["reduceOnly"] = "true"
@@ -675,7 +731,7 @@ class BinanceFuturesClient(BaseRestClient):
pass pass
raise LiveTradingError( raise LiveTradingError(
f"{e} | debug: symbol={sym} side={sd} " f"{e} | debug: symbol={sym} side={sd} "
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} " f"qty_req={q_req} qty_norm={self._dec_str(q_dec, strict_precision=qty_precision)} "
f"base_url={self.base_url} filtersSymbol={filt_symbol} contractType={contract_type} " f"base_url={self.base_url} filtersSymbol={filt_symbol} contractType={contract_type} "
f"stepSize={step} quantityPrecision={qty_prec} minNotional={min_not} " f"stepSize={step} quantityPrecision={qty_prec} minNotional={min_not} "
f"dualSidePosition={dual_mode} positionSide={pos_side_used} " f"dualSidePosition={dual_mode} positionSide={pos_side_used} "
@@ -714,7 +770,7 @@ class BinanceFuturesClient(BaseRestClient):
px = float(price or 0.0) px = float(price or 0.0)
if q_req <= 0 or px <= 0: if q_req <= 0 or px <= 0:
raise LiveTradingError("Invalid quantity/price") raise LiveTradingError("Invalid quantity/price")
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False) q_dec, qty_precision = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False)
if float(q_dec or 0) <= 0: if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}") raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
px_dec = self._normalize_price(symbol=symbol, price=px) px_dec = self._normalize_price(symbol=symbol, price=px)
@@ -726,7 +782,7 @@ class BinanceFuturesClient(BaseRestClient):
"side": sd, "side": sd,
"type": "LIMIT", "type": "LIMIT",
"timeInForce": "GTC", "timeInForce": "GTC",
"quantity": self._dec_str(q_dec), "quantity": self._dec_str(q_dec, strict_precision=qty_precision),
"price": self._dec_str(px_dec), "price": self._dec_str(px_dec),
} }
if reduce_only: if reduce_only:
@@ -777,7 +833,7 @@ class BinanceFuturesClient(BaseRestClient):
pass pass
raise LiveTradingError( raise LiveTradingError(
f"{e} | debug: symbol={sym} side={sd} " f"{e} | debug: symbol={sym} side={sd} "
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} " f"qty_req={q_req} qty_norm={self._dec_str(q_dec, strict_precision=qty_precision)} "
f"price_req={px} price_norm={self._dec_str(px_dec)}" f"price_req={px} price_norm={self._dec_str(px_dec)}"
) )
exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "") exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "")
@@ -38,33 +38,72 @@ class BinanceSpotClient(BaseRestClient):
return Decimal("0") return Decimal("0")
@staticmethod @staticmethod
def _dec_str(d: Decimal, max_decimals: int = 18) -> str: def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
""" """
Convert Decimal to string with controlled precision. Convert Decimal to string with controlled precision.
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision. Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
This method ensures the output string doesn't exceed the required 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)
strict_precision: If provided, strictly limit to this many decimal places (no trailing zero removal)
""" """
try: try:
if d == 0: if d == 0:
return "0" return "0"
# Normalize to remove unnecessary trailing zeros from internal representation # Normalize to remove unnecessary trailing zeros from internal representation
normalized = d.normalize() 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:
try:
prec = int(strict_precision)
if prec < 0:
prec = 0
if prec > 18:
prec = 18
# Use quantize to ensure exact precision (round down to match stepSize)
q = Decimal("1").scaleb(-prec)
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
# Format with exact precision - this will produce at most 'prec' decimal places
# Use fixed-point format to ensure we don't exceed precision
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('.')
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 # Convert to string using fixed-point notation
# Use a reasonable max_decimals to avoid excessive precision
# Binance typically uses 8 decimal places for most symbols
s = format(normalized, f".{max_decimals}f") s = format(normalized, f".{max_decimals}f")
# Remove trailing zeros and decimal point if not needed # Remove trailing zeros and decimal point if not needed
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
return s if s else "0" return s if s else "0"
except Exception: except Exception:
# Fallback: try to convert safely # Fallback: try to convert safely
try: try:
# If Decimal conversion fails, try float with limited precision
f = float(d) f = float(d)
if f == 0: if f == 0:
return "0" return "0"
if strict_precision is not None:
try:
prec = int(strict_precision)
if prec < 0:
prec = 0
if prec > 18:
prec = 18
s = format(f, f".{prec}f")
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 # Format with max_decimals and remove trailing zeros
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
@@ -77,6 +116,16 @@ class BinanceSpotClient(BaseRestClient):
if 'e' in s.lower() or 'E' in s: if 'e' in s.lower() or 'E' in s:
try: try:
f = float(s) f = float(s)
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -236,13 +285,16 @@ class BinanceSpotClient(BaseRestClient):
return Decimal("0") return Decimal("0")
return px return px
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal: 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). 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.
""" """
q = self._to_dec(quantity) q = self._to_dec(quantity)
if q <= 0: if q <= 0:
return Decimal("0") return (Decimal("0"), None)
fdict: Dict[str, Any] = {} fdict: Dict[str, Any] = {}
try: try:
fdict = self.get_symbol_filters(symbol=symbol) or {} fdict = self.get_symbol_filters(symbol=symbol) or {}
@@ -272,9 +324,18 @@ class BinanceSpotClient(BaseRestClient):
if qty_precision is None and step > 0: if qty_precision is None and step > 0:
try: try:
# stepSize like "0.001" means 3 decimal places # stepSize like "0.001" means 3 decimal places
step_str = str(step).rstrip('0') # 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:
qty_precision = len(step_str.split('.')[1]) # Count decimal places after removing trailing zeros
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:
qty_precision = 0
if qty_precision > 18:
qty_precision = 18
else: else:
# If stepSize is 1 or larger, precision is 0 # If stepSize is 1 or larger, precision is 0
qty_precision = 0 qty_precision = 0
@@ -286,8 +347,8 @@ class BinanceSpotClient(BaseRestClient):
q = self._floor_to_precision(q, qty_precision) q = self._floor_to_precision(q, qty_precision)
if min_qty > 0 and q < min_qty: if min_qty > 0 and q < min_qty:
return Decimal("0") return (Decimal("0"), qty_precision)
return q return (q, qty_precision)
def place_limit_order( def place_limit_order(
self, self,
@@ -306,7 +367,7 @@ class BinanceSpotClient(BaseRestClient):
px = float(price or 0.0) px = float(price or 0.0)
if q_req <= 0 or px <= 0: if q_req <= 0 or px <= 0:
raise LiveTradingError("Invalid quantity/price") raise LiveTradingError("Invalid quantity/price")
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False) q_dec, qty_precision = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False)
if float(q_dec or 0) <= 0: if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}") raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
px_dec = self._normalize_price(symbol=symbol, price=px) px_dec = self._normalize_price(symbol=symbol, price=px)
@@ -318,7 +379,7 @@ class BinanceSpotClient(BaseRestClient):
"side": sd, "side": sd,
"type": "LIMIT", "type": "LIMIT",
"timeInForce": "GTC", "timeInForce": "GTC",
"quantity": self._dec_str(q_dec), "quantity": self._dec_str(q_dec, strict_precision=qty_precision),
"price": self._dec_str(px_dec), "price": self._dec_str(px_dec),
} }
if client_order_id: if client_order_id:
@@ -328,7 +389,7 @@ class BinanceSpotClient(BaseRestClient):
except LiveTradingError as e: except LiveTradingError as e:
raise LiveTradingError( raise LiveTradingError(
f"{e} | debug: symbol={sym} side={sd} " f"{e} | debug: symbol={sym} side={sd} "
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} " f"qty_req={q_req} qty_norm={self._dec_str(q_dec, strict_precision=qty_precision)} "
f"price_req={px} price_norm={self._dec_str(px_dec)}" f"price_req={px} price_norm={self._dec_str(px_dec)}"
) )
return LiveOrderResult( return LiveOrderResult(
@@ -352,7 +413,7 @@ class BinanceSpotClient(BaseRestClient):
if sd not in ("BUY", "SELL"): if sd not in ("BUY", "SELL"):
raise LiveTradingError(f"Invalid side: {side}") raise LiveTradingError(f"Invalid side: {side}")
q_req = float(quantity or 0.0) q_req = float(quantity or 0.0)
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True) q_dec, qty_precision = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True)
if float(q_dec or 0) <= 0: if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}") raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
@@ -360,7 +421,7 @@ class BinanceSpotClient(BaseRestClient):
"symbol": sym, "symbol": sym,
"side": sd, "side": sd,
"type": "MARKET", "type": "MARKET",
"quantity": self._dec_str(q_dec), "quantity": self._dec_str(q_dec, strict_precision=qty_precision),
} }
if client_order_id: if client_order_id:
params["newClientOrderId"] = str(client_order_id) params["newClientOrderId"] = str(client_order_id)
@@ -369,7 +430,7 @@ class BinanceSpotClient(BaseRestClient):
except LiveTradingError as e: except LiveTradingError as e:
raise LiveTradingError( raise LiveTradingError(
f"{e} | debug: symbol={sym} side={sd} " f"{e} | debug: symbol={sym} side={sd} "
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)}" f"qty_req={q_req} qty_norm={self._dec_str(q_dec, strict_precision=qty_precision)}"
) )
return LiveOrderResult( return LiveOrderResult(
exchange_id="binance", exchange_id="binance",
@@ -54,15 +54,34 @@ class BitgetMixClient(BaseRestClient):
return Decimal("0") return Decimal("0")
@staticmethod @staticmethod
def _dec_str(d: Decimal, max_decimals: int = 18) -> str: def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
""" """
Convert Decimal to string with controlled precision. Convert Decimal to string with controlled precision.
Bitget requires quantities to match sizeStep/sizePlace 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)
strict_precision: If provided, strictly limit to this many decimal places
""" """
try: try:
if d == 0: if d == 0:
return "0" return "0"
normalized = d.normalize() normalized = d.normalize()
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
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('.')
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f") s = format(normalized, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -72,6 +91,16 @@ class BitgetMixClient(BaseRestClient):
f = float(d) f = float(d)
if f == 0: if f == 0:
return "0" return "0"
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -81,6 +110,16 @@ class BitgetMixClient(BaseRestClient):
if 'e' in s.lower() or 'E' in s: if 'e' in s.lower() or 'E' in s:
try: try:
f = float(s) f = float(s)
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -219,17 +258,20 @@ class BitgetMixClient(BaseRestClient):
self._contract_cache[key] = (now, first) self._contract_cache[key] = (now, first)
return first if isinstance(first, dict) else {} return first if isinstance(first, dict) else {}
def _normalize_size(self, *, symbol: str, product_type: str, base_size: float) -> Decimal: def _normalize_size(self, *, symbol: str, product_type: str, base_size: float) -> Tuple[Decimal, Optional[int]]:
""" """
Normalize Bitget mix order size. Normalize Bitget mix order size.
This system computes `amount` as base-asset quantity (e.g. BTC amount). This system computes `amount` as base-asset quantity (e.g. BTC amount).
Bitget mix `size` is typically in contracts; convert using contractSize if available, Bitget mix `size` is typically in contracts; convert using contractSize if available,
then align to size step / min trade number (best-effort). 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.
""" """
req_base = self._to_dec(base_size) req_base = self._to_dec(base_size)
if req_base <= 0: if req_base <= 0:
return Decimal("0") return (Decimal("0"), None)
contract: Dict[str, Any] = {} contract: Dict[str, Any] = {}
try: try:
@@ -245,6 +287,7 @@ class BitgetMixClient(BaseRestClient):
# Determine step size. # 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: if step <= 0:
sp = contract.get("sizePlace") sp = contract.get("sizePlace")
try: try:
@@ -253,15 +296,32 @@ class BitgetMixClient(BaseRestClient):
places = 0 places = 0
if places >= 0 and places <= 18: if places >= 0 and places <= 18:
step = Decimal("1") / (Decimal("10") ** Decimal(str(places))) step = Decimal("1") / (Decimal("10") ** Decimal(str(places)))
size_precision = places
if step > 0: if step > 0:
qty = self._floor_to_step(qty, step) qty = self._floor_to_step(qty, step)
# Infer precision from step if not already set
if size_precision is None:
try:
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
decimal_part = step_str.split('.')[1]
size_precision = len(decimal_part)
if size_precision < 0:
size_precision = 0
if size_precision > 18:
size_precision = 18
else:
size_precision = 0
except Exception:
pass
# Enforce min trade number if present. # Enforce min trade number if present.
mn = self._to_dec(contract.get("minTradeNum") or contract.get("minSize") or contract.get("minQty") or "0") mn = self._to_dec(contract.get("minTradeNum") or contract.get("minSize") or contract.get("minQty") or "0")
if mn > 0 and qty < mn: if mn > 0 and qty < mn:
return Decimal("0") return (Decimal("0"), size_precision)
return qty return (qty, size_precision)
def ping(self) -> bool: def ping(self) -> bool:
code, data, _ = self._request("GET", "/api/v2/public/time") code, data, _ = self._request("GET", "/api/v2/public/time")
@@ -354,7 +414,7 @@ class BitgetMixClient(BaseRestClient):
if sd not in ("buy", "sell"): if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}") raise LiveTradingError(f"Invalid side: {side}")
req = float(size or 0.0) req = float(size or 0.0)
sz_dec = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req) sz_dec, sz_precision = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req)
if float(sz_dec or 0) <= 0: if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below step/min): requested={req}") raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
@@ -365,7 +425,7 @@ class BitgetMixClient(BaseRestClient):
"marginMode": self._normalize_margin_mode(margin_mode), "marginMode": self._normalize_margin_mode(margin_mode),
"side": sd, "side": sd,
"orderType": "market", "orderType": "market",
"size": self._dec_str(sz_dec), "size": self._dec_str(sz_dec, strict_precision=sz_precision),
} }
if reduce_only: if reduce_only:
body["reduceOnly"] = "YES" body["reduceOnly"] = "YES"
@@ -408,7 +468,7 @@ class BitgetMixClient(BaseRestClient):
px = float(price or 0.0) px = float(price or 0.0)
if req <= 0 or px <= 0: if req <= 0 or px <= 0:
raise LiveTradingError("Invalid size/price") raise LiveTradingError("Invalid size/price")
sz_dec = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req) sz_dec, sz_precision = self._normalize_size(symbol=symbol, product_type=product_type, base_size=req)
if float(sz_dec or 0) <= 0: if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below step/min): requested={req}") raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
@@ -420,7 +480,7 @@ class BitgetMixClient(BaseRestClient):
"side": sd, "side": sd,
"orderType": "limit", "orderType": "limit",
"price": str(px), "price": str(px),
"size": self._dec_str(sz_dec), "size": self._dec_str(sz_dec, strict_precision=sz_precision),
} }
# Force maker behavior when requested (avoid taker fills). # Force maker behavior when requested (avoid taker fills).
if post_only: if post_only:
@@ -54,15 +54,34 @@ class BitgetSpotClient(BaseRestClient):
return Decimal("0") return Decimal("0")
@staticmethod @staticmethod
def _dec_str(d: Decimal, max_decimals: int = 18) -> str: def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
""" """
Convert Decimal to string with controlled precision. Convert Decimal to string with controlled precision.
Bitget requires quantities to match quantityStep/quantityScale 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)
strict_precision: If provided, strictly limit to this many decimal places
""" """
try: try:
if d == 0: if d == 0:
return "0" return "0"
normalized = d.normalize() normalized = d.normalize()
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
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('.')
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f") s = format(normalized, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -72,6 +91,16 @@ class BitgetSpotClient(BaseRestClient):
f = float(d) f = float(d)
if f == 0: if f == 0:
return "0" return "0"
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -81,6 +110,16 @@ class BitgetSpotClient(BaseRestClient):
if 'e' in s.lower() or 'E' in s: if 'e' in s.lower() or 'E' in s:
try: try:
f = float(s) f = float(s)
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -200,13 +239,16 @@ class BitgetSpotClient(BaseRestClient):
self._sym_meta_cache[sym] = (now, found) self._sym_meta_cache[sym] = (now, found)
return found return found
def _normalize_base_size(self, *, symbol: str, base_size: float) -> Decimal: def _normalize_base_size(self, *, symbol: str, base_size: float) -> Tuple[Decimal, Optional[int]]:
""" """
Normalize spot base size to lot/step constraints (best-effort). 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.
""" """
req = self._to_dec(base_size) req = self._to_dec(base_size)
if req <= 0: if req <= 0:
return Decimal("0") return (Decimal("0"), None)
meta: Dict[str, Any] = {} meta: Dict[str, Any] = {}
try: try:
@@ -216,6 +258,7 @@ class BitgetSpotClient(BaseRestClient):
# Try common fields. If unavailable, keep as-is. # 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: if step <= 0:
# Some endpoints expose decimals instead of step. # Some endpoints expose decimals instead of step.
qd = meta.get("quantityPrecision") or meta.get("quantityPlace") or meta.get("sizePlace") qd = meta.get("quantityPrecision") or meta.get("quantityPlace") or meta.get("sizePlace")
@@ -225,14 +268,31 @@ class BitgetSpotClient(BaseRestClient):
places = 0 places = 0
if places >= 0 and places <= 18: if places >= 0 and places <= 18:
step = Decimal("1") / (Decimal("10") ** Decimal(str(places))) step = Decimal("1") / (Decimal("10") ** Decimal(str(places)))
size_precision = places
if step > 0: if step > 0:
req = self._floor_to_step(req, step) req = self._floor_to_step(req, step)
# Infer precision from step if not already set
if size_precision is None:
try:
step_normalized = step.normalize()
step_str = str(step_normalized)
if '.' in step_str:
decimal_part = step_str.split('.')[1]
size_precision = len(decimal_part)
if size_precision < 0:
size_precision = 0
if size_precision > 18:
size_precision = 18
else:
size_precision = 0
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: if mn > 0 and req < mn:
return Decimal("0") return (Decimal("0"), size_precision)
return req 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) sym = to_bitget_um_symbol(symbol)
@@ -243,14 +303,14 @@ class BitgetSpotClient(BaseRestClient):
px = float(price or 0.0) px = float(price or 0.0)
if req <= 0 or px <= 0: if req <= 0 or px <= 0:
raise LiveTradingError("Invalid size/price") raise LiveTradingError("Invalid size/price")
sz_dec = self._normalize_base_size(symbol=symbol, base_size=req) sz_dec, sz_precision = self._normalize_base_size(symbol=symbol, base_size=req)
if float(sz_dec or 0) <= 0: if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below step/min): requested={req}") raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
body: Dict[str, Any] = { body: Dict[str, Any] = {
"side": sd, "side": sd,
"symbol": sym, "symbol": sym,
"size": self._dec_str(sz_dec), "size": self._dec_str(sz_dec, strict_precision=sz_precision),
"orderType": "limit", "orderType": "limit",
"force": "gtc", "force": "gtc",
"price": str(px), "price": str(px),
@@ -278,10 +338,10 @@ class BitgetSpotClient(BaseRestClient):
# For Bitget spot market BUY, many APIs interpret size as quote amount. # For Bitget spot market BUY, many APIs interpret size as quote amount.
# Our worker may pass quote-sized value for BUY; do not quantize it as base size. # Our worker may pass quote-sized value for BUY; do not quantize it as base size.
if sd == "sell": if sd == "sell":
sz_dec = self._normalize_base_size(symbol=symbol, base_size=req) sz_dec, sz_precision = self._normalize_base_size(symbol=symbol, base_size=req)
if float(sz_dec or 0) <= 0: if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below step/min): requested={req}") raise LiveTradingError(f"Invalid size (below step/min): requested={req}")
sz_str = self._dec_str(sz_dec) sz_str = self._dec_str(sz_dec, strict_precision=sz_precision)
else: else:
sz_str = str(req) sz_str = str(req)
@@ -61,15 +61,34 @@ class BybitClient(BaseRestClient):
return Decimal("0") return Decimal("0")
@staticmethod @staticmethod
def _dec_str(d: Decimal, max_decimals: int = 18) -> str: def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
""" """
Convert Decimal to string with controlled precision. Convert Decimal to string with controlled precision.
Bybit requires quantities to match qtyStep 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)
strict_precision: If provided, strictly limit to this many decimal places
""" """
try: try:
if d == 0: if d == 0:
return "0" return "0"
normalized = d.normalize() normalized = d.normalize()
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
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('.')
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f") s = format(normalized, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -79,6 +98,16 @@ class BybitClient(BaseRestClient):
f = float(d) f = float(d)
if f == 0: if f == 0:
return "0" return "0"
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -88,6 +117,16 @@ class BybitClient(BaseRestClient):
if 'e' in s.lower() or 'E' in s: if 'e' in s.lower() or 'E' in s:
try: try:
f = float(s) f = float(s)
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -201,10 +240,10 @@ class BybitClient(BaseRestClient):
self._inst_cache[key] = (now, first) self._inst_cache[key] = (now, first)
return first if isinstance(first, dict) else {} return first if isinstance(first, dict) else {}
def _normalize_qty(self, *, symbol: str, qty: float) -> Decimal: def _normalize_qty(self, *, symbol: str, qty: float) -> Tuple[Decimal, Optional[int]]:
q = self._to_dec(qty) q = self._to_dec(qty)
if q <= 0: if q <= 0:
return Decimal("0") return (Decimal("0"), None)
sym = to_bybit_symbol(symbol) sym = to_bybit_symbol(symbol)
try: try:
info = self.get_instrument_info(category=self.category, symbol=sym) or {} info = self.get_instrument_info(category=self.category, symbol=sym) or {}
@@ -215,9 +254,28 @@ class BybitClient(BaseRestClient):
mn = self._to_dec((lot or {}).get("minOrderQty") or "0") mn = self._to_dec((lot or {}).get("minOrderQty") or "0")
if step > 0: if step > 0:
q = self._floor_to_step(q, step) 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]
qty_precision = len(decimal_part)
if qty_precision < 0:
qty_precision = 0
if qty_precision > 18:
qty_precision = 18
else:
qty_precision = 0
except Exception:
pass
if mn > 0 and q < mn: if mn > 0 and q < mn:
return Decimal("0") return (Decimal("0"), qty_precision)
return q return (q, qty_precision)
def place_market_order( def place_market_order(
self, self,
@@ -233,7 +291,7 @@ class BybitClient(BaseRestClient):
if sd not in ("buy", "sell"): if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}") raise LiveTradingError(f"Invalid side: {side}")
q_req = float(qty or 0.0) q_req = float(qty or 0.0)
q_dec = self._normalize_qty(symbol=symbol, qty=q_req) q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
if float(q_dec or 0) <= 0: if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
body: Dict[str, Any] = { body: Dict[str, Any] = {
@@ -241,7 +299,7 @@ class BybitClient(BaseRestClient):
"symbol": sym, "symbol": sym,
"side": "Buy" if sd == "buy" else "Sell", "side": "Buy" if sd == "buy" else "Sell",
"orderType": "Market", "orderType": "Market",
"qty": self._dec_str(q_dec), "qty": self._dec_str(q_dec, strict_precision=qty_precision),
"timeInForce": "GTC", "timeInForce": "GTC",
} }
if reduce_only and self.category == "linear": if reduce_only and self.category == "linear":
@@ -271,7 +329,7 @@ class BybitClient(BaseRestClient):
px = float(price or 0.0) px = float(price or 0.0)
if q_req <= 0 or px <= 0: if q_req <= 0 or px <= 0:
raise LiveTradingError("Invalid qty/price") raise LiveTradingError("Invalid qty/price")
q_dec = self._normalize_qty(symbol=symbol, qty=q_req) q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
if float(q_dec or 0) <= 0: if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
body: Dict[str, Any] = { body: Dict[str, Any] = {
@@ -279,7 +337,7 @@ class BybitClient(BaseRestClient):
"symbol": sym, "symbol": sym,
"side": "Buy" if sd == "buy" else "Sell", "side": "Buy" if sd == "buy" else "Sell",
"orderType": "Limit", "orderType": "Limit",
"qty": self._dec_str(q_dec), "qty": self._dec_str(q_dec, strict_precision=qty_precision),
"price": str(px), "price": str(px),
"timeInForce": "GTC", "timeInForce": "GTC",
} }
@@ -74,15 +74,35 @@ class DeepcoinClient(BaseRestClient):
return Decimal("0") return Decimal("0")
@staticmethod @staticmethod
def _dec_str(d: Decimal, max_decimals: int = 18) -> str: def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
""" """
Convert Decimal to string with controlled precision. Convert Decimal to string with controlled precision.
Deepcoin requires quantities to match lotSz/qtyStep 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)
strict_precision: If provided, strictly limit to this many decimal places
""" """
try: try:
if d == 0: if d == 0:
return "0" return "0"
normalized = d.normalize() 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('.')
return s if s else "0"
except Exception:
pass
s = format(normalized, f".{max_decimals}f") s = format(normalized, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -92,6 +112,16 @@ class DeepcoinClient(BaseRestClient):
f = float(d) f = float(d)
if f == 0: if f == 0:
return "0" return "0"
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -101,6 +131,16 @@ class DeepcoinClient(BaseRestClient):
if 'e' in s.lower() or 'E' in s: if 'e' in s.lower() or 'E' in s:
try: try:
f = float(s) f = float(s)
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -366,13 +406,16 @@ class DeepcoinClient(BaseRestClient):
except Exception: except Exception:
return {} return {}
def _normalize_qty(self, *, symbol: str, qty: float) -> Decimal: def _normalize_qty(self, *, symbol: str, qty: float) -> Tuple[Decimal, Optional[int]]:
""" """
Normalize order quantity to exchange requirements. 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) q = self._to_dec(qty)
if q <= 0: if q <= 0:
return Decimal("0") return (Decimal("0"), None)
sym = to_deepcoin_symbol(symbol) sym = to_deepcoin_symbol(symbol)
try: try:
@@ -386,9 +429,28 @@ class DeepcoinClient(BaseRestClient):
if step > 0: if step > 0:
q = self._floor_to_step(q, step) 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]
qty_precision = len(decimal_part)
if qty_precision < 0:
qty_precision = 0
if qty_precision > 18:
qty_precision = 18
else:
qty_precision = 0
except Exception:
pass
if mn > 0 and q < mn: if mn > 0 and q < mn:
return Decimal("0") return (Decimal("0"), qty_precision)
return q return (q, qty_precision)
def place_market_order( def place_market_order(
self, self,
@@ -419,7 +481,7 @@ class DeepcoinClient(BaseRestClient):
raise LiveTradingError(f"Invalid side: {side}") raise LiveTradingError(f"Invalid side: {side}")
q_req = float(qty or 0.0) q_req = float(qty or 0.0)
q_dec = self._normalize_qty(symbol=symbol, qty=q_req) q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
if float(q_dec or 0) <= 0: if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
@@ -428,7 +490,7 @@ class DeepcoinClient(BaseRestClient):
"tdMode": "cash" if self.market_type == "spot" else "cross", "tdMode": "cash" if self.market_type == "spot" else "cross",
"side": sd, "side": sd,
"ordType": "market", "ordType": "market",
"sz": self._dec_str(q_dec), "sz": self._dec_str(q_dec, strict_precision=qty_precision),
} }
if self.market_type != "spot": if self.market_type != "spot":
@@ -480,7 +542,7 @@ class DeepcoinClient(BaseRestClient):
if q_req <= 0 or px <= 0: if q_req <= 0 or px <= 0:
raise LiveTradingError("Invalid qty/price") raise LiveTradingError("Invalid qty/price")
q_dec = self._normalize_qty(symbol=symbol, qty=q_req) q_dec, qty_precision = self._normalize_qty(symbol=symbol, qty=q_req)
if float(q_dec or 0) <= 0: if float(q_dec or 0) <= 0:
raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}")
@@ -489,7 +551,7 @@ class DeepcoinClient(BaseRestClient):
"tdMode": "cash" if self.market_type == "spot" else "cross", "tdMode": "cash" if self.market_type == "spot" else "cross",
"side": sd, "side": sd,
"ordType": "limit", "ordType": "limit",
"sz": self._dec_str(q_dec), "sz": self._dec_str(q_dec, strict_precision=qty_precision),
"px": str(px), "px": str(px),
} }
@@ -52,16 +52,41 @@ class OkxClient(BaseRestClient):
self._lev_cache_ttl_sec = 60.0 self._lev_cache_ttl_sec = 60.0
@staticmethod @staticmethod
def _dec_str(d: Decimal, max_decimals: int = 18) -> str: def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
""" """
Convert Decimal to a non-scientific string with controlled precision. Convert Decimal to a non-scientific string with controlled precision.
OKX expects plain decimal strings matching lotSz 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)
strict_precision: If provided, strictly limit to this many decimal places
""" """
try: try:
if d == 0: if d == 0:
return "0" return "0"
# Normalize to remove unnecessary trailing zeros # Normalize to remove unnecessary trailing zeros
normalized = d.normalize() normalized = d.normalize()
# If strict_precision is provided, use it and strictly limit decimal places
if strict_precision is not None:
try:
prec = int(strict_precision)
if prec < 0:
prec = 0
if prec > 18:
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('.')
return s if s else "0"
except Exception:
pass
# Format with max_decimals and remove trailing zeros # Format with max_decimals and remove trailing zeros
s = format(normalized, f".{max_decimals}f") s = format(normalized, f".{max_decimals}f")
if '.' in s: if '.' in s:
@@ -72,6 +97,16 @@ class OkxClient(BaseRestClient):
f = float(d) f = float(d)
if f == 0: if f == 0:
return "0" return "0"
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -81,6 +116,16 @@ class OkxClient(BaseRestClient):
if 'e' in s.lower() or 'E' in s: if 'e' in s.lower() or 'E' in s:
try: try:
f = float(s) f = float(s)
if strict_precision is not None:
try:
prec = int(strict_precision)
if 0 <= prec <= 18:
s = format(f, f".{prec}f")
if '.' in s:
s = s.rstrip('0').rstrip('.')
return s if s else "0"
except Exception:
pass
s = format(f, f".{max_decimals}f") s = format(f, f".{max_decimals}f")
if '.' in s: if '.' in s:
s = s.rstrip('0').rstrip('.') s = s.rstrip('0').rstrip('.')
@@ -146,19 +191,22 @@ class OkxClient(BaseRestClient):
self._inst_cache[key] = (now, first) self._inst_cache[key] = (now, first)
return first if isinstance(first, dict) else {} return first if isinstance(first, dict) else {}
def _normalize_order_size(self, *, inst_id: str, market_type: str, size: float) -> Decimal: def _normalize_order_size(self, *, inst_id: str, market_type: str, size: float) -> Tuple[Decimal, Optional[int]]:
""" """
Normalize requested size to OKX constraints: Normalize requested size to OKX constraints:
- Spot: size is base currency quantity; align to lotSz/minSz. - Spot: size is base currency quantity; align to lotSz/minSz.
- Swap: OKX sz is in contracts; convert base qty -> contracts using ctVal, then align to lotSz/minSz. - 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. 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.
""" """
mt = (market_type or "swap").strip().lower() mt = (market_type or "swap").strip().lower()
iid = str(inst_id or "").strip() iid = str(inst_id or "").strip()
req = self._to_dec(size) req = self._to_dec(size)
if req <= 0: if req <= 0:
return Decimal("0") return (Decimal("0"), None)
inst_type = "SPOT" if mt == "spot" else "SWAP" inst_type = "SPOT" if mt == "spot" else "SWAP"
inst: Dict[str, Any] = {} inst: Dict[str, Any] = {}
@@ -181,10 +229,28 @@ class OkxClient(BaseRestClient):
if lot_sz > 0: if lot_sz > 0:
req = self._floor_to_step(req, lot_sz) 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]
size_precision = len(decimal_part)
if size_precision < 0:
size_precision = 0
if size_precision > 18:
size_precision = 18
else:
size_precision = 0
except Exception:
pass
# Enforce min size best-effort. # Enforce min size best-effort.
if min_sz > 0 and req < min_sz: if min_sz > 0 and req < min_sz:
return Decimal("0") return (Decimal("0"), size_precision)
return req return (req, size_precision)
def _iso_ts(self) -> str: def _iso_ts(self) -> str:
# OKX requires RFC3339 timestamp with milliseconds, e.g. 2020-12-08T09:08:57.715Z # OKX requires RFC3339 timestamp with milliseconds, e.g. 2020-12-08T09:08:57.715Z
@@ -390,7 +456,7 @@ class OkxClient(BaseRestClient):
if sd not in ("buy", "sell"): if sd not in ("buy", "sell"):
raise LiveTradingError(f"Invalid side: {side}") raise LiveTradingError(f"Invalid side: {side}")
sz_raw = float(size or 0.0) sz_raw = float(size or 0.0)
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw) sz_dec, sz_precision = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
if float(sz_dec or 0) <= 0: if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
@@ -400,7 +466,7 @@ class OkxClient(BaseRestClient):
"tdMode": "cash", "tdMode": "cash",
"side": sd, "side": sd,
"ordType": "market", "ordType": "market",
"sz": self._dec_str(sz_dec), "sz": self._dec_str(sz_dec, strict_precision=sz_precision),
# Follow hummingbot approach so "sz" is in base currency. # Follow hummingbot approach so "sz" is in base currency.
"tgtCcy": "base_ccy", "tgtCcy": "base_ccy",
} }
@@ -462,7 +528,7 @@ class OkxClient(BaseRestClient):
if mt == "spot": if mt == "spot":
inst_id = to_okx_spot_inst_id(symbol) inst_id = to_okx_spot_inst_id(symbol)
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw) sz_dec, sz_precision = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
if float(sz_dec or 0) <= 0: if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
body: Dict[str, Any] = { body: Dict[str, Any] = {
@@ -470,13 +536,13 @@ class OkxClient(BaseRestClient):
"tdMode": "cash", "tdMode": "cash",
"side": sd, "side": sd,
"ordType": "limit", "ordType": "limit",
"sz": self._dec_str(sz_dec), "sz": self._dec_str(sz_dec, strict_precision=sz_precision),
"px": str(px), "px": str(px),
} }
else: else:
inst_id = to_okx_swap_inst_id(symbol) inst_id = to_okx_swap_inst_id(symbol)
ps = self._resolve_pos_side(requested_pos_side=pos_side, market_type=mt) ps = self._resolve_pos_side(requested_pos_side=pos_side, market_type=mt)
sz_dec = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw) sz_dec, sz_precision = self._normalize_order_size(inst_id=inst_id, market_type=mt, size=sz_raw)
if float(sz_dec or 0) <= 0: if float(sz_dec or 0) <= 0:
raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}")
td = (td_mode or "cross").lower() td = (td_mode or "cross").lower()
@@ -488,7 +554,7 @@ class OkxClient(BaseRestClient):
"side": sd, "side": sd,
"posSide": ps, "posSide": ps,
"ordType": "limit", "ordType": "limit",
"sz": self._dec_str(sz_dec), "sz": self._dec_str(sz_dec, strict_precision=sz_precision),
"px": str(px), "px": str(px),
} }
if reduce_only: if reduce_only: