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:
@@ -47,33 +47,67 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
return Decimal("0")
|
||||
|
||||
@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.
|
||||
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)
|
||||
strict_precision: If provided, strictly limit to this many decimal places (no trailing zero removal)
|
||||
"""
|
||||
try:
|
||||
if d == 0:
|
||||
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:
|
||||
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
|
||||
# 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")
|
||||
# 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:
|
||||
s = s.rstrip('0').rstrip('.')
|
||||
return s if s else "0"
|
||||
except Exception:
|
||||
# Fallback: try to convert safely
|
||||
try:
|
||||
# If Decimal conversion fails, try float with limited precision
|
||||
f = float(d)
|
||||
if f == 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
|
||||
s = format(f, f".{max_decimals}f")
|
||||
if '.' in s:
|
||||
@@ -86,6 +120,16 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
if 'e' in s.lower() or 'E' in s:
|
||||
try:
|
||||
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")
|
||||
if '.' in s:
|
||||
s = s.rstrip('0').rstrip('.')
|
||||
@@ -256,13 +300,16 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
return Decimal("0")
|
||||
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).
|
||||
|
||||
Returns:
|
||||
Tuple of (normalized_quantity, precision) where precision is the number of decimal places required.
|
||||
"""
|
||||
q = self._to_dec(quantity)
|
||||
if q <= 0:
|
||||
return Decimal("0")
|
||||
return (Decimal("0"), None)
|
||||
fdict: Dict[str, Any] = {}
|
||||
try:
|
||||
fdict = self.get_symbol_filters(symbol=symbol) or {}
|
||||
@@ -292,9 +339,18 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
if qty_precision is None and step > 0:
|
||||
try:
|
||||
# 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:
|
||||
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:
|
||||
# If stepSize is 1 or larger, precision is 0
|
||||
qty_precision = 0
|
||||
@@ -306,8 +362,8 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
q = self._floor_to_precision(q, qty_precision)
|
||||
|
||||
if min_qty > 0 and q < min_qty:
|
||||
return Decimal("0")
|
||||
return q
|
||||
return (Decimal("0"), qty_precision)
|
||||
return (q, qty_precision)
|
||||
|
||||
def ping(self) -> bool:
|
||||
code, data, _ = self._request("GET", "/fapi/v1/time")
|
||||
@@ -555,7 +611,7 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
if sd not in ("BUY", "SELL"):
|
||||
raise LiveTradingError(f"Invalid side: {side}")
|
||||
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:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
|
||||
@@ -575,7 +631,7 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
if notional < min_notional:
|
||||
raise LiveTradingError(
|
||||
"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"minNotional={self._dec_str(min_notional)}"
|
||||
)
|
||||
@@ -589,7 +645,7 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
"symbol": sym,
|
||||
"side": sd,
|
||||
"type": "MARKET",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
|
||||
}
|
||||
if reduce_only:
|
||||
params["reduceOnly"] = "true"
|
||||
@@ -675,7 +731,7 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
pass
|
||||
raise LiveTradingError(
|
||||
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"stepSize={step} quantityPrecision={qty_prec} minNotional={min_not} "
|
||||
f"dualSidePosition={dual_mode} positionSide={pos_side_used} "
|
||||
@@ -714,7 +770,7 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
px = float(price or 0.0)
|
||||
if q_req <= 0 or px <= 0:
|
||||
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:
|
||||
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
|
||||
px_dec = self._normalize_price(symbol=symbol, price=px)
|
||||
@@ -726,7 +782,7 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
"side": sd,
|
||||
"type": "LIMIT",
|
||||
"timeInForce": "GTC",
|
||||
"quantity": self._dec_str(q_dec),
|
||||
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
|
||||
"price": self._dec_str(px_dec),
|
||||
}
|
||||
if reduce_only:
|
||||
@@ -777,7 +833,7 @@ class BinanceFuturesClient(BaseRestClient):
|
||||
pass
|
||||
raise LiveTradingError(
|
||||
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)}"
|
||||
)
|
||||
exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "")
|
||||
|
||||
Reference in New Issue
Block a user