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.

Epic: A-4094 RFC: docs/rfc/dynamic-fee-engine.md Owner: Joe (+ Tin) Needed by: August 2026 Status: Draft Status snapshot: 2026-07-22

#Principles

  1. trading_accounts stays the materialized rate — trade-engine2 is untouched#
    The engine writes the resolved rate into the existing trading_accounts.maker_fee/taker_fee columns; logical replication → accounts_monitortrade-engine2 and the trade_fees_square recon 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.
  2. 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.
  3. 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 one FeeProgram::effective_rate(volume) -> FeeRates that the engine loop alone calls, and (2) the policy type is a single-variant tagged enum FeeProgramKind::VolumeLadder { tiers } mirroring the DB program_kind discriminator. These are cheap insurance, not a roadmap; the "additive" combinator is deliberately left undesigned until a second component type is real.
  4. auto/manual cutover, not a fixed-program bypass (Q-manual = keep the flag)#
    trading_accounts.fee_mode ('manual' | 'auto'), default manual. The engine selects WHERE 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 to auto. 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.
  5. 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_fees admin write (admin_routes.rs:381) through fee_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.
  6. One uniform ladder (Q-ladder = yes)#
    A single active schedule tiers every auto account — 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.
  7. Policy is the source of truth — git is law, the DB is a cache#
    rs/admin-cli/examples/fee_program.yaml, typed by sdk-internal/src/fee_program.rs and validated in CI, is the authored source of truth. The artifact moved under admin-cli/examples so the materializer owns the example it publishes. A loader materializes it into fee_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. Retires py/nate's private fee_program.yaml copy.

#TODO 0 / 9 done

not started — no PR draft PR open PR / in progress done — merged

#Design Questions

  1. 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 single effective_rate(volume) point and a single-variant tagged FeeProgramKind), 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).
  2. Represent a negotiated/manual rate as a fixed (0-degree-polynomial) program instead of an auto/manual engine bypass?#
    NoNo for v1 — keep fee_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 through fee_rate_changes (phase 4), keeping the swap cheap if Q-comp flips (scratchpad s-manual).
  3. 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.
  4. 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).
  5. Tiering volume pool — over which accounts is an account's trailing volume summed? Two coupled facets: (a) per trading_accounts.id vs pooled across an owner's accounts (ubo_user_id); (b) do manual/bypassed accounts' volume count toward the pool that tiers their auto siblings?#
    OpenOpen, and resolved together — both facets are the same choice of what account set feeds query_account_volumes for 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, a manual sibling's volume lands in the same pool by construction, so you can't settle one without the other. Decide as one policy before the first auto cohort.
  6. Volume basis — combined maker+taker notional, or separate maker/taker thresholds?#
    OpenLeaning combined (what query_account_volumes returns and what py/nate/config/fee_program.yaml keys on). Confirm before authoring the tier ladder.
  7. Per-account fee floor so a manual sweetener can coexist with auto?#
    NoDeferred to v2 — manual vs auto is binary for v1. Revisit with a per-account maker_fee_floor/taker_fee_floor if a sweetener-under-auto case appears.
  8. 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.rs conventions; 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 with trailing_30d; the daily tick reuses the interval scaffolding.
  • Write-back: DbTradingAccount::update_fees (entities.rs), already wired from admin_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 in entities.rs, plus JSONB columns in db/postgres/1.sql. Storing fee_components.attributes as 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).

Link copied