Skip to content

Model venue volume-tiered maker / taker fees

A flat fee rate buries a real PnL driver. For a market-maker running $10M+ daily volume, the difference between VIP-0 and VIP-3 is the entire margin. Strategies that look profitable at VIP-0 may be net-negative once the rebate ladder kicks in, and vice-versa.

FeeSchedule ships a volume-tiered ladder that mirrors how real venues bill: add tiers with (min 30-day notional, maker bps, taker bps), record every fill, and the schedule resolves the active tier from the 30-day rolling sum on each lookup.

Configure

"""Track volume tiers and compute realistic fees per fill."""
import flox_py as flox

# Canned profile: Binance UM futures 10-tier ladder.
sched = flox.FeeSchedule.binance_um_futures()

# Or build manually:
custom = flox.FeeSchedule()
custom.add_tier(min_notional_30d=0,         maker_bps=2.0, taker_bps=4.0)
custom.add_tier(min_notional_30d=1_000_000, maker_bps=1.0, taker_bps=3.0)
custom.add_tier(min_notional_30d=10_000_000, maker_bps=-0.5, taker_bps=2.0)

ts = 1_000_000_000

# A taker fill of 1000 USDT at the Regular tier costs 0.4 USDT.
fee = sched.fee_for(ts_ns=ts, notional=1000.0, is_maker=False)
print(f"taker fee @ tier 0: {fee:.4f} USDT")

# Push 1M notional through and re-query — tier advances, fee drops.
sched.record_fill(ts, 1_200_000.0)
fee2 = sched.fee_for(ts_ns=ts, notional=1000.0, is_maker=False)
print(f"after 1.2M volume: tier={sched.current_tier_index()}, fee={fee2:.4f}")

# Maker rebate kicks in at VIP-9.
sched.record_fill(ts, 1_000_000_000.0)
print(f"current tier={sched.current_tier_index()}, "
      f"rolling 30d notional={sched.rolling_notional_30d():,.0f}")

# Maker fill at top tier — negative number means received rebate.
maker_fee = sched.fee_for(ts_ns=ts, notional=10_000.0, is_maker=True)
print(f"maker rebate @ top tier: {maker_fee:+.4f} USDT")
const flox = require('@flox-foundation/flox');
const sched = new flox.FeeSchedule();
sched.loadProfile('binance_um_futures');
sched.recordFill(tsNs, notional);
const fee = sched.feeFor(tsNs, notional, /*isMaker=*/false);
from flox.fee_schedule import FeeSchedule

s = FeeSchedule()
s.load_profile("binance_um_futures")
s.record_fill(ts_ns, notional)
fee = s.fee_for(ts_ns, notional, is_maker=False)
const h = __flox_fee_schedule_create();
__flox_fee_schedule_load_profile(h, "binance_um_futures");
__flox_fee_schedule_record_fill(h, tsNs, notional);
const fee = __flox_fee_schedule_fee_for(h, tsNs, notional, 0);
auto s = flox::FeeSchedule::binance_um_futures();
s.recordFill(tsNs, notional);
double fee = s.feeFor(tsNs, notional, /*isMaker=*/false);

Canned profiles

Profile Tiers Top maker rate
binance_um_futures 10 -0.005% rebate
bybit_linear 6 -0.005% rebate
okx_swap 4 0%
deribit 2 -0.010% rebate above LV-1

Numbers reflect published VIP brackets at the time of writing. Tune for your actual tier; venue schedules drift.

Aggregating volume across symbols

record_fill accumulates into the schedule's own 30-day window. When the schedule is bound to an Account, record_fill pushes into the account instead and tier resolution reads the account's aggregate counter, so volume across every symbol advances the same tier:

sched.bind_account(account)      # tier now tracks account.rolling_notional_30d()
sched.clear_account_binding()    # back to the schedule's own window

The VenueStack factories bind the account for you. current_bps(now_ns) returns the active (maker_bps, taker_bps) pair without computing a fee.

Fee sign convention

Returned fee is positive when the account pays, negative when the account receives (maker rebate). Integrate as:

equity -= sched.fee_for(ts_ns, notional, is_maker)

So a maker rebate (negative fee) adds to equity.

Tier transition log

tier_transition_ts_ns() returns the timestamps of every tier change recorded by record_fill. Useful for post-trade attribution ("which fills happened at each tier"):

transitions = sched.tier_transition_ts_ns()
print(f"changed tier {len(transitions)} times during backtest")

Notes

  • The rolling window is exactly 30 days (30 * 24 * 3600 * 1e9 ns). Notional ages out on the next call after the cutoff.
  • Tiers are stored sorted ascending by min_notional_30d. Insert order does not matter; the schedule sorts on every add_tier.
  • The active tier is resolved on every fee_for / current_tier_index call (cheap — linear walk of typically < 10 tiers).
  • reset_rolling() clears the 30-day window and the transition log (keeps the tier definitions).
  • For research that wants to disable fees entirely, instantiate an empty FeeSchedule()fee_for returns 0 for every fill.