Programming Languages Bug Report

Orders rejected with Filter failure: LOT_SIZE and PRICE_FILTER: rounding to the exchange grid

Our rebalancing bot computed order sizes as fractions of a portfolio and about one order in five was rejected with Filter failure: LOT_SIZE or PRICE_FILTER. Quantities and prices must sit on each symbol's step and tick grid. Reading exchangeInfo filters and rounding with Decimal fixed it.

Ryan Pham Ryan Pham @ryan_bugs · November 06, 2024 · 6 min read · 13 Views
Banner: exchange rejecting orders whose size and price are not on the allowed grid

We run a rebalancing bot that keeps a basket of assets at target weights. It computes the difference between current and target value for each asset and places limit orders for the difference. After adding new pairs, rejections appeared regularly:

ERROR place_order SOLUSDT side=BUY qty=3.4182917 price=161.2347
      {"code":-1013,"msg":"Filter failure: LOT_SIZE"}
ERROR place_order ADAUSDT side=SELL qty=1520.6 price=0.354981
      {"code":-1013,"msg":"Filter failure: PRICE_FILTER"}
ERROR place_order DOGEUSDT side=BUY qty=12 price=0.1612
      {"code":-1013,"msg":"Filter failure: NOTIONAL"}

About 20 percent of rebalancing orders failed, so baskets drifted further from targets every day.

Reproducing it

Fetching the symbol rules:

info = requests.get("https://api.binance.com/api/v3/exchangeInfo", params={"symbol": "SOLUSDT"}).json()
for f in info["symbols"][0]["filters"]:
    print(f)
{'filterType': 'PRICE_FILTER', 'minPrice': '0.01000000', 'maxPrice': '10000.00000000', 'tickSize': '0.01000000'}
{'filterType': 'LOT_SIZE', 'minQty': '0.00100000', 'maxQty': '9000.00000000', 'stepSize': '0.00100000'}
{'filterType': 'NOTIONAL', 'minNotional': '5.00000000', 'applyMinToMarket': True, 'maxNotional': '9000000.00000000', ...}

Our quantity 3.4182917 had seven decimals; the step size allows three. Our price 161.2347 had four decimals; the tick size allows two.

What we ruled out

  • Insufficient balance. That returns a different error.
  • Market closed or symbol trading halted. Other orders for the same symbol were accepted.

Root cause

The bot used a hardcoded rounding of 6 decimals for quantity and price, tuned for the first pairs we traded. Every symbol has its own price tick size, quantity step size, minimum and maximum quantity, and minimum order value (notional). Values that are not exact multiples of the step and tick, or that fall below the minimum notional, are rejected. New pairs had different rules.

What is happening under the hood

Exchanges enforce a grid for each symbol:

  • PRICE_FILTER: price must be within min and max and (price - minPrice) % tickSize == 0.
  • LOT_SIZE: quantity must be within min and max and (quantity - minQty) % stepSize == 0. MARKET_LOT_SIZE applies to market orders.
  • NOTIONAL (older symbols used MIN_NOTIONAL): price times quantity must be within bounds.
  • Other filters exist, such as PERCENT_PRICE_BY_SIDE (price must be close to the current market) and MAX_NUM_ORDERS.

These rules change over time: exchanges adjust tick sizes when prices move a lot. Hardcoding them breaks eventually.

Floating point makes the arithmetic subtle: 3.418 / 0.001 in binary floating point is not exactly 3418, so naive checks fail at the edges.

Environment

Component Version / value
Bot Python 3.12, own REST client
Exchange Binance Spot
Symbols 14 USDT pairs
Rounding Hardcoded 6 decimals, float arithmetic

Minimal reproduction

qty = round(target_value / price, 6)        # 3.418291 -> not on 0.001 step
price = round(mid_price * 0.999, 6)         # 161.234700 -> not on 0.01 tick

The fix

1. Load filters from exchangeInfo and cache them, refreshing every hour and when an order is rejected with a filter failure.

2. Round with Decimal, in the safe direction. Quantities round down (never spend more than intended). Buy prices round down, sell prices round up, so a limit order is never more aggressive than intended.

from decimal import Decimal, ROUND_DOWN, ROUND_UP

def floor_to_step(value: Decimal, step: Decimal) -> Decimal:
    return (value / step).to_integral_value(rounding=ROUND_DOWN) * step

def ceil_to_step(value: Decimal, step: Decimal) -> Decimal:
    return (value / step).to_integral_value(rounding=ROUND_UP) * step

def prepare_order(symbol: str, side: str, qty: Decimal, price: Decimal) -> tuple[str, str] | None:
    f = FILTERS[symbol]
    price = floor_to_step(price, f.tick) if side == "BUY" else ceil_to_step(price, f.tick)
    qty = floor_to_step(qty, f.step)
    if qty < f.min_qty or qty * price < f.min_notional:
        return None                                   # too small to place; carry to next cycle
    if qty > f.max_qty:
        qty = floor_to_step(f.max_qty, f.step)
    return format(qty.normalize(), "f"), format(price.normalize(), "f")

All values are Decimal built from the exchange's strings, never from floats, and are sent as strings, so 0.001 stays exactly 0.001.

3. Skip dust instead of failing. Orders below the minimum notional are not sent; the remainder is carried into the next rebalance.

Alternatives and trade-offs

  • Client libraries with built-in precision helpers (for example ccxt's amount_to_precision and price_to_precision). Good if you use them consistently; check their rounding direction.
  • Round to nearest. Simpler, but can round a quantity up above available balance or make a limit price more aggressive.

How to test the fix

Property-based tests generate random quantities and prices for each symbol's real filters and assert that every prepared order satisfies all filter rules, and that rounded quantity never exceeds the input. We also placed orders on the exchange's spot testnet for all 14 symbols with awkward values.

Confirming in production

  • Filter failure rejections went from about 20 percent of rebalancing orders to zero over the next month.
  • Basket tracking error against targets dropped from 1.8 percent to 0.3 percent.

Regression guard

  • Alert on any -1013 response.
  • Filters reloaded hourly and on rejection; a change in a symbol's filters is logged.
  • The property tests run against a fresh exchangeInfo snapshot in CI weekly.

Security implications

Correct rounding direction is a risk control: rounding a quantity up could exceed intended exposure, and rounding a buy price up makes the order pay more than planned. The bot's key has no withdrawal permission and is IP-restricted. Nothing in this post is advice about what or when to trade; it is about placing the orders you intended.

Prevention checklist

  • Never hardcode precision; read filters from the exchange.
  • Use Decimal from exchange strings, send strings.
  • Round quantities down and prices in the passive direction.
  • Check minimum notional before sending.
  • Refresh filters periodically and after rejections.

References

Add a label

#python #binance-api #trading-bot #decimal #exchange-filters
Written by
Ryan Pham @ryan_bugs

I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.