diff --git a/backend_api_python/app/services/live_trading/binance.py b/backend_api_python/app/services/live_trading/binance.py index b0496f6..952b1d0 100644 --- a/backend_api_python/app/services/live_trading/binance.py +++ b/backend_api_python/app/services/live_trading/binance.py @@ -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 "") diff --git a/backend_api_python/app/services/live_trading/binance_spot.py b/backend_api_python/app/services/live_trading/binance_spot.py index 3eb2d30..db4302e 100644 --- a/backend_api_python/app/services/live_trading/binance_spot.py +++ b/backend_api_python/app/services/live_trading/binance_spot.py @@ -38,33 +38,72 @@ class BinanceSpotClient(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 + # 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 - # 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 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 s = format(f, f".{max_decimals}f") if '.' in s: @@ -77,6 +116,16 @@ class BinanceSpotClient(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('.') @@ -236,13 +285,16 @@ class BinanceSpotClient(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 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) if q <= 0: - return Decimal("0") + return (Decimal("0"), None) fdict: Dict[str, Any] = {} try: fdict = self.get_symbol_filters(symbol=symbol) or {} @@ -272,9 +324,18 @@ class BinanceSpotClient(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 @@ -286,8 +347,8 @@ class BinanceSpotClient(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 place_limit_order( self, @@ -306,7 +367,7 @@ class BinanceSpotClient(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) @@ -318,7 +379,7 @@ class BinanceSpotClient(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 client_order_id: @@ -328,7 +389,7 @@ class BinanceSpotClient(BaseRestClient): except LiveTradingError as e: 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)}" ) return LiveOrderResult( @@ -352,7 +413,7 @@ class BinanceSpotClient(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}") @@ -360,7 +421,7 @@ class BinanceSpotClient(BaseRestClient): "symbol": sym, "side": sd, "type": "MARKET", - "quantity": self._dec_str(q_dec), + "quantity": self._dec_str(q_dec, strict_precision=qty_precision), } if client_order_id: params["newClientOrderId"] = str(client_order_id) @@ -369,7 +430,7 @@ class BinanceSpotClient(BaseRestClient): except LiveTradingError as e: 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)}" ) return LiveOrderResult( exchange_id="binance", diff --git a/backend_api_python/app/services/live_trading/bitget.py b/backend_api_python/app/services/live_trading/bitget.py index 862b8ed..b8f6858 100644 --- a/backend_api_python/app/services/live_trading/bitget.py +++ b/backend_api_python/app/services/live_trading/bitget.py @@ -54,15 +54,34 @@ class BitgetMixClient(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. 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: if d == 0: return "0" 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") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -72,6 +91,16 @@ class BitgetMixClient(BaseRestClient): 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 s = format(f, f".{max_decimals}f") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -81,6 +110,16 @@ class BitgetMixClient(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('.') @@ -219,17 +258,20 @@ class BitgetMixClient(BaseRestClient): self._contract_cache[key] = (now, first) 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. 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. """ req_base = self._to_dec(base_size) if req_base <= 0: - return Decimal("0") + return (Decimal("0"), None) contract: Dict[str, Any] = {} try: @@ -245,6 +287,7 @@ class BitgetMixClient(BaseRestClient): # Determine step size. 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") try: @@ -253,15 +296,32 @@ class BitgetMixClient(BaseRestClient): places = 0 if places >= 0 and places <= 18: step = Decimal("1") / (Decimal("10") ** Decimal(str(places))) + size_precision = places if step > 0: 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. mn = self._to_dec(contract.get("minTradeNum") or contract.get("minSize") or contract.get("minQty") or "0") if mn > 0 and qty < mn: - return Decimal("0") - return qty + return (Decimal("0"), size_precision) + return (qty, size_precision) def ping(self) -> bool: code, data, _ = self._request("GET", "/api/v2/public/time") @@ -354,7 +414,7 @@ class BitgetMixClient(BaseRestClient): if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") 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: raise LiveTradingError(f"Invalid size (below step/min): requested={req}") @@ -365,7 +425,7 @@ class BitgetMixClient(BaseRestClient): "marginMode": self._normalize_margin_mode(margin_mode), "side": sd, "orderType": "market", - "size": self._dec_str(sz_dec), + "size": self._dec_str(sz_dec, strict_precision=sz_precision), } if reduce_only: body["reduceOnly"] = "YES" @@ -408,7 +468,7 @@ class BitgetMixClient(BaseRestClient): px = float(price or 0.0) if req <= 0 or px <= 0: 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: raise LiveTradingError(f"Invalid size (below step/min): requested={req}") @@ -420,7 +480,7 @@ class BitgetMixClient(BaseRestClient): "side": sd, "orderType": "limit", "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). if post_only: diff --git a/backend_api_python/app/services/live_trading/bitget_spot.py b/backend_api_python/app/services/live_trading/bitget_spot.py index e8b972f..827c20a 100644 --- a/backend_api_python/app/services/live_trading/bitget_spot.py +++ b/backend_api_python/app/services/live_trading/bitget_spot.py @@ -54,15 +54,34 @@ class BitgetSpotClient(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. 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: if d == 0: return "0" 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") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -72,6 +91,16 @@ class BitgetSpotClient(BaseRestClient): 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 s = format(f, f".{max_decimals}f") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -81,6 +110,16 @@ class BitgetSpotClient(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('.') @@ -200,13 +239,16 @@ class BitgetSpotClient(BaseRestClient): self._sym_meta_cache[sym] = (now, 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). + + Returns: + Tuple of (normalized_size, precision) where precision is the number of decimal places required. """ req = self._to_dec(base_size) if req <= 0: - return Decimal("0") + return (Decimal("0"), None) meta: Dict[str, Any] = {} try: @@ -216,6 +258,7 @@ class BitgetSpotClient(BaseRestClient): # 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") + size_precision = None if step <= 0: # Some endpoints expose decimals instead of step. qd = meta.get("quantityPrecision") or meta.get("quantityPlace") or meta.get("sizePlace") @@ -225,14 +268,31 @@ class BitgetSpotClient(BaseRestClient): places = 0 if places >= 0 and places <= 18: step = Decimal("1") / (Decimal("10") ** Decimal(str(places))) + size_precision = places if step > 0: 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") if mn > 0 and req < mn: - return Decimal("0") - return req + 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: sym = to_bitget_um_symbol(symbol) @@ -243,14 +303,14 @@ class BitgetSpotClient(BaseRestClient): px = float(price or 0.0) if req <= 0 or px <= 0: 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: raise LiveTradingError(f"Invalid size (below step/min): requested={req}") body: Dict[str, Any] = { "side": sd, "symbol": sym, - "size": self._dec_str(sz_dec), + "size": self._dec_str(sz_dec, strict_precision=sz_precision), "orderType": "limit", "force": "gtc", "price": str(px), @@ -278,10 +338,10 @@ class BitgetSpotClient(BaseRestClient): # 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. 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: 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: sz_str = str(req) diff --git a/backend_api_python/app/services/live_trading/bybit.py b/backend_api_python/app/services/live_trading/bybit.py index eb46072..6398ba2 100644 --- a/backend_api_python/app/services/live_trading/bybit.py +++ b/backend_api_python/app/services/live_trading/bybit.py @@ -61,15 +61,34 @@ class BybitClient(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. 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: if d == 0: return "0" 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") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -79,6 +98,16 @@ class BybitClient(BaseRestClient): 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 s = format(f, f".{max_decimals}f") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -88,6 +117,16 @@ class BybitClient(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('.') @@ -201,10 +240,10 @@ class BybitClient(BaseRestClient): self._inst_cache[key] = (now, first) 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) if q <= 0: - return Decimal("0") + return (Decimal("0"), None) sym = to_bybit_symbol(symbol) try: 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") 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] + 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: - return Decimal("0") - return q + return (Decimal("0"), qty_precision) + return (q, qty_precision) def place_market_order( self, @@ -233,7 +291,7 @@ class BybitClient(BaseRestClient): if sd not in ("buy", "sell"): raise LiveTradingError(f"Invalid side: {side}") 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: raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") body: Dict[str, Any] = { @@ -241,7 +299,7 @@ class BybitClient(BaseRestClient): "symbol": sym, "side": "Buy" if sd == "buy" else "Sell", "orderType": "Market", - "qty": self._dec_str(q_dec), + "qty": self._dec_str(q_dec, strict_precision=qty_precision), "timeInForce": "GTC", } if reduce_only and self.category == "linear": @@ -271,7 +329,7 @@ class BybitClient(BaseRestClient): px = float(price or 0.0) if q_req <= 0 or px <= 0: 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: raise LiveTradingError(f"Invalid qty (below step/min): requested={q_req}") body: Dict[str, Any] = { @@ -279,7 +337,7 @@ class BybitClient(BaseRestClient): "symbol": sym, "side": "Buy" if sd == "buy" else "Sell", "orderType": "Limit", - "qty": self._dec_str(q_dec), + "qty": self._dec_str(q_dec, strict_precision=qty_precision), "price": str(px), "timeInForce": "GTC", } diff --git a/backend_api_python/app/services/live_trading/deepcoin.py b/backend_api_python/app/services/live_trading/deepcoin.py index 761073d..79b30d4 100644 --- a/backend_api_python/app/services/live_trading/deepcoin.py +++ b/backend_api_python/app/services/live_trading/deepcoin.py @@ -74,15 +74,35 @@ class DeepcoinClient(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. 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: 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('.') + return s if s else "0" + except Exception: + pass + s = format(normalized, f".{max_decimals}f") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -92,6 +112,16 @@ class DeepcoinClient(BaseRestClient): 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 s = format(f, f".{max_decimals}f") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -101,6 +131,16 @@ class DeepcoinClient(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('.') @@ -366,13 +406,16 @@ class DeepcoinClient(BaseRestClient): except Exception: 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. + + 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") + return (Decimal("0"), None) sym = to_deepcoin_symbol(symbol) try: @@ -386,9 +429,28 @@ class DeepcoinClient(BaseRestClient): 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] + 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: - return Decimal("0") - return q + return (Decimal("0"), qty_precision) + return (q, qty_precision) def place_market_order( self, @@ -419,7 +481,7 @@ class DeepcoinClient(BaseRestClient): raise LiveTradingError(f"Invalid side: {side}") 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: 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", "side": sd, "ordType": "market", - "sz": self._dec_str(q_dec), + "sz": self._dec_str(q_dec, strict_precision=qty_precision), } if self.market_type != "spot": @@ -480,7 +542,7 @@ class DeepcoinClient(BaseRestClient): if q_req <= 0 or px <= 0: 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: 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", "side": sd, "ordType": "limit", - "sz": self._dec_str(q_dec), + "sz": self._dec_str(q_dec, strict_precision=qty_precision), "px": str(px), } diff --git a/backend_api_python/app/services/live_trading/okx.py b/backend_api_python/app/services/live_trading/okx.py index abbb830..963f969 100644 --- a/backend_api_python/app/services/live_trading/okx.py +++ b/backend_api_python/app/services/live_trading/okx.py @@ -52,16 +52,41 @@ class OkxClient(BaseRestClient): self._lev_cache_ttl_sec = 60.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 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) + strict_precision: If provided, strictly limit to this many decimal places """ try: if d == 0: 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: + 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 s = format(normalized, f".{max_decimals}f") if '.' in s: @@ -72,6 +97,16 @@ class OkxClient(BaseRestClient): 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 s = format(f, f".{max_decimals}f") if '.' in s: s = s.rstrip('0').rstrip('.') @@ -81,6 +116,16 @@ class OkxClient(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('.') @@ -146,19 +191,22 @@ class OkxClient(BaseRestClient): self._inst_cache[key] = (now, first) 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: - 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. 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() iid = str(inst_id or "").strip() req = self._to_dec(size) if req <= 0: - return Decimal("0") + return (Decimal("0"), None) inst_type = "SPOT" if mt == "spot" else "SWAP" inst: Dict[str, Any] = {} @@ -180,11 +228,29 @@ 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] + 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. if min_sz > 0 and req < min_sz: - return Decimal("0") - return req + return (Decimal("0"), size_precision) + return (req, size_precision) def _iso_ts(self) -> str: # 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"): raise LiveTradingError(f"Invalid side: {side}") 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: raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") @@ -400,7 +466,7 @@ class OkxClient(BaseRestClient): "tdMode": "cash", "side": sd, "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. "tgtCcy": "base_ccy", } @@ -462,7 +528,7 @@ class OkxClient(BaseRestClient): if mt == "spot": 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: raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") body: Dict[str, Any] = { @@ -470,13 +536,13 @@ class OkxClient(BaseRestClient): "tdMode": "cash", "side": sd, "ordType": "limit", - "sz": self._dec_str(sz_dec), + "sz": self._dec_str(sz_dec, strict_precision=sz_precision), "px": str(px), } else: inst_id = to_okx_swap_inst_id(symbol) 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: raise LiveTradingError(f"Invalid size (below lot/min size): requested={sz_raw}") td = (td_mode or "cross").lower() @@ -488,7 +554,7 @@ class OkxClient(BaseRestClient): "side": sd, "posSide": ps, "ordType": "limit", - "sz": self._dec_str(sz_dec), + "sz": self._dec_str(sz_dec, strict_precision=sz_precision), "px": str(px), } if reduce_only: