Dynamic Fee Engine
Recompute each account's maker/taker fee daily from its trailing-30-day volume and write it back to trading_accounts — so the rate propagates through the existing replication path with zero changes to trade-engine2. A single volume-tier ladder, evaluated in recon-engine, with hysteresis to stop boundary flapping and an auto/manual flag so negotiated desks are never clobbered.
#Principles
trading_accountsstays the materialized rate — trade-engine2 is untouched#The engine writes the resolved rate into the existingtrading_accounts.maker_fee/taker_feecolumns; logical replication →accounts_monitor→trade-engine2and thetrade_fees_squarerecon invariant all keep working verbatim. The schedule/tier tables capture the derivation; the account columns hold the materialized result. The derivation tables are a rebuildable cache — the hot fill path never depends on them. This is the load-bearing property that keeps blast radius small and every later migration a move, not a redesign.- Trailing-30d rolling, recomputed daily, with hysteresis in v1 (Q-window)#The window is trailing-30 days (
[now-30d, now]), recomputed on a daily cadence — the prevailing exchange standard (Hyperliquid 14d; Coinbase/Binance/Kraken 30d) and it lets an account "catch up" as it trades. A rolling window flaps at tier boundaries as old volume rolls off, so hysteresis is mandatory v1 scope (not deferred): promote immediately, demote only after N consecutive days below the lower band, rate-limited and audited. Kept idempotent by expressing hysteresis as a pure function of warehouse history (best tier over the last K daily windows), so a crashed or duplicated daily tick still converges with no resume token. - Flat volume-ladder — likely all we ever ship; composition only if a real need appears (Q-comp = no)#v1 is a single volume-tier ladder — no
fee_components/ composition tables — and that may well be the end state. Composition earns its keep only if we actually need additional program types (polynomial, multiplier, …), which we may never. So we do not build it and do not treat it as a planned phase. We only leave two near-free seams so that if the need ever arises the change is additive rather than a rewrite: (1) all rate resolution goes through oneFeeProgram::effective_rate(volume) -> FeeRatesthat the engine loop alone calls, and (2) the policy type is a single-variant tagged enumFeeProgramKind::VolumeLadder { tiers }mirroring the DBprogram_kinddiscriminator. These are cheap insurance, not a roadmap; the "additive" combinator is deliberately left undesigned until a second component type is real. - auto/manual cutover, not a fixed-program bypass (Q-manual = keep the flag)#
trading_accounts.fee_mode ('manual' | 'auto'), defaultmanual. The engine selectsWHERE fee_mode='auto'; manual accounts are never in the loop, so negotiated off-ladder desks are pinned. Migration is a no-op (everyone starts manual = today's behavior) and rollout flips cohorts toauto. Representing manual as a 0-degree-polynomial program was rejected for v1: with no general evaluator it would force one into existence just to model constants, and it turns the no-op migration into a mint-a-program- per-desk backfill. - Manual changes are audited too — one recon covers every account#The flag's one real weakness is that the automated invariant can't police the accounts it exempts — precisely the high-value negotiated desks. We close that here: route the existing
update_feesadmin write (admin_routes.rs:381) throughfee_rate_changes(reason='manual'), so the self-recon invariant ("rate == derived/assigned rate") holds for all accounts. This buys the universal invariant without building an evaluator, and makes a later "manual = constant program" swap a representation change behind an already-uniform audit surface. - One uniform ladder (Q-ladder = yes)#A single active schedule tiers every
autoaccount — no per-desk ladders. Publishing different fee programs for different people affects the perception of fairness and invites capriciousness in administering it (Andrew, A-4094). The schema still models per-account assignment (fee_schedule_id) so this is a policy choice, not a structural limit, but v1 ships one ladder. - Policy is the source of truth — git is law, the DB is a cache#
rs/admin-cli/examples/fee_program.yaml, typed bysdk-internal/src/fee_program.rsand validated in CI, is the authored source of truth. The artifact moved underadmin-cli/examplesso the materializer owns the example it publishes. A loader materializes it intofee_schedules+ tiers (what the engine reads at runtime) and a doc-gen step renders it into the customer rulebook page — one artifact, two consumers, zero drift. Retirespy/nate's privatefee_program.yamlcopy.
#TODO 0 / 9 done
-
0
Data model + no-op migration#
db/postgres/1.sql(declarative, Atlas): addfee_schedules,fee_schedule_tiers(versioned/immutable-by-version so historical rates reconstruct for billing disputes), andfee_rate_changes(audit, withreasoncoveringtier_promotion/tier_demotion/schedule_edit/manual). Addtrading_accounts.fee_schedule_id CHAR(16)andfee_mode TEXT NOT NULL DEFAULT 'manual'. Everything defaults tomanual→ zero behavior change on deploy. No persistent hysteresis table — hysteresis is recomputed from the warehouse (phase 3). Trivial rollback. -
1
Policy artifact + typed schema + loader#
rs/admin-cli/examples/fee_program.yamlandrs/sdk-internal/src/fee_program.rs:FeeProgram { window_kind, kind: FeeProgramKind }whereFeeProgramKindis a serde-tagged enum with the singleVolumeLadder { tiers }variant today (a cheap seam for later program types, if ever needed), andwindow_kinddefaults totrailing_30d.FeeProgram::validate()ensure!s tiers strictly monotonic inmin_notional_usd, no gaps/overlaps, rebate floor respected. Export a JSON Schema viaschemarsintoschemas/. A loader (alongsidejust migrate-db) materializes the active policy into thefee_schedulesrow + tiers, replacing thepy/natecopy. -
2
Engine task in recon-engine (
fees.rs), daily, with dry-run#refresh_fee_rates_task, modeled onrefresh_leaderboard_task: a daily tick →resolve_window(trailing_30d)=[now-30d, now]→ChTradeRow::query_account_volumes→ resolve eachautoaccount to a tier through the singleeffective_rate(volume)seam → on a diff,DbTradingAccount::update_fees+ insertfee_rate_changes. Extend theLeaderboardCadencehelper withtrailing_30d. Failure-isolated per schedule ({e:?}, skip). Dry-run mode: compute + writefee_rate_changesbut do not callupdate_fees— the realized-vs-modeled diff NATE already visualizes. -
3
Hysteresis — anti-flap demotion policy (v1 scope, from Q-window)#
A rolling daily window oscillates at tier boundaries as old volume rolls off, so the raw
effective_ratefrom phase 2 is wrapped in a hysteresis rule inside the same seam: promote immediately; demote only after N consecutive days below the lower band (and/or "best tier over the last K daily windows"). Expressed as a pure function of warehouse history — recompute the trailing daily tiers from ClickHouse each tick — so no persisted counter and no resume token; a duplicated/crashed daily tick still converges. Demotions are rate-limited and always audited (reason='tier_demotion'); the "never silently raise a rate without a demotion record" rule is enforced here. -
4
Manual write path through the audit seam#
Route the existing
update_feesadmin endpoint throughfee_rate_changes(reason='manual'), so a negotiated-rate change lands an audit row exactly like an engine-driven tier change. Single audit provenance; the "why did my fee change?" query is uniform across auto and manual. Prerequisite for the universal invariant in phase 5. -
5
Self-recon invariant (recon-engine polices its own engine)#
Add a recon check: for every
autoaccount,(maker_fee, taker_fee)equals its schedule's hysteresis-adjustedeffective_rateover the trailing window; and the manual-safety check that no engine-written (reason != 'manual')fee_rate_changesrow exists for an account that wasmanualat change time. With phase 4 in place the reconciliation covers negotiated desks too — no exempt accounts. The service that hosts the engine hosts the check that polices it. -
6
Admin CRUD in api-gateway#
Human-facing control surface (request/response only, no periodic compute): define/edit the ladder schedule + tiers, assign an account to the schedule, and flip
fee_mode. Writes the same Postgres tables the recon-engine task reads. The evaluator stays in recon-engine; the gateway is the control surface. -
7
Rulebook doc-gen#
just policy/rulebook(or a tiny bin) rendersrs/admin-cli/examples/fee_program.yamlto the customer rulebook page via the existing docs pipeline — the rulebook is generated from the policy, never maintained beside it. Git history is the audit log of every fee change. -
8
Rollout#
Deploy phases 0–7 dark (all accounts
manual). Run the engine in dry-run, reconcile the daily intended-vs-actual diff against NATE. Flip a small cohort tofee_mode='auto', watchfee_rate_changes+ the self-recon invariant, then widen. Per CLAUDE.md, exercise the lifecycle matrix (recon-engine restart/crash mid-tick, ClickHouse-lag delay, auto↔manual flip races, and a demotion straddling a restart) before widening.awaiting sign-off dry-run daily diff reconciled against NATE realized-vs-modeled; no unexplained rate deltas across a full 30d windowawaiting sign-off lifecycle matrix green — recon-engine restart/crash mid-tick, ClickHouse-lag, auto↔manual flip during an in-flight tick, demotion across a restart⏲ first auto cohort soaks one week of daily recomputes with the self-recon invariant clean
#Design Questions
-
Model fee programs as composable typed components (
ladder/polynomial/multiplier, applied additively) rather than a flat schedule/tiers pair?#NoNo — flat single volume-tier ladder, quite possibly for good. Kept cheap-to-revisit via two seams (a singleeffective_rate(volume)point and a single-variant taggedFeeProgramKind), but composition is built only if a concrete second program type is ever needed, which may never happen. The combinator contract is deliberately not pre-committed (scratchpad s-comp). -
Represent a negotiated/manual rate as a fixed (0-degree-polynomial) program instead of an
auto/manualengine bypass?#NoNo for v1 — keepfee_mode ('manual'|'auto'). A constant program needs a general evaluator that the flat model otherwise wouldn't have, and it turns a no-op migration into a per-desk backfill. The flag's audit/recon blind spot is closed instead by routing manual writes throughfee_rate_changes(phase 4), keeping the swap cheap if Q-comp flips (scratchpad s-manual). -
One uniform ladder for everyone, or per-account/per-region ladders?#YesOne uniform ladder for v1 (Andrew, A-4094) — different ladders raise fairness/perception concerns. The schema still models per-account assignment, so this is policy, not structure. Multi-ladder (e.g. per region) is out of scope.
-
Window basis — calendar-month-prior-period or trailing-30d for v1?#YesTrailing-30 days, recomputed daily (A-4094) — industry standard, lets accounts catch up. Consequence: hysteresis is now v1 scope (phase 3) to stop boundary flapping, kept idempotent via warehouse recompute. Calendar-month retained as a future
window_kind(scratchpad s-window). -
Tiering volume pool — over which accounts is an account's trailing volume summed? Two coupled facets: (a) per
trading_accounts.idvs pooled across an owner's accounts (ubo_user_id); (b) domanual/bypassed accounts' volume count toward the pool that tiers theirautosiblings?#OpenOpen, and resolved together — both facets are the same choice of what account set feedsquery_account_volumesfor a given account's tier. (a) leans on the existing UBO model; desks with multiple accounts will care. (b) interacts with (a): if pooling is per-owner, amanualsibling's volume lands in the same pool by construction, so you can't settle one without the other. Decide as one policy before the firstautocohort. -
Volume basis — combined maker+taker notional, or separate maker/taker thresholds?#OpenLeaning combined (what
query_account_volumesreturns and whatpy/nate/config/fee_program.yamlkeys on). Confirm before authoring the tier ladder. -
Per-account fee floor so a manual sweetener can coexist with
auto?#NoDeferred to v2 —manualvsautois binary for v1. Revisit with a per-accountmaker_fee_floor/taker_fee_floorif a sweetener-under-auto case appears. -
Cadence — recompute interval and hour-of-day (UTC)?#YesRecompute daily (A-4094). Remaining detail: the exact hour-of-day (UTC) for the daily tick, following
calendar_math.rsconventions; a duplicated tick is safe because ticks are idempotent.
#Scratchpad
Long-form reference and working notes — mostly write-only, for LLM/agent use. Collapsed by default.
›Scratchpad — reference design & raw notes
#Reuse map — what the engine leans on (little new infra)
The build is mostly wiring existing primitives; the genuinely new code is the tier-resolution + hysteresis seam, the policy types, the audit table, and the CRUD.
- Loop scaffolding:
refresh_leaderboard_task/refresh_leaderboard_for_period(rs/recon-engine/src/leaderboard.rs, ~150 lines) — tick + boundary crossing + window + upsert. The fee task is the same loop with a different sink; scaffolding is zero-marginal. - Volume:
ChTradeRow::query_account_volumes(conn, accounts, start_ns, end_ns, limit, multipliers)(rs/sdk-internal/clickhouse/src/schema.rs) — the trailing-30d window uses its(start_ns, end_ns)signature directly; hysteresis reuses it once per trailing daily window. - Windowing:
LeaderboardCadence::{period_start,next_period_start,crossed_boundary}(rs/sdk/src/protocol/api_gateway.rs) +calendar_math.rs. Extend the cadence enum withtrailing_30d; the daily tick reuses the interval scaffolding. - Write-back:
DbTradingAccount::update_fees(entities.rs), already wired fromadmin_routes.rs:381; propagates via replication with no trade-engine2 change. - Typed-JSON precedent (for the deferred composition model, not v1):
PgJson<FundingRateSchedule>/PgJson<OrnnInstrumentConfig>and the#[try_from(serde_json::Value)]pattern inentities.rs, plusJSONBcolumns indb/postgres/1.sql. Storingfee_components.attributesas typed JSON is a paved road here — which is why deferring composition is cheap.
#Why flat, and what would make composition cheap if it's ever needed (Q-comp)
The migration flat → composition is expensive only in rework, not in
risk: the authoritative rate is materialized in trading_accounts and the
derivation tables are rebuildable from policy.yaml, so a reshape never
touches the fill path, replication, or the recon invariant — you cut over
at a period boundary, dry-run-gated. The seams above (effective_rate()
funnel + single-variant tagged type) turn the later change into "add a
FeeProgramKind variant + a combinator + rehome tiers into a JSON
attributes column," designed against two real component types.
Cost sits almost entirely in one place: defining the additive combinator contract ("a program is a sum of components each emitting a maker/taker contribution"). That contract can't be validated with a single component, so building composition in v1 pays framework cost and correctness risk for an unexercised capability. Decision: build flat, keep the seams as cheap insurance, and reach for composition only if a concrete second component type (e.g. polynomial) ever materializes — quite possibly never. The seams cost ~nothing if we never cash them in.
Anti-pattern to avoid: standing up the 3-table fee_programs /
fee_program_compositions / fee_components schema but only ever
inserting single-component programs — that pays composition's cost with
none of its benefit and still freezes the combinator question.
#auto/manual flag vs. 0-degree-polynomial program (Q-manual)
The choice is conditional on Q-comp. A "0-degree polynomial program"
presupposes an evaluator ("a program consumes volume, emits a
(maker, taker) pair"). Since v1 is flat with no general evaluator,
representing manual as a constant program would force that evaluator into
existence purely to model overrides — strictly more than a WHERE clause.
So v1 keeps the flag.
Per-dimension: the flag wins on migration (no-op vs. mint-a-program- per-desk backfill of today's per-user rates), schema (2 columns vs. a link table + proliferation), and reuse (the manual write path already exists). The constant-program model wins on uniform audit provenance and a universal self-recon invariant — and the flag's exempt-accounts blind spot is real. We take the flag plus phase 4's audit-seam hybrid, which closes the blind spot without the evaluator, and leaves "manual = constant program" as a cheap swap if Q-comp later flips.
#Window decision — trailing-30d, daily, + hysteresis (Q-window, decided)
Decided (A-4094): trailing-30 days, recomputed daily. The RFC's original
recommendation was calendar-month-prior-period (fixed and knowable in
advance, no flapping, no hysteresis), but review chose rolling — Andrew:
"30d rolling seems the standard and lets people 'catch up' if they fall
behind"; Tin: most venues use 14d (Hyperliquid) / 30d rolling (Coinbase,
Binance, Kraken). Calendar-month is retained as a second window_kind for
later, not v1.
The consequence the RFC flagged is now real work: a rolling window flaps at tier boundaries as old volume rolls off, so hysteresis is v1 scope (phase 3) — promote immediately, demote after N consecutive days below the lower band, rate-limited and audited. Keep it a pure function of warehouse history (recompute the trailing daily tiers from ClickHouse each tick) rather than a persisted consecutive-days counter, so the daily tick stays idempotent and needs no resume token — consistent with the RFC's Lifecycle & correctness stance. Open sub-question: exact hour-of-day (UTC) for the daily recompute (Q-cadence).