---
title: "Binance Order Filters for Trading Bots"
description: "Use Binance exchange info to size trading bot orders: price ticks, quantity step sizes, minimum notional, and cached symbol metadata with refreshes."
canonical: "https://siebly.io/blog/binance-exchange-info-filters-trading-bots"
---

# Binance Order Filters for Trading Bots

Use Binance exchange info to size trading bot orders: price ticks, quantity step sizes, minimum notional, and cached symbol metadata with refreshes.

## Technical Disclaimer

These articles are software engineering references for exchange API integrations. They are not financial, investment, legal, tax, compliance, or trading advice. Use public data, demo, testnet, or paper workflows first. Keep API credentials out of frontend code and disable withdrawal permissions for automation keys.

## Reading Binance Exchange Info {#exchange-info}

A bot allocates 600 USDT to a BTCUSDT order and divides that amount by its limit price. The result is a valid decimal number. It may still be an invalid order quantity: Binance accepts quantities in specific increments, and the rounded order must also meet the minimum order value.

Hardcoding those increments works until the bot trades another symbol or the exchange changes a rule. Fetching every symbol's rules before every order creates a different problem. Order submission now waits on an extra request, even when the metadata hasn't changed.

A cached `exchangeInfo` response handles the repeated reads. The bot loads it before preparing orders, refreshes it separately, and checks the final price and quantity against one snapshot. This article builds that order-preparation loop with the [Binance JavaScript SDK](/sdk/binance/javascript). It uses public USD-M Futures data and simulated submissions; it needs no API keys and doesn't place orders.

