RFC: Dynamic Maker/Taker Fee Engine

Date: 2026-06-23 (decisions locked 2026-07-09) Status: Draft core decisions settled in review (A-4094); moving to plan draft Author: (you)

Review decisions (Slack A-4094, 2026-07-08/09). v1 is a single uniform volume ladder, tiered per trading_accounts.id over a trailing-30-day window recomputed daily (with hysteresis), authored as a flat schedule + tiers (no composition), with manual retained as an engine bypass. See Decisions below for the full record; the design text has been updated to match.

Background

Maker/taker fees today are static per trading account. The source of truth is two columns on the trading_accounts row:

From there the rate flows entirely through existing infrastructure:

  1. trade-engine2 subscribes to trading_accounts via logical replication (BTreeMapReplica<DbTradingAccount, 1>), and accounts_monitor.rs translates each row change into AccountFeesSnapshot / AccountFeesUpdate events (rs/trade-engine2/src/tasks/accounts_monitor.rs:9-40).
  2. The state machine keeps an in-memory account_fee_rates: BTreeMap<AccountId, FeeRates> and applies fee = rate × price × quantity at fill time (rs/trade-engine2/src/state_machine.rs:345-384, :482-484).
  3. recon-engine enforces the 4-leg, net-zero fee ledger invariant per trade (rs/recon-engine/src/checks/trade_fees_square.rs).

So changing a rate is already a single Postgres UPDATE that propagates everywhere automatically. What is missing is anything that decides the rate from trading activity.

The business intent already exists, but only as an offline model. py/nate ships the published ladder in py/nate/config/fee_program.yaml: four tiers keyed by 30-day total (maker + taker) notional, maker/taker in bps, top tier a maker rebate (-0.5 bps). Its own comment is the crux of this RFC:

This is the published ladder, not what every desk is actually charged: AX bills flat per-user rates today, so some desks sit on off-ladder negotiated rates.

We want to close that gap: an engine that computes each account's effective maker/taker fees from its trailing volume by calendar month or trailing 30 days and writes them back, while preserving negotiated overrides.

Two reusable primitives already exist and should not be re-implemented:

Goal

Build an engine that, on a schedule:

  1. Computes each account's trailing volume over a configurable window (calendar-month-to-date or trailing-30-day).
  2. Maps that volume to a maker/taker rate via a versioned fee schedule.
  3. Writes the derived rate to trading_accounts so it propagates through the existing replication path with zero changes to trade-engine2.
  4. Never clobbers a negotiated/manual rate off-ladder desks are pinned.
  5. Leaves an audit trail: who/what changed a rate, from what to what, and the volume + tier that justified it.

Non-goals (v1): RFQ-specific rebate schedules (tracked separately in rome.md / A-3297), MM stipends and bonus-pool payouts (those are deposits with free-text reference_ids, not fees see the MM-payouts model), per-product fee differentiation, and referral/promo programs. The data model is shaped so these can land later without a rewrite (see Future-proofing).

Where should this live?

This is the core question. I evaluated folding it into each existing service against standing up a new one.

Candidate Fit Verdict
trade-engine2 Already holds the rate map and applies fees. One reviewer suggested adding a tier task here. No. This is the hot, deterministic fill path. It must stay a pure consumer of rates. Mixing "decide the rate" (periodic ClickHouse scans, schedule logic) into "apply the rate" couples a slow analytical job to the matching/settlement path and conflates two responsibilities.
recon-engine Already runs the exact CHPG periodic volume loop and writes derived state to Postgres refresh_leaderboard_task upserts per-account volume rankings (DbLeaderboardEntry::upsert_batch, rs/recon-engine/src/leaderboard.rs). This is a direct precedent: the fee engine is the same loop with a different sink. Recommended. See below.
settlement-engine Scheduled financial daemon that already writes funding/settlement transactions. No. Its domain is contract lifecycle funding, rolls, settlement prices. Commercial pricing programs are a different bounded context; bundling them dilutes the service and entangles fee-policy deploys with settlement-critical code.
api-gateway Owns the admin write path (update_fees) and an admin UI surface. Partial. It should own the human-facing CRUD for schedules and manual overrides (request/response). It should not host a long-running periodic evaluator that is not a gateway's job.
New standalone service A dedicated ax-fee-engine daemon would be the textbook "own bounded context" answer. No, for v1. It buys a clean domain boundary at the cost of a whole new deploy unit binary, Dockerfile, health wiring, on-call surface to host one periodic loop that recon-engine already runs verbatim. Not worth it until the fee domain grows past volume tiers (see Future-proofing).

