Skip to content

Model venue rate limits in the simulator

Real exchanges throttle: weighted per-endpoint quotas, per-account order-action caps, burst bans after sustained 429s. Without modelling this, a market-maker backtest that puts up 800 orders/day looks identical to one that puts up 8000 — even though the real account would have spent the second half of the day rate-limited.

RateLimitPolicy adds a sliding-window quota model to SimulatedExecutor. Each submit / cancel / replace consults the policy first; an overflow emits REJECTED_RATE_LIMIT and the action is not committed to the simulator.

What you configure

  • One or more buckets, each with a window length, a capacity, and per-action weights. The standard pattern is two buckets — a short burst window (10s) and a sustained one (60s).
  • An optional ban rule: after N consecutive rejects, ban every action for a fixed duration. Models the 3-minute IP ban that some venues apply after sustained 429s.

Canned profiles

Profile Buckets Ban
binance_um_futures 50 / 10s, 300 / 60s 3 rejects → 3m
bybit_linear 10 / 1s, 100 / 60s 5 rejects → 1m
okx_swap 60 / 2s 3 rejects → 2m
deribit 5 / 0.5s, 60 / 60s 3 rejects → 1m

Numbers approximate published rules; tune to your account tier.

Apply from a strategy

"""Attach a venue rate-limit policy to the simulated executor."""
import flox_py as flox

exec = flox.SimulatedExecutor()

# Use a canned profile.
policy = flox.RateLimitPolicy.binance_um_futures()
exec.set_rate_limit_policy(policy)

# Or build one manually:
custom = flox.RateLimitPolicy()
custom.add_bucket(name="orders_10s", window_ns=10_000_000_000, capacity=50)
custom.add_bucket(name="orders_60s", window_ns=60_000_000_000, capacity=300)
custom.set_ban(after_consecutive_rejects=3, ban_duration_ns=180_000_000_000)
exec.set_rate_limit_policy(custom)

# Inspect remaining capacity per bucket.
now_ns = 5_000_000_000
for s in custom.bucket_states(now_ns):
    print(f"{s['name']}: used={s['used']} / {s['capacity']}")
const flox = require('flox-node');
const policy = new flox.RateLimitPolicy();
policy.loadProfile('binance_um_futures');
exec.setRateLimitPolicy(policy);
from flox.rate_limit import RateLimitPolicy

p = RateLimitPolicy()
p.load_profile("binance_um_futures")
exec.set_rate_limit_policy(p)
const p = __flox_rate_limit_policy_create();
__flox_rate_limit_policy_load_profile(p, "binance_um_futures");
__flox_simulated_executor_set_rate_limit_policy(exec, p);
#include "flox/backtest/rate_limit_policy.h"
auto p = flox::RateLimitPolicy::binance_um_futures();
sim.setRateLimitPolicy(p);

Reading remaining capacity

bucket_states(now_ns) returns per-bucket usage. Use this to implement back-off in the strategy before the venue rejects you:

for s in policy.bucket_states(now_ns):
    headroom = s["capacity"] - s["used"]
    if headroom < 5:
        # back off — only a few slots left in this window
        pass

Per-endpoint families

Real venues split quotas across families — trading endpoints, market-data feeds, and account-state queries don't share the same bucket. A strategy that polls position state aggressively can exhaust its account quota while the trading quota is barely touched.

RateLimitPolicy reflects that: each bucket belongs to a family (default Trading), and try_consume(action, now_ns) only charges buckets whose family matches the action. Canned profiles populate all three families with realistic numbers.

Action Family
submit / cancel / replace Trading
query_account Account
query_market_data MarketData
p.add_family_bucket(
    family=flox_py.RateLimitEndpointFamily.MarketData,
    name="md_60s",
    window_ns=60_000_000_000,
    capacity=6000,
)
p.try_consume("query_market_data", now_ns)
p.addFamilyBucket("market_data", "md_60s", 60_000_000_000, 6000);

Notes

  • Reject-check is atomic across buckets: if any single bucket would overflow, none of them get charged. Stays consistent if you have three buckets configured and only the third one rejects.
  • The replace action defaults to weight 2 across all canned profiles, matching most venues' published rules.
  • Once a ban fires, every subsequent action rejects (no matter the per-bucket headroom) until the ban window expires.
  • Set policy.set_ban(0, 0) to disable the ban mechanism.