`USDMClient.getExchangeInfo()` returns the USD-M Futures instruments. The bot finds `BTCUSDT`, checks its trading status, and reads its `filters` array by `filterType`. The [exchange-information documentation](https://developers.binance.com/en/docs/catalog/core-trading-derivatives-trading-usd-s-m-futures/api/rest-api/market-data) explicitly warns against treating `pricePrecision` as `tickSize` or `quantityPrecision` as `stepSize`.

For example, two decimal places can represent both `1.23` and `1.25`. An increment of `0.05`, starting from zero, permits the second value but not the first. Calling `toFixed(2)` doesn't establish that a value falls on an allowed increment.

## Price Ticks and Quantity Steps {#price-ticks-and-step-sizes}

The example prepares ordinary limit buys in one-way mode. These are the main [USD-M Futures filters](https://developers.binance.com/en/docs/products/derivatives-trading-usds-futures/common-definition) involved:

| Rule | Filter |
| --- | --- |
| Price | `PRICE_FILTER`: bounds and `tickSize`. A zero price-rule value disables that rule. |
| Quantity | `LOT_SIZE`: bounds and `stepSize` for the limit order. |
| Order value | `MIN_NOTIONAL`: final price multiplied by quantity reaches `notional`. |
| Price band | `PERCENT_PRICE`: a buy price stays below the mark-price ceiling. |
| Open orders | `MAX_NUM_ORDERS`: the account has capacity for another order on the symbol. |

Price and quantity rounding serve different purposes. Rounding a buy limit down preserves the maximum price the strategy requested. Rounding its quantity down preserves the allocation. A sell limit that represents a minimum acceptable price needs rounding up instead.

The order manager checks notional after both adjustments. A 600 USDT allocation may produce an order worth 590 USDT after quantity rounding. Raising the quantity to spend the remainder can exceed the allocation. If rounding takes the result below the minimum notional, the example rejects it.

Binance returns decimal values as strings. The calculation uses [decimal.js](https://mikemcl.github.io/decimal.js/) to keep those values in decimal arithmetic through rounding and validation. Conversion to the SDK's numeric request fields happens at the end, with a check that conversion hasn't changed the decimal value.

## Caching Symbol Metadata {#cached-exchange-info}

Save the three JavaScript blocks below, in order, as `binance-filter-bot.mjs`. Install the dependencies and run it with a shortened refresh interval:

```sh
npm install binance decimal.js
DEMO_FAST_REFRESH=1 node binance-filter-bot.mjs
```

The cache shares an in-progress refresh between callers. It constructs a replacement symbol map before making it available, so an order never reads a half-updated map. A failed request leaves the previous snapshot and its retrieval time intact.

```js title="binance-filter-bot.mjs: metadata cache"
import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import { USDMClient } from 'binance';
import Decimal from 'decimal.js';

const D = Decimal.clone({ precision: 40 });

function createMetadataCache(client, {
  maxAgeMs = 60 * 60_000,
  retryDelayMs = 30_000,
  now = () => performance.now(),
} = {}) {
  let snapshot;
  let pending;
  let retryAfter = 0;

  async function refresh() {
    if (pending) return pending;
    if (now() < retryAfter) throw new Error('Metadata refresh retry delayed');

    pending = (async () => {
      const info = await client.getExchangeInfo();
      if (!Array.isArray(info.symbols) || info.symbols.length === 0) {
        throw new Error('Empty exchange info response');
      }
      const symbols = new Map();
      for (const instrument of info.symbols) {
        if (typeof instrument.symbol !== 'string'
          || !Array.isArray(instrument.filters)
          || symbols.has(instrument.symbol)) {
          throw new Error('Invalid exchange info response');
        }
        symbols.set(instrument.symbol, instrument);
      }
      snapshot = { symbols, receivedAt: now() };
      console.log('Metadata refreshed:', symbols.size, 'symbols');
    })();

    try {
      await pending;
    } catch (error) {
      retryAfter = now() + retryDelayMs;
      throw error;
    } finally {
      pending = undefined;
    }
  }

  function get(symbol) {
    if (!snapshot || now() - snapshot.receivedAt > maxAgeMs) {
      throw new Error('Symbol metadata unavailable or too old');
    }
    const instrument = snapshot.symbols.get(symbol);
    if (instrument?.status !== 'TRADING') {
      throw new Error(`${symbol} is unavailable for new orders`);
    }
    return instrument;
  }

  return { refresh, get };
}
```

The one-hour maximum age is an application setting for this example, not a guarantee from Binance. A refresh failure doesn't immediately invalidate a usable snapshot. Repeated failures eventually make `get()` refuse new order preparation. Cancellation and account recovery should remain available through their own paths.

The refresh interval and maximum age answer separate questions: when to fetch again, and how long the bot accepts the last successful response after fetches fail. Neither proves that a rule is unchanged. The exchange can change a filter between a local check and submission.

A replacement map also handles removals. If a symbol disappears from a successful response, the cache stops returning its old entry. Updating existing entries without removing absent ones would leave delisted instruments available locally.

## Preparing a Limit Order {#prepare-limit-order}

The next block sizes a BTCUSDT buy from a USDT allocation. It checks the supported order type, price and quantity bounds, minimum notional, and the buy-side mark-price ceiling. It leaves account-dependent checks, including open-order counts and available margin, to the execution service.

The increment calculation starts from the filter's minimum, following the USD-M Futures rules. Price ticks can be disabled with zero. This example requires a positive quantity step and rejects metadata it can't use rather than guessing a default.

```js title="binance-filter-bot.mjs: order preparation"
function amount(value, allowZero = false) {
  const number = new D(value);
  if (!number.isFinite() || (allowZero ? number.lt(0) : number.lte(0))) {
    throw new Error(`Invalid decimal: ${value}`);
  }
  return number;
}

function roundDown(value, minimum, step) {
  if (step.isZero()) return value;
  return value.minus(minimum).div(step).floor().mul(step).plus(minimum);
}

function sdkNumber(value) {
  const number = value.toNumber();
  if (!Number.isFinite(number) || !value.eq(String(number))) {
    throw new Error('Decimal cannot be represented by the request field');
  }
  return number;
}

function prepareBuy(instrument, { budgetUsdt, limitPrice, markPrice, clientOrderId }) {
  if (!instrument.orderTypes.includes('LIMIT')
    || !instrument.timeInForce.includes('GTC')) {
    throw new Error('LIMIT GTC unavailable');
  }
  const filters = new Map(instrument.filters.map((item) => [item.filterType, item]));
  function required(type) {
    const filter = filters.get(type);
    if (!filter) throw new Error(`Missing ${type}`);
    return filter;
  }
  const priceRule = required('PRICE_FILTER');
  const lot = required('LOT_SIZE');
  const minPrice = amount(priceRule.minPrice, true);
  const maxPrice = amount(priceRule.maxPrice, true);
  const tick = amount(priceRule.tickSize, true);
  const minQty = amount(lot.minQty, true);
  const maxQty = amount(lot.maxQty);
  const step = amount(lot.stepSize);
  const budget = amount(budgetUsdt);

  const price = roundDown(amount(limitPrice), minPrice, tick);
  if (price.lte(0) || price.lt(minPrice)
    || (!maxPrice.isZero() && price.gt(maxPrice))) {
    throw new Error('Price outside PRICE_FILTER bounds');
  }
  const quantity = roundDown(budget.div(price), minQty, step);
  if (quantity.lte(0) || quantity.lt(minQty) || quantity.gt(maxQty)) {
    throw new Error('Quantity outside LOT_SIZE bounds');
  }
  const notional = price.mul(quantity);
  if (notional.gt(budget)) throw new Error('Rounded order exceeds allocation');
  if (notional.lt(amount(required('MIN_NOTIONAL').notional))) {
    throw new Error('Rounded order below MIN_NOTIONAL');
  }
  const buyCeiling = amount(markPrice)
    .mul(amount(required('PERCENT_PRICE').multiplierUp));
  if (price.gt(buyCeiling)) throw new Error('Buy price exceeds PERCENT_PRICE');

  return {
    symbol: instrument.symbol,
    side: 'BUY', type: 'LIMIT', timeInForce: 'GTC', positionSide: 'BOTH',
    price: sdkNumber(price), quantity: sdkNumber(quantity),
    newClientOrderId: clientOrderId,
  };
}
```

The function rejects an oversized quantity instead of silently shrinking it to the symbol's maximum. Splitting an allocation into several orders would be a separate execution decision, with several requests, reservations, and possible partial fills.

This function supports the BTCUSDT limit-buy path shown here. It isn't a complete local copy of Binance's validator. Account restrictions, position controls, margin requirements, and other applicable rules can still reject an order. The [pre-trade checks article](/blog/safety-boundaries-for-nodejs-trading-systems) covers the application checks around submission.

## Running the Bot {#running-the-bot}

The final block loads metadata before starting, refreshes it on a timer, and prepares six order candidates. Each iteration reads a public bid and mark price, then takes the current cached instrument. There is no asynchronous work between that cache read and preparation.

```js title="binance-filter-bot.mjs: dry-run bot"
const client = new USDMClient({}, { timeout: 10_000 });
const metadata = createMetadataCache(client);
const symbol = 'BTCUSDT';
const refreshEveryMs = process.env.DEMO_FAST_REFRESH === '1' ? 15_000 : 600_000;

const execution = {
  async submitNewOrder(order) {
    console.log('DRY RUN:', JSON.stringify(order));
    return { clientOrderId: order.newClientOrderId, simulated: true };
  },
};

await metadata.refresh();
const refreshTimer = setInterval(() => {
  metadata.refresh().catch((error) => console.error('Refresh failed:', error.message));
}, refreshEveryMs);

try {
  for (let iteration = 0; iteration < 6; iteration += 1) {
    try {
      const [book, mark] = await Promise.all([
        client.getSymbolOrderBookTicker({ symbol }),
        client.getMarkPrice({ symbol }),
      ]);
      const instrument = metadata.get(symbol);
      const order = prepareBuy(instrument, {
        budgetUsdt: '600', limitPrice: book.bidPrice, markPrice: mark.markPrice,
        clientOrderId: randomUUID(),
      });
      await execution.submitNewOrder(order);
    } catch (error) {
      console.error('Candidate skipped:', error.message);
    }
    if (iteration < 5) await sleep(5_000);
  }
} finally {
  clearInterval(refreshTimer);
}
```

The run lasts roughly 25 seconds plus request time. `DEMO_FAST_REFRESH=1` makes the refresh visible during that run; without it, the interval is ten minutes. Those settings demonstrate scheduling and need to fit the application's request budget. A group of workers can share a metadata service instead of each polling the full instrument list.

Each `DRY RUN` line is a prepared request, not an accepted exchange order or a simulated fill. The mock only prints it. The bid and mark-price requests are separate observations, so a price-band rejection can still occur as the market moves. A bid-priced GTC order also isn't guaranteed to remain passive.

A live bot usually prepares orders from its market-data feed and submits only when its strategy produces a new signal. Before replacing the mock with an authenticated sender, the dispatch path needs current account state, order-capacity reservations, stable client IDs, and halt handling. A metadata refresh does not submit orders or authorize retrying old candidates.

## Handling Changed Filters {#filter-refresh-and-rejections}

Suppose the bot cached a quantity step of `0.001`, then Binance changed it to `0.01`. A prepared quantity of `0.007` would no longer fit. A scheduled refresh shortens the interval during which the bot uses the old rule, but can't eliminate the race.

A definitive filter rejection is a reason to pause new candidates for that symbol and compare the submitted values with refreshed metadata. If a refresh was already in progress before the rejection, let it finish and request a subsequent snapshot. That avoids treating the completion of an older request as evidence of a post-rejection refresh. Rejection-triggered refreshes also need a rate limit, even when they succeed. Sharing concurrent requests alone won't stop a sequence of invalid orders from repeatedly fetching the full instrument list.

Binance's [USD-M error codes](https://developers.binance.com/en/docs/products/derivatives-trading-usds-futures/error-code) identify several useful cases:

| Code | Check before preparing another candidate |
| --- | --- |
| `-4014` | Submitted price against the current tick. |
| `-4023` | Submitted quantity against the current step. |
| `-4164` | Final order value against minimum notional. |
| `-4016` | Buy price against the current mark-price ceiling. |

A filter error doesn't prove that the cache changed. The bug may be in rounding, units, or the choice of market. Refreshing the same rules repeatedly won't fix those mistakes. Re-evaluate the candidate after diagnosis; don't keep resending the rejected request.

A timeout has a different outcome. The order may already exist, so refreshing metadata doesn't make resubmission safe. Resolve the original client order ID through the [unknown-order workflow](/blog/crypto-websocket-reliability-account-state#unknown-orders) before deciding what to send next.

## Market Orders and Spot Filters {#market-orders-and-spot}

A USD-M market order needs its applicable `MARKET_LOT_SIZE` rules. It has no submitted limit price, and minimum-notional evaluation uses mark price. A quantity estimated from a price snapshot doesn't fix the eventual fill value. Changing `type` to `MARKET` in the example while retaining its limit-order validation would leave those differences unhandled.

Binance Spot also has an `exchangeInfo` endpoint, but its [filter definitions](https://developers.binance.com/en/docs/products/spot/filters) differ. Spot can use `NOTIONAL` with minimum and maximum values, `PERCENT_PRICE_BY_SIDE`, and market-order applicability flags. Its `MIN_NOTIONAL` field is `minNotional`; the USD-M example reads `notional`. Keep the product and environment with the cache rather than sharing a symbol-only lookup across Spot, Futures, and demo endpoints.

For a quick verification, run the same preparation function with an increment such as `0.05`, a quantity that rounds below minimum notional, and a failed refresh followed by cache expiry. A concurrent-refresh check should observe one metadata request. These cases exercise the parts that ordinary decimal-place rounding and successful API calls can hide.

## Related articles

- [Binance User Data Stream 410 Gone: Fix Spot listenKey in JavaScript](/blog/binance-user-data-stream-410-gone-listenkey-javascript)
- [Binance, Bybit, and Coinbase Connector Boundaries](/blog/binance-bybit-coinbase-api-integration-patterns)
- [Diagnosing Binance Signature Error -1022 in JavaScript](/blog/troubleshooting-binance-api-error-1022-in-javascript-diagnose-invalid-signatures)


## Related Siebly Resources

- [Pre-Trade Checks and Kill Switches](/blog/safety-boundaries-for-nodejs-trading-systems)
- [Binance JavaScript SDK](/sdk/binance/javascript)
- [Exchange API Rate Limits](/blog/handling-crypto-exchange-api-rate-limits-in-javascript-and-nodejs)
- [WebSocket Reconnects and Backfill](/blog/crypto-websocket-reliability-account-state)