Recommendation: a module in recon-engine

Land the engine as a new periodic task inside recon-engine, alongside refresh_leaderboard_task. The two are mechanically the same tick on a cadence, call ChTradeRow::query_account_volumes for the window, upsert to Postgres so this reuses the service's existing daemon scaffolding, ClickHouse + Postgres pools, cadence/boundary helpers, and failure-isolation pattern rather than standing up a second deploy unit for one loop. recon-engine already writes derived state to Postgres on a schedule, so this is in-character, not a layering violation.

Concretely, add rs/recon-engine/src/fees.rs with a refresh_fee_rates_task that:

The human-facing CRUD (define schedules, assign an account to a schedule, pin a manual override) lives in api-gateway admin routes, writing the same Postgres tables the task reads. The recon-engine task is the periodic evaluator; the gateway is the control surface. This keeps request/response and scheduled-compute concerns in their natural homes while sharing one data model.

The one real cost is blast radius: fee rates are authoritative billing state that flows straight into trade-engine2's fill path, whereas recon-engine's other writes (invariants_log, the leaderboard table) are diagnostic. A bug in the fee task charges customers wrong. We accept that and mitigate it with the fee_mode='auto' gate, the dry-run rollout, and a self-recon invariant (all below) the same service that hosts the engine also hosts the check that polices it.

Future-proofing when to extract a service

Keep the data model generic even though the home is recon-engine. The schedule/tier/assignment tables (below) model program window tier rate plus per-account assignment and override, so RFQ rebate schedules, per-product fees, and promo/referral programs become new rows and new program_kinds, not a schema rewrite. If and when the fee domain outgrows a single periodic task its own admin surface, multiple program kinds, independent deploy cadence extract the task and its tables into a standalone ax-fee-engine crate. Because all fee logic lives behind update_fees and the schedule tables, that extraction is a move, not a redesign. (If the name is ever needed: fee-engine, not pricing-engine "price" is overloaded here by index/mark/settlement prices and index-publisher.) The engine deliberately does not absorb risk limits (owned by risk) or stipend/bonus payouts (which are deposits, not fees).

Policy as code ("codeslaw") the fee program is the source of truth

Today the dependency points the wrong way: there is a published rulebook (somewhere in a doc/PDF), and py/nate/config/fee_program.yaml is a hand-kept copy of it that code reads. The code chases the doc, and the two drift.

Invert it. Make a version-controlled policy artifact in the repo the single source of truth, and generate everything else from it:

Git history then is the audit log of every fee change who, when, and (via PR review) why which is exactly the provenance a regulated exchange wants. This is the "code is law" framing: the rulebook is rendered from the policy, never maintained alongside it.

Where the program lives

The YAML supplies the values; the Rust structs are the single source of truth for shape. Deserialization is validation gate #1. Gate #2 is a FeeProgram::validate() -> Result<()> that ensure!s the invariants a type can't express tiers strictly monotonic in min_notional_usd, no gaps/overlaps, rebate floor respected (per CLAUDE.md: enforce contracts with ensure!, not comments). Export a JSON Schema (via schemars) into the existing schemas/ dir so editors lint the YAML live.

What language to author it in

Two axes get conflated here: the authoring format and the type system. The recommendation is YAML authored, typed by Rust serde structs, validated in CI for a flat tier ladder, the program is data, not a program, and YAML is diffable, commentable, PR-able by non-engineers, and already the de-facto choice.

Survey of the alternatives and when each would actually win:

Option When it wins Verdict here
YAML + serde schema Declarative data; humans edit; structure enforced in Rust Recommended. Matches existing fee_program.yaml; lowest friction.
TOML Flat config Nested tier arrays read worse than YAML.
JSON / JSON5 Machine target Plain JSON has no comments a generated artifact, not a source.
Rust consts Maximum type safety Defeats the point rates become a recompile+redeploy, not DB-driven.
CUE / Dhall / Jsonnet / KCL Typed config that generates artifacts and validates invariants natively Genuinely strong for "rulebook from code"; CUE could even subsume validate(). Defer niche, new toolchain until many interrelated programs justify it.
CEL / Rhai / Starlark / Lua Rules need logic: conditional eligibility, computed thresholds, time-boxed promos, per-product overrides Keep in the back pocket. Don't start here.

