Skip to content

Commit e5bb37b

Browse files
committed
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.
1 parent d875acd commit e5bb37b

File tree

7 files changed

+505
-82
lines changed

7 files changed

+505
-82
lines changed

backend_api_python/app/services/live_trading/binance.py

Lines changed: 74 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,33 +47,67 @@ def _to_dec(x: Any) -> Decimal:
4747
return Decimal("0")
4848

4949
@staticmethod
50-
def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
50+
def _dec_str(d: Decimal, max_decimals: int = 18, strict_precision: Optional[int] = None) -> str:
5151
"""
5252
Convert Decimal to string with controlled precision.
5353
Binance requires quantities/prices to match LOT_SIZE/PRICE_FILTER precision.
5454
This method ensures the output string doesn't exceed the required precision.
55+
56+
Args:
57+
d: Decimal value to format
58+
max_decimals: Maximum decimal places (fallback if strict_precision not provided)
59+
strict_precision: If provided, strictly limit to this many decimal places (no trailing zero removal)
5560
"""
5661
try:
5762
if d == 0:
5863
return "0"
5964
# Normalize to remove unnecessary trailing zeros from internal representation
6065
normalized = d.normalize()
66+
67+
# If strict_precision is provided, use it and strictly limit decimal places
68+
# This ensures we match the stepSize requirement exactly
69+
if strict_precision is not None:
70+
try:
71+
prec = int(strict_precision)
72+
if prec < 0:
73+
prec = 0
74+
if prec > 18:
75+
prec = 18
76+
# Use quantize to ensure exact precision (round down to match stepSize)
77+
q = Decimal("1").scaleb(-prec)
78+
quantized = normalized.quantize(q, rounding=ROUND_DOWN)
79+
# Format with exact precision - this will produce at most 'prec' decimal places
80+
s = format(quantized, f".{prec}f")
81+
# Remove trailing zeros and decimal point if not needed
82+
if '.' in s:
83+
s = s.rstrip('0').rstrip('.')
84+
return s if s else "0"
85+
except Exception:
86+
pass
87+
88+
# Fallback to original logic if strict_precision not provided or failed
6189
# Convert to string using fixed-point notation
62-
# Use a reasonable max_decimals to avoid excessive precision
63-
# Binance typically uses 8 decimal places for most symbols
6490
s = format(normalized, f".{max_decimals}f")
6591
# Remove trailing zeros and decimal point if not needed
66-
# This ensures we don't send "0.02874400" when "0.028744" is sufficient
6792
if '.' in s:
6893
s = s.rstrip('0').rstrip('.')
6994
return s if s else "0"
7095
except Exception:
7196
# Fallback: try to convert safely
7297
try:
73-
# If Decimal conversion fails, try float with limited precision
7498
f = float(d)
7599
if f == 0:
76100
return "0"
101+
if strict_precision is not None:
102+
try:
103+
prec = int(strict_precision)
104+
if 0 <= prec <= 18:
105+
s = format(f, f".{prec}f")
106+
if '.' in s:
107+
s = s.rstrip('0').rstrip('.')
108+
return s if s else "0"
109+
except Exception:
110+
pass
77111
# Format with max_decimals and remove trailing zeros
78112
s = format(f, f".{max_decimals}f")
79113
if '.' in s:
@@ -86,6 +120,16 @@ def _dec_str(d: Decimal, max_decimals: int = 18) -> str:
86120
if 'e' in s.lower() or 'E' in s:
87121
try:
88122
f = float(s)
123+
if strict_precision is not None:
124+
try:
125+
prec = int(strict_precision)
126+
if 0 <= prec <= 18:
127+
s = format(f, f".{prec}f")
128+
if '.' in s:
129+
s = s.rstrip('0').rstrip('.')
130+
return s if s else "0"
131+
except Exception:
132+
pass
89133
s = format(f, f".{max_decimals}f")
90134
if '.' in s:
91135
s = s.rstrip('0').rstrip('.')
@@ -256,13 +300,16 @@ def _normalize_price(self, *, symbol: str, price: float) -> Decimal:
256300
return Decimal("0")
257301
return px
258302

