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 lrvx

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

# Or build manually:
custom = lrvx.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 lrvx = require('@lrvx/lrvx');
const sched = new lrvx.FeeSchedule();
sched.loadProfile('binance_um_futures');
sched.recordFill(tsNs, notional);
const fee = sched.feeFor(tsNs, notional, /*isMaker=*/false);
from lrvx.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 = __lrvx_fee_schedule_create();
__lrvx_fee_schedule_load_profile(h, "binance_um_futures");
__lrvx_fee_schedule_record_fill(h, tsNs, notional);
const fee = __lrvx_fee_schedule_fee_for(h, tsNs, notional, 0);
auto s = lrvx::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")

Fees a backtest actually charges

A run driven by a VenueStack pays that stack's ladder. The stack hands its FeeSchedule to the executor, and BacktestRunner.result() forwards it to the result, which bills every fill at the tier the account's 30-day notional resolves to -- Fill.is_maker picks the side. On the Binance UM ladder a VIP-8 account pays 2.1 bps taker where the Regular tier pays 4.0, so a 200,000.00 round trip costs 42.00 instead of 80.00.

The result replays the run's fills into its own copy of the ladder, so the tier climbs as the run trades while the stack's live 30-day counter is left alone; calling result() twice prices the run the same way both times.

Precedence, highest first:

Setting Wins when
use_percentage_fee = False Always -- a flat fixed_fee_per_trade replaces the fee model outright
The venue's FeeSchedule A VenueStack (or any executor with set_fee_schedule) drives the run
maker_fee_rate / taker_fee_rate No ladder is attached
fee_rate No ladder and no per-side override

A run with no venue attached is unchanged: it charges the flat BacktestConfig rate.

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.