Turn a desired crypto order into the best one that passes every exchange filter, in exact decimals.
Exchanges reject orders that break their price tick, quantity step, min/max quantity, min/max notional or
percent-of-price band. Most trading bots normalise orders with Math.floor(qty / step) * step in floating
point, one filter at a time. That produces quantities like 0.6000000000000001 that are off the step,
turns 0.3 / 0.1 into 2.9999999999999996 and gives up a whole step, rounds a buy price up past the
user's limit, or bumps the quantity up to reach min notional and spends more than the budget. The filters
interact, so fixing them one at a time does not work either: the right answer is the best point on a
constrained integer lattice, and sometimes no such point exists at all.
Exact integers only. Every filter value and every field of the intent is parsed from its string form
("0.00000100", "1e-8", "53224700e-4") into a BigInt on a shared decimal exponent: prices in units of
10^-sp, quantities in 10^-sq, notionals in 10^-(sp+sq) so that price × quantity needs no rescaling. There is
no floating point anywhere in the solver; npm run lint enforces that on the solver's modules.
The lattice. A limit order is a pair (i, j): price = i·tick, quantity = j·step. The constraints are
minPrice ≤ i·tick ≤ maxPrice percent band: ref·down ≤ i·tick ≤ ref·up
minQty ≤ j·step ≤ maxQty BUY: i·tick ≤ limit SELL: i·tick ≥ limit
minNotional ≤ i·tick·j·step ≤ min(maxNotional, quoteBudget)
The price constraints give an interval of i with exact ceiling/floor division. For a fixed i, the quantity constraints give an interval of j:
jmin(i) = max(a0, ceil(Kn / i)) a0 = ceil(minQty / step), Kn = ceil(minNotional / (tick·step))
jmax(i) = min(b0, floor(Kx / i)) b0 = floor(maxQty / step), Kx = floor(min(maxNotional, budget) / (tick·step))
Choosing the order. The solver starts at the price closest to the limit on the safe side (a BUY rounds down, a SELL rounds up, both clipped by min/max price and the band). If jmin ≤ jmax there, the policy picks j:
| policy | chooses |
|---|---|
max-quantity (default) |
the largest j, with quantity acting as a cap |
closest-quantity |
the j·step nearest quantity (ties go to the smaller), still inside every hard bound |
min-leftover |
the largest spend under quoteBudget; quantity is ignored |
If no j fits, the notional constraints force the price to move away from the limit. That walk is not a
tick-by-tick search. ceil(Kn / i) is constant on runs of consecutive i (the same block decomposition used for
divisor sums), there are at most about 2√Kn runs, and inside one run the feasible i form a prefix ending at
floor(Kx / c). So each run is decided in O(1) and the walk jumps run to run. Before walking, three closed
forms cut the range: no i works if Kx < Kn; every i ≤ floor((Kn−1)/b0) needs more than maxQty; and from
i ≥ ceil(Kn/a0) the minimum quantity is the constant a0, so that whole tail is one interval.
Worked example (examples/budget-walk.json): tick 0.01, step 1, minNotional 10, BUY limit 3.00, budget 10.
At 3.00 the smallest valid quantity is 4 (notional 12) but the budget buys 3 (notional 9). Here Kn = 1000 and
Kx = 1000, and the walk starts at i = 300. That index lies in the run [250, 333], where ceil(1000/i) = 4.
The feasible prefix of that run ends at floor(1000/4) = 250, which is inside the run, so the walk stops after
one run at i = 250: price 2.50, quantity 4, notional exactly 10. It never tests the 49 ticks in between.
Market orders have one price, the reference price, so only the j interval is computed. minNotional and
maxNotional apply at the reference price unless applyMinToMarket / applyMaxToMarket is false.
Certificates. When no order exists, snap returns which filter binds and, where one exists, the fix:
minBudget: the cheapest valid order over the allowed price range, found by the same run decomposition (the cost i·jmin(i) is smallest at the start of each run). It is tight:minBudgetworks andminBudgetminus one notional unit does not.limitPrice: the nearest limit that admits an order (lowest for a BUY, highest for a SELL). Also tight: one price unit short of it fails.
Requires Node.js 22 or later. No runtime dependencies.
From a clone of this repository:
npm ci
npm test # build, then run the full test suite
npm run bench # the accuracy and throughput benchmark belowimport { snap, fromBinance } from "lotsnap";
const result = snap(
{ side: "BUY", type: "LIMIT", price: "3.00", quoteBudget: "10" },
{ tickSize: "0.01", stepSize: "1", minNotional: "10" },
"max-quantity",
);
// { ok: true,
// order: { side: "BUY", type: "LIMIT", price: "2.5", quantity: "4", notional: "10" },
// leftover: "0",
// binding: { price: "WALK", quantity: "BUDGET" },
// explanation: [...] }Filters are a flat object: tickSize, minPrice, maxPrice, stepSize, minQty, maxQty, minNotional,
maxNotional, applyMinToMarket, applyMaxToMarket, and percentPrice with bid/ask up/down multipliers.
Absent and zero values disable a bound. fromBinance(symbol.filters) converts a Binance exchangeInfo
filter array (PRICE_FILTER, LOT_SIZE, MIN_NOTIONAL, NOTIONAL, PERCENT_PRICE,
PERCENT_PRICE_BY_SIDE). Malformed input throws LotsnapError; an impossible order is a normal return value
with ok: false.
validateOrder(order, intent, filters) is the independent exact validator used by the tests. naiveSnap is
the float normaliser kept as a reference for how the usual approach fails; do not trade with it.
After npm ci && npm run build, run it as node dist/src/cli.js (or npx lotsnap once installed as a package).
$ node dist/src/cli.js check examples/budget-walk.json
ORDER BUY LIMIT price=2.5 quantity=4 notional=10
leftover 0
price [WALK] price 2.5 is below the best allowed price 3 because at every closer tick no step multiple satisfies the quantity, notional and budget bounds together
quantity [BUDGET] quantity 4 is the largest step multiple the quote budget pays for
$ node dist/src/cli.js check examples/budget-short.json # minNotional 10.37, budget 10; exit status 2
NO VALID ORDER
binding BUDGET
reason the quote budget is below the cheapest valid order at an allowed price
fix a quote budget of 10.37 is the smallest that works
$ node dist/src/cli.js check examples/binance-btcusdt.json # a Binance filter array
ORDER BUY LIMIT price=64250.12 quantity=0.00389 notional=249.9329668
leftover 0.0670332
price [TICK] price 64250.12 is the limit rounded down to the tick size
quantity [BUDGET] quantity 0.00389 is the largest step multiple the quote budget pays for
check reads {"filters": ..., "intent": ..., "policy"?: ...} from a file or - for stdin, and takes
--policy and --json. Exit status is 0 when an order exists, 2 when none does, 1 for bad input.
hunt draws seeded random filter sets until it finds ones where the naive normaliser produces an order an
exchange would reject or that overspends, and prints lotsnap's answer next to it:
$ node dist/src/cli.js hunt --seed 7 --count 2
--- case 1 (seed 7, draw 1) ---
filters {"tickSize":"0.00000005","stepSize":"1e-4","minQty":"0.0008","maxQty":"977947e-4","maxNotional":"1795717325880e-12","percentPrice":{"bidMultiplierUp":"1.2","bidMultiplierDown":"0.95","askMultiplierUp":"1.05","askMultiplierDown":"0.95"}}
intent {"side":"BUY","type":"LIMIT","referencePrice":"0.1383315","price":"0.16084285","quoteBudget":"1.209996330315"}
naive {"quantity":"7.5228","price":"0.16084284999999998"}
rejected: price 0.16084284999999998 is not a multiple of tickSize 0.00000005
lotsnap
ORDER BUY LIMIT price=0.16084285 quantity=7.5228 notional=1.20998859198
leftover 0.000007738335
price [LIMIT] price 0.16084285 is the limit price
quantity [BUDGET] quantity 7.5228 is the largest step multiple the quote budget pays for
--- case 2 (seed 7, draw 2) ---
filters {"tickSize":"0.002","stepSize":"10e-4","maxPrice":"5210.24","minQty":"0.014","percentPrice":{"bidMultiplierUp":"1.2","bidMultiplierDown":"0.8","askMultiplierUp":"5","askMultiplierDown":"0.95"}}
intent {"side":"SELL","type":"LIMIT","referencePrice":"3306.486","price":"5625.84","quantity":"328.057"}
naive {"quantity":"328.057","price":"5210.24"}
rejected: sell price below the limit price
lotsnap
NO VALID ORDER
binding MAX_PRICE
reason the limit price is above the highest allowed tick 5210.24 (MAX_PRICE)
fix a limit price of 5210.24 is the nearest that works
- Differential test against brute force. 4,000 seeded random filter sets and intents per
npm test(npm run stressruns 50,000) on lattices of up to a few hundred ticks and steps. The oracle intest/oracle.tsenumerates every (i, j) pair and checks each constraint by multiplication alone: no ceil/floor division, no runs. The solver must match its price, quantity, feasibility,minBudgetandlimitPriceexactly, for all three policies, BUY and SELL, LIMIT and MARKET. - Properties, each over 3,000 generated cases: every returned order passes
validateOrder; a BUY never pays above its limit and a SELL never sells below it; spend never exceeds budget; every budget and limit-price certificate is tight; raising the budget never makes an order infeasible, never moves the price away from the limit, and never lowers quantity at an unchanged price; loosening any single filter never makes a feasible intent infeasible. - Edge cases: tick
0.00000100against step1e-8; maxNotional pushing the effective max quantity below minQty; a percent band that excludes the limit on either side; contradictory price filters; absent filters and Binance's"0.00000000"disabled filters; quote-budget market orders valued at the reference price; min notional with more decimals than tick and step combined; malformed input. - Random cases write the same values in padded, trimmed and scientific notation, so parsing is under test too. The property tests use no shrinking library. Each property stops at its first failure and prints the full input, so a failure is reported at once instead of after an unbounded shrink.
npm run bench generates 100,000 seeded intent/filter pairs, runs the naive float normaliser and lotsnap on
each under max-quantity, and checks both outputs with the exact validator. "Gives up quantity" means the
naive order is valid but smaller than lotsnap's, or the naive code returned nothing when a valid order existed.
Realistic magnitudes (prices up to 5e6 ticks, quantities up to 1e6 steps):
| normaliser | orders returned | rejected by exact validator | overspends budget | gives up quantity |
|---|---|---|---|---|
| naive float | 99,111 | 52,715 (52.72%) | 6,817 (6.82%) | 5,014 (5.01%) |
| lotsnap | 78,196 | 0 (0.00%) | 0 (0.00%) | reference |
Small lattices (up to about 240 ticks and 120 steps):
| normaliser | orders returned | rejected by exact validator | overspends budget | gives up quantity |
|---|---|---|---|---|
| naive float | 99,042 | 52,190 (52.19%) | 7,931 (7.93%) | 4,782 (4.78%) |
| lotsnap | 75,526 | 0 (0.00%) | 0 (0.00%) | reference |
On the realistic set, the naive rejections break down as: 26,219 off the tick or step lattice (float noise or a lost step), 21,021 with the price on the wrong side of the limit, and 23,930 breaking another filter. One case can have several causes. lotsnap returns fewer orders because it returns none when none is valid; the naive code almost always returns something.
These are rates on a synthetic distribution built to exercise the filters: random filters, limits near and past the price band, budgets near min notional. They show that the failure modes are real and common in that space. They are not an estimate of how often a particular bot fails in production.
Throughput, all three policies, including parsing the decimal strings:
| case set | snaps/s | p50 | p99 | p99.9 | max |
|---|---|---|---|---|---|
| realistic magnitudes | 7,997 | 0.003 ms | 3.95 ms | 16.9 ms | 32.8 ms |
| small lattices | 319,329 | 0.003 ms | 0.005 ms | 0.010 ms | 0.60 ms |
Measured on an Apple M2 with Node.js v24.12.0 by npm run bench (100,000 cases, seed 42; takes about 90
seconds). node dist/bench/bench.js <cases> <seed> changes either.
The median is microseconds in both sets, but on realistic magnitudes the mean is dominated by a tail. Those slow cases are budget certificates where min notional has no exact factorisation as price × quantity near the limit, so the search visits O(√Kn) runs before it can prove the minimum.
The price stays as close to the limit as the filters and budget allow; the policy only chooses the quantity. The alternative, optimising quantity or spend over every (price, quantity) pair, would tell a user who asked to buy at 30,000 with 100 USDT to buy at 0.01, which is technically optimal and useless. Keeping price first also keeps the problem tractable: the solver needs the first feasible run, not a global optimum over all prices. The cost is one monotonicity property that does not hold. Raising the budget can lower the quantity, because a larger budget can make a price closer to the limit feasible, and that price buys fewer units. With tick 1, step 1, minNotional 101 and a BUY limit of 30, a budget of 101 can only be met at price 1 (quantity 101), while a budget of 120 is met at price 30 (quantity 4). The tests check the properties that do hold: the price never moves away from the limit, and the quantity never falls while the price is unchanged. Market orders have a single price, so for them more budget never means less quantity.
Certificates are defined so they can be tested. "The smallest budget that works" is the minimum of
i·jmin(i) over the allowed range, not the cost at the limit price. That makes it tight by construction
(minBudget minus one unit fails), and the property tests check it directly rather than trusting the
derivation. Computing the global minimum takes more work than evaluating one price, and the run decomposition
keeps that work to O(√Kn).
- Only price and quantity filters are modelled.
MAX_NUM_ORDERS,ICEBERG_PARTS,MAX_POSITION, trailing delta and similar filters are ignored byfromBinance. - The percent band uses the reference price you supply. lotsnap does not compute Binance's weighted average price, and a market order's real fill price will differ from its reference price, so a quote budget on a market order holds only at that price.
- Worst case, a walk or a budget certificate visits O(√Kn) runs, where Kn = minNotional / (tick × step). On realistic magnitudes that is fractions of a millisecond at the median but tens of milliseconds for the slowest cases (see Results). It is never a scan over every tick.
- Without a tick or step, prices or quantities fall back to 8 decimal places (or more, if the inputs have more).
quoteBudgetis accepted only on BUY orders. A sell-side "receive at least this much quote" target is not modelled.- Only numbers are returned. lotsnap does not place orders, fetch exchange info or talk to the network.
MIT. See LICENSE.