259-
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Decimal:
303+
def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool) -> Tuple[Decimal, Optional[int]]:
260304
"""
261305
Normalize futures order quantity using LOT_SIZE / MARKET_LOT_SIZE filters (best-effort).
306+
307+
Returns:
308+
Tuple of (normalized_quantity, precision) where precision is the number of decimal places required.
262309
"""
263310
q = self._to_dec(quantity)
264311
if q <= 0:
265-
return Decimal("0")
312+
return (Decimal("0"), None)
266313
fdict: Dict[str, Any] = {}
267314
try:
268315
fdict = self.get_symbol_filters(symbol=symbol) or {}
@@ -292,9 +339,18 @@ def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool)
292339
if qty_precision is None and step > 0:
293340
try:
294341
# stepSize like "0.001" means 3 decimal places
295-
step_str = str(step).rstrip('0')
342+
# Use normalize() to remove trailing zeros, then count decimal places
343+
step_normalized = step.normalize()
344+
step_str = str(step_normalized)
296345
if '.' in step_str:
297-
qty_precision = len(step_str.split('.')[1])
346+
# Count decimal places after removing trailing zeros
347+
decimal_part = step_str.split('.')[1]
348+
qty_precision = len(decimal_part)
349+
# Ensure precision is at least 0 and at most 18
350+
if qty_precision < 0:
351+
qty_precision = 0
352+
if qty_precision > 18:
353+
qty_precision = 18
298354
else:
299355
# If stepSize is 1 or larger, precision is 0
300356
qty_precision = 0
@@ -306,8 +362,8 @@ def _normalize_quantity(self, *, symbol: str, quantity: float, for_market: bool)
306362
q = self._floor_to_precision(q, qty_precision)
307363

308364
if min_qty > 0 and q < min_qty:
309-
return Decimal("0")
310-
return q
365+
return (Decimal("0"), qty_precision)
366+
return (q, qty_precision)
311367

312368
def ping(self) -> bool:
313369
code, data, _ = self._request("GET", "/fapi/v1/time")
@@ -555,7 +611,7 @@ def place_market_order(
555611
if sd not in ("BUY", "SELL"):
556612
raise LiveTradingError(f"Invalid side: {side}")
557613
q_req = float(quantity or 0.0)
558-
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True)
614+
q_dec, qty_precision = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=True)
559615
if float(q_dec or 0) <= 0:
560616
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
561617

@@ -575,7 +631,7 @@ def place_market_order(
575631
if notional < min_notional:
576632
raise LiveTradingError(
577633
"Order notional is below MIN_NOTIONAL. "
578-
f"symbol={sym} side={sd} qty={self._dec_str(q_dec)} "
634+
f"symbol={sym} side={sd} qty={self._dec_str(q_dec, strict_precision=qty_precision)} "
579635
f"markPrice={mark_price} notional={self._dec_str(notional)} "
580636
f"minNotional={self._dec_str(min_notional)}"
581637
)
@@ -589,7 +645,7 @@ def place_market_order(
589645
"symbol": sym,
590646
"side": sd,
591647
"type": "MARKET",
592-
"quantity": self._dec_str(q_dec),
648+
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
593649
}
594650
if reduce_only:
595651
params["reduceOnly"] = "true"
@@ -675,7 +731,7 @@ def place_market_order(
675731
pass
676732
raise LiveTradingError(
677733
f"{e} | debug: symbol={sym} side={sd} "
678-
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} "
734+
f"qty_req={q_req} qty_norm={self._dec_str(q_dec, strict_precision=qty_precision)} "
679735
f"base_url={self.base_url} filtersSymbol={filt_symbol} contractType={contract_type} "
680736
f"stepSize={step} quantityPrecision={qty_prec} minNotional={min_not} "
681737
f"dualSidePosition={dual_mode} positionSide={pos_side_used} "
@@ -714,7 +770,7 @@ def place_limit_order(
714770
px = float(price or 0.0)
715771
if q_req <= 0 or px <= 0:
716772
raise LiveTradingError("Invalid quantity/price")
717-
q_dec = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False)
773+
q_dec, qty_precision = self._normalize_quantity(symbol=symbol, quantity=q_req, for_market=False)
718774
if float(q_dec or 0) <= 0:
719775
raise LiveTradingError(f"Invalid quantity (below step/minQty): requested={q_req}")
720776
px_dec = self._normalize_price(symbol=symbol, price=px)
@@ -726,7 +782,7 @@ def place_limit_order(
726782
"side": sd,
727783
"type": "LIMIT",
728784
"timeInForce": "GTC",
729-
"quantity": self._dec_str(q_dec),
785+
"quantity": self._dec_str(q_dec, strict_precision=qty_precision),
730786
"price": self._dec_str(px_dec),
731787
}
732788
if reduce_only:
@@ -777,7 +833,7 @@ def place_limit_order(
777833
pass
778834
raise LiveTradingError(
779835
f"{e} | debug: symbol={sym} side={sd} "
780-
f"qty_req={q_req} qty_norm={self._dec_str(q_dec)} "
836+
f"qty_req={q_req} qty_norm={self._dec_str(q_dec, strict_precision=qty_precision)} "
781837
f"price_req={px} price_norm={self._dec_str(px_dec)}"
782838
)
783839
exchange_order_id = str(raw.get("orderId") or raw.get("clientOrderId") or "")

0 commit comments

Comments
 (0)