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 a9e2ba2be..0d8a6deb6 100644 --- a/backend_api_python/app/services/live_trading/binance_spot.py +++ b/backend_api_python/app/services/live_trading/binance_spot.py @@ -162,11 +162,12 @@ def _decimal_places_from_step(step: Decimal) -> Optional[int]: if st <= 0: return None try: - normalized = st.normalize() - step_str = str(normalized) - if "." in step_str: - return min(18, max(0, len(step_str.split(".")[1]))) - return 0 + # normalize() renders small steps in scientific notation ("1E-8"), + # so derive the decimal places from the exponent instead. + exp = st.normalize().as_tuple().exponent + if not isinstance(exp, int): + return None + return min(18, max(0, -exp)) except Exception: return None diff --git a/backend_api_python/app/services/live_trading/bitget.py b/backend_api_python/app/services/live_trading/bitget.py index 1cf72bd32..7adbfdc28 100644 --- a/backend_api_python/app/services/live_trading/bitget.py +++ b/backend_api_python/app/services/live_trading/bitget.py @@ -535,17 +535,9 @@ def _normalize_size(self, *, symbol: str, product_type: str, base_size: float) - # 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 + exp = step.normalize().as_tuple().exponent + if isinstance(exp, int): + size_precision = min(max(0, -exp), 18) except Exception: pass @@ -645,16 +637,9 @@ def _normalize_price(self, *, symbol: str, product_type: str, price: float) -> T px = self._floor_to_step(px, step) if price_precision is None: try: - step_normalized = step.normalize() - step_str = str(step_normalized) - if "." in step_str: - price_precision = len(step_str.split(".")[1]) - if price_precision < 0: - price_precision = 0 - if price_precision > 18: - price_precision = 18 - else: - price_precision = 0 + exp = step.normalize().as_tuple().exponent + if isinstance(exp, int): + price_precision = min(max(0, -exp), 18) except Exception: pass diff --git a/backend_api_python/app/services/live_trading/bybit.py b/backend_api_python/app/services/live_trading/bybit.py index 0c798e7b0..5fe04eb71 100644 --- a/backend_api_python/app/services/live_trading/bybit.py +++ b/backend_api_python/app/services/live_trading/bybit.py @@ -528,21 +528,14 @@ def _normalize_qty(self, *, symbol: str, qty: float) -> Tuple[Decimal, Optional[ if step > 0: q = self._floor_to_step(q, step) - # Infer precision from qtyStep + # Infer precision from qtyStep. Decimal.normalize() renders small steps in + # scientific notation, so derive precision from the Decimal exponent. 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 + exp = step.normalize().as_tuple().exponent + if isinstance(exp, int): + qty_precision = min(max(0, -exp), 18) except Exception: pass @@ -637,16 +630,9 @@ def _normalize_price(self, *, symbol: str, price: float) -> Tuple[Decimal, Optio price_precision = None if tick > 0: try: - tick_normalized = tick.normalize() - tick_str = str(tick_normalized) - if "." in tick_str: - price_precision = len(tick_str.split(".")[1]) - if price_precision < 0: - price_precision = 0 - if price_precision > 18: - price_precision = 18 - else: - price_precision = 0 + exp = tick.normalize().as_tuple().exponent + if isinstance(exp, int): + price_precision = min(max(0, -exp), 18) except Exception: pass return (p, price_precision) diff --git a/backend_api_python/tests/test_step_precision_scientific_notation.py b/backend_api_python/tests/test_step_precision_scientific_notation.py new file mode 100644 index 000000000..ba1d9a052 --- /dev/null +++ b/backend_api_python/tests/test_step_precision_scientific_notation.py @@ -0,0 +1,71 @@ +"""Precision inference from exchange step sizes below 1E-6. + +Decimal.normalize() renders steps such as 0.0000001 as "1E-7". Parsing that +string for a '.' misreads the precision as 0, which truncates prices and +quantities to integers when they are rendered for the exchange. +""" + +import time +from decimal import Decimal + +from app.services.live_trading.binance_spot import BinanceSpotClient +from app.services.live_trading.bitget import BitgetMixClient +from app.services.live_trading.bybit import BybitClient + + +def _bybit_client(price_tick: str, qty_step: str) -> BybitClient: + client = BybitClient(api_key="k", secret_key="s", category="linear") + client._inst_cache["linear:1000PEPEUSDT"] = ( + time.time(), + { + "priceFilter": {"tickSize": price_tick}, + "lotSizeFilter": {"qtyStep": qty_step, "minOrderQty": "0"}, + }, + ) + return client + + +def _bitget_client(contract: dict) -> BitgetMixClient: + client = BitgetMixClient.__new__(BitgetMixClient) + client._contract_cache = {} + client._contract_cache_ttl_sec = 300.0 + client.get_contract = lambda **_kwargs: contract + return client + + +def test_bybit_small_tick_keeps_price_decimals(): + client = _bybit_client("0.0000001", "100") + price, precision = client._normalize_price(symbol="1000PEPE/USDT", price=0.01234567) + assert precision == 7 + assert client._dec_str(price, strict_precision=precision) == "0.0123456" + + +def test_bybit_small_qty_step_keeps_qty_decimals(): + client = _bybit_client("0.01", "0.00000001") + qty, precision = client._normalize_qty(symbol="1000PEPE/USDT", qty=0.123456789) + assert precision == 8 + assert client._dec_str(qty, strict_precision=precision) == "0.12345678" + + +def test_bitget_small_price_step_keeps_price_decimals(): + client = _bitget_client({"priceStep": "0.0000001"}) + price, precision = client._normalize_price( + symbol="PEPE/USDT", product_type="USDT-FUTURES", price=0.01234567 + ) + assert precision == 7 + assert client._dec_str(price, strict_precision=precision) == "0.0123456" + + +def test_bitget_small_size_step_keeps_size_decimals(): + client = _bitget_client({"sizeMultiplier": "0.00000001"}) + size, precision = client._normalize_size( + symbol="BTC/USDT", product_type="USDT-FUTURES", base_size=0.123456789 + ) + assert precision == 8 + assert client._dec_str(size, strict_precision=precision) == "0.12345678" + + +def test_binance_decimal_places_from_small_step(): + assert BinanceSpotClient._decimal_places_from_step(Decimal("0.00000001")) == 8 + assert BinanceSpotClient._decimal_places_from_step(Decimal("0.00001000")) == 5 + assert BinanceSpotClient._decimal_places_from_step(Decimal("10")) == 0