The guiding principle: stay declarative as long as the policy is data; reach for a language only when it becomes computation. You'll know you've crossed the line when you catch yourself encoding if/then into YAML keys that's the signal to introduce a sandboxed, deterministic evaluator (CEL for safe boolean eval, Rhai for Rust-native embedding), not to keep contorting the data format. Because every consumer goes through the fee_program.rs types, that later migration is contained.

Generation and the relationship to the DB tables

One artifact, two consumers, zero drift:

Design

Data model (Postgres, db/postgres/1.sql)

Schedules and tiers are versioned and immutable-by-version so historical rates are reconstructable for billing disputes.

CREATE TABLE fee_schedules (
    id            CHAR(16) PRIMARY KEY,
    name          TEXT NOT NULL,
    program_kind  TEXT NOT NULL DEFAULT 'volume_tier',   -- future: 'rfq_rebate', 'promo', ...
    window_kind   TEXT NOT NULL,                          -- 'calendar_month' | 'trailing_30d'
    is_active     BOOLEAN NOT NULL DEFAULT TRUE,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE fee_schedule_tiers (
    schedule_id      CHAR(16) NOT NULL REFERENCES fee_schedules(id),
    min_notional_usd NUMERIC NOT NULL,                    -- lower bound, inclusive
    maker_fee        NUMERIC NOT NULL,                    -- decimal, e.g. -0.00005
    taker_fee        NUMERIC NOT NULL,
    PRIMARY KEY (schedule_id, min_notional_usd)
);

trading_accounts gains an assignment + override mode. The existing maker_fee / taker_fee columns stay as the materialized effective rate i.e. the engine writes the resolved rate there and the entire downstream path (replication trade-engine2) is unchanged.

ALTER TABLE trading_accounts
  ADD COLUMN fee_schedule_id CHAR(16) REFERENCES fee_schedules(id),  -- NULL = no auto program
  ADD COLUMN fee_mode TEXT NOT NULL DEFAULT 'manual';                -- 'manual' | 'auto'

v1 runs a single uniform ladder (decision Q8): exactly one fee_schedules row is is_active, and every auto account points its fee_schedule_id at it. The per-account fee_schedule_id column is kept cheap, and it future-proofs multiple concurrent schedules but v1 never assigns divergent schedules to different accounts. The only per-account degree of freedom in v1 is manual vs auto.

Audit trail (also the source for "why did my fee change?"):

CREATE TABLE fee_rate_changes (
    id              BIGSERIAL PRIMARY KEY,
    account_id      CHAR(16) NOT NULL,
    changed_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    schedule_id     CHAR(16),
    window_notional NUMERIC NOT NULL,
    old_maker_fee   NUMERIC, old_taker_fee NUMERIC,
    new_maker_fee   NUMERIC, new_taker_fee NUMERIC,
    reason          TEXT NOT NULL                          -- 'tier_promotion', 'tier_demotion', 'schedule_edit', 'manual'
);

Engine loop

A new task in recon-engine, modeled directly on refresh_leaderboard_task:

on each tick (and on boundary crossing):
  for each active schedule S:
    window = resolve_window(S.window_kind, now)          # [start_ns, end_ns]
    accounts = trading_accounts where fee_mode='auto' and fee_schedule_id = S.id
    volumes  = ChTradeRow::query_account_volumes(conn, Some(accounts), start_ns, end_ns, None)
    for each account A:
      tier = highest tier in S whose min_notional_usd <= volumes[A]
      if (tier.maker_fee, tier.taker_fee) != (A.maker_fee, A.taker_fee):
        DbTradingAccount::update_fees(A, tier.maker_fee, tier.taker_fee)   # propagates automatically
        insert fee_rate_changes(...)

resolve_window:

Both windows already work with query_account_volumes's (start_ns, end_ns) signature no new ClickHouse query is needed for v1. The cadence/boundary logic generalizes LeaderboardCadence; since both tasks now live in recon-engine, the fee task can share that windowing helper directly (extend it with trailing_30d rather than fork it). Hoisting it into sdk-internal is optional and only worth it once a second crate needs it.

Rate application policy

Calendar-month vs trailing-30-day differ in behavior, not just window math. Decided (A-4094): trailing-30 days.

The engine ships trailing-30d with hysteresis. To preserve the idempotent-tick property (Lifecycle & correctness), express hysteresis as a pure function of warehouse history e.g. "best tier over the last K daily 30-day windows," each recomputed from ClickHouse rather than a persisted consecutive-days counter, so a crashed or duplicated tick still converges with no resume token.

A cross-cutting rule for both: never silently raise a rate mid-window without a demotion record, and consider a "no-worse-than-assigned-floor" guard so a manual sweetener can coexist with auto (a per-account maker_fee_floor / taker_fee_floor, deferred to v2).

Why the write-back target is trading_accounts (not a new effective table)

Keeping the engine's output in the existing maker_fee/taker_fee columns means trade-engine2, the replication stream, the admin GUI, and the recon invariant all keep working untouched. Introducing a separate "effective rates" table would force a second consumer path in the hot fill engine for no benefit in v1. The schedule/tier tables capture the derivation; the account columns hold the materialized result. This is the minimal, lowest-risk integration.

Lifecycle & correctness considerations

Per CLAUDE.md's connection/lifecycle guidance, the engine must be safe across restarts and partial failures:

Rollout

  1. Schema migration: new tables + trading_accounts columns, all defaulting to manual zero behavior change on deploy. Reuses the declarative db/postgres/1.sql flow.
  2. Land refresh_fee_rates_task in recon-engine (alongside the leaderboard task), extend the cadence helper with trailing_30d, and add admin CRUD in api-gateway.
  3. Author the canonical program in rs/admin-cli/examples/fee_program.yaml (typed by sdk-internal/fee_program.rs), and add the loader that materializes it into the fee_schedules row + tiers replacing the py/nate private copy.
  4. Dry-run mode: engine computes and writes fee_rate_changes rows but does not call update_fees. Diff intended vs. actual across all accounts; this is exactly the realized-vs-modeled gap NATE already visualizes, so we can reconcile against it before going live.
  5. Flip a small cohort of accounts to fee_mode='auto', watch the audit table and recon, then widen.

Alternatives considered

Decisions

Settled in review (Slack thread, A-4094, 2026-07-08/09; decider Andrew Lee unless noted). Listed decision-first; the design text above reflects these.

  1. Window trailing-30d rolling, recomputed daily. calendar-month-prior vs trailing-30d Chosen because it's the prevailing exchange standard (Hyperliquid 14d; Coinbase/Binance/Kraken 30d) and "lets people catch up if they fall behind." Consequence: hysteresis moves into v1 scope a rolling window flaps at tier boundaries without it (see Rate application policy). Promote immediately; demote only after N consecutive days below the lower band, rate-limited and audited.

  2. One uniform ladder for everyone yes. v1 puts every account on a single uniform fee ladder. Rationale: no exchange precedent for publishing "different fee programs for different people"; it hurts perceived fairness and "invites capriciousness in administering it" (+1'd in thread). Consequence: one active schedule; per-account assignment collapses to manual vs auto (see Data model). This narrows but does not delete goal #4: negotiated desks are preserved via manual mode, not via a divergent published ladder.

  3. Program modeling flat schedule + tiers, not composition. v1 implements only the ladder shape; program_kind is reserved for future kinds. The composition model (typed additive fee_components) was floated and then withdrawn in review ("shouldn't use a composite model for v1"): it only pays off once a second component type exists, and the flat model gives an "infinitely cleaner," revertible cutover and partial rollout. Generalize by adding program_kinds later (see Alternatives considered).

  4. manual stays an engine bypass, not a program. With composition rejected (Q6), representing a negotiated desk as a "fixed constant program" is moot. Keep fee_mode='manual'. Rollout initializes every account to manual, then flips cohorts to auto this is the clean, revertible cutover that motivated the flat model. The override-safety invariant keeps checking against manual.

  5. Aggregation per trading_accounts.id. Fees are set per trading account; that's the accepted v1 heuristic ("a decent heuristic for now we can update it later"). No pooling across an owner's accounts (ubo_user_id).

  6. Manual siblings' volume in the pool moot. Per-account tiering (Q3) means each account is tiered by its own trailing volume only; there is no cross-account pool for a manual account's volume to contribute to.

  7. Multi-ladder per account no. One ladder in v1; fairness issues with per-account/per-region ladders. A single account is only ever on one ladder.

Still open

  1. Volume basis: fee_program.yaml keys tiers on combined maker+taker notional (what query_account_volumes returns). Confirm that's the intended basis, vs. separate maker/taker volume thresholds.
  2. Override coexistence: do we need a per-account floor so a manual sweetener survives auto mode (maker_fee_floor/taker_fee_floor), or is manual-vs-auto binary enough for v1? (Leaning binary; floor deferred to v2.)
  3. Cadence/timing: daily recompute is decided; the exact tick hour-of-day (UTC, matching calendar_math.rs) and the demotion band width / N-day count for hysteresis still need concrete numbers.