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")
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:
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 * 1e9ns). 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 everyadd_tier. - The active tier is resolved on every
fee_for/current_tier_indexcall (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_forreturns 0 for every fill.