RFC: AIEX Venue Fees — Estimate, Accrue, Realize

Date: 2026-08-06

Status: Draft

Related: btnl-reconciliation (the reconciliation that delivers the clearing data this RFC consumes), accrual-engine (the accrual_engine schema this RFC's ledger lives in), A-4459/A-4462 (append-only ledger corrections).

Author: Andrew Lee (with Claude)

Bitnomial is the DCM and owns the fee program. On AIEX, no intraday feed carries the fee: FIX drop copy ExecutionReports, exchange REST /fills, and BTP are all fee-free. The only source of the actual fee charged is the Clearing REST API's /executions (commission + per-fee-type fees breakdown), which is post-trade, eventually consistent, and in practice a daily-report cadence. The best AIEX can do intraday is guess.

Today's code pretends otherwise: btnl-trade-engine computes a fee from our own trading_accounts rate table at booking, freezes it on the trade row as if it were truth, and books four final Fee ledger legs. That is false certainty — the internal rate table has no authority over a fee program Bitnomial owns. This RFC replaces it with an honest three-part model: a frozen estimate at booking, an accrual that adjusts risk and withdrawals intraday, and a realization step that books the venue's actual fee into the cash ledger when the clearing report arrives. Every stream stays append-only, with no exceptions.

A note on the current state: several merged Bitnomial surfaces (including the btnl-execution-fees-match recon check and comments asserting fee semantics) have not been validated against real clearing data. This RFC treats them as untested scaffolding, not established fact, and names the empirical gates in §Open questions.


Decision-first summary

D1 — Two facts, two records: fee determination is an appended event, never a mutation

A fill and its fee are learned at different times from different sources: the trade happened (drop copy, intraday) and the venue determined its fee (clearing report, T+1). Model them as two records:

This dissolves the Option→Some discomfort entirely: no row transitions state, no ReplacingMergeTree version races on trades, and restatements are ordinary appends with full lineage instead of a special case.

D2 — Estimates never enter the cash ledger: accrue, don't book

The four final Fee transactions per trade stop (AIEX only). Booking instead appends an accrue event carrying the estimated fee to a fee-accrual ledger. The cash ledger (transactions + current_balances) receives only clearing-confirmed fee postings, at realization. In the common path there are zero corrections: the estimate lives and dies in the accrual ledger, and system-correction:// (A-4459/A-4462) stays reserved for the genuinely exceptional case of Bitnomial restating an already-realized report.

D3 — Risk and withdrawals subtract an O(1) open-accrual sum, via the existing replication mechanism

The accrual ledger mirrors the cash ledger's two-layer shape: an append-only journal (ClickHouse fee_accruals: accrue / release / park events) plus a materialized per-account balance (Postgres accrual_engine.fee_accrual_balances: account_id, open_amount, sequence_number), written under the same PG-commit-is-the-atomicity-boundary discipline as ax-transaction-engine. Risk-engine and treasury subscribe to fee_accrual_balances over logical replication exactly as they do current_balances, and compute:

One number, one new replica, no new mechanism. Without this, moving estimates out of the cash ledger would silently overstate intraday equity — and the sharper edge is withdrawals: an account could trade all day, withdraw its full balance, and go negative at realization.

D4 — Realization is reconciliation: per-item lifecycle, batch identity, day-close invariant

The accrual item's lifecycle is the recon state machine:

Realization is per-item, not day-atomic — one missing execution parks one break instead of holding the whole day hostage. Each day's realizations are batch-tagged reference_id = clearing-report://<date> so the cash ledger records which report caused the postings; a restated report is a new batch that supersedes via system-correction://. The operational invariant: the day is closed ⟺ open accruals for that day are zero or explicitly parked. Statement cut gates on day-close; an aging alarm falls out of the same predicate.

D5 — The estimator is versioned, executable policy

Bitnomial publishes its fee schedule; encode it codeslaw-style (the ax-policy-fee pattern, PR #3465) as a versioned estimator. Every estimate is stamped estimator_version next to estimated_fee, so any guess is reproducible after the schedule changes. Bias conservative — estimate at the maximum plausible tier — because withdrawable (D3) depends on the accrual never understating the eventual charge. The existing btnl-execution-fees-match check stops being a pass/fail alert and becomes the estimator-quality monitor: realized-vs-accrued variance per account, feeding schedule updates.

D6 — Pure pass-through: no Architect markup

The AIEX customer fee is exactly Bitnomial's determination. There is no Architect commission component booked on top, so there is no "known at booking" fee to realize immediately — the entire fee is venue-determined and flows through the estimate → accrue → realize lifecycle. (If a markup is ever introduced, it books final at fill through the existing path and only the venue component accrues; the model composes.)

D7 — The fee-accrual ledger lands in accrual_engine; crate membership is open

The accrual_engine schema (accrual-engine RFC D7, PR #3502) is the declared home for accrual ledgers, each landing with the program that owns it. fee_accrual_balances (and any PG bookkeeping) lands there. Whether the compute joins the ax-accrual-engine crate is left open (O5): the engine's lifecycle is per-period (ACCRUED → APPROVED → PAID), while venue-fee accrual is per-fill and continuous (open → realized | parked), realized into the cash ledger rather than enqueued to program-payouts. Shared schema home and seam philosophy: yes. Forced abstraction fit: no.


Background — current state (treat as untested)

Data model

ClickHouse (db/clickhouse/init.sql):

Postgres (db/postgres/1.sql, Atlas declarative):

Write discipline mirrors ax-transaction-engine: PG balance update + CH journal insert inside one PG commit; the accrue event is atomic with trade booking (same resume-token transaction), and realization is atomic with the Fee transaction posting and determination insert.

Flows

  1. Booking (AIEX). Estimate fee via versioned policy → freeze *_fee_estimated on the trade row → append accrue events (per side) → no cash-ledger fee legs. Crash-replay regenerates accruals from the frozen estimate, not transactions.
  2. Intraday reads. Risk equity and treasury withdrawable subtract open_amount. Fills API serves estimated_fee labeled as such; actual is NULL until determined.
  3. Realization (per clearing report). For each clearing execution: match to (trade_id, side) → append fee_determination → post Fee transactions (venue amount, batch-tagged clearing-report://<date>) → append release. Unmatched venue fee → book against the account keyed to the clearing execution id and park a break. Accrual open past N days → aging alarm, park.
  4. Restatement. Superseding determination row + system-correction:// legs for the cash delta (A-4462 machinery).
  5. Day close. Assert the invariant; cut statements; emit realized-vs-accrued variance to the estimator monitor.

SDK surface

Public Fill.fee becomes semantically "actual, when known": Option<Decimal> (EP3 always Some, AIEX Some only post-determination), plus an estimated_fee field. Wording must not leak clearing internals per the rs/sdk hygiene rule; this is a client-visible contract change and gets its own review.

Migration / cutover

  1. Schema: CH columns + fee_determinations + fee_accruals; PG fee_accrual_balances. Existing AIEX rows: reinterpret historical maker_fee/taker_fee as estimates (backfill *_fee_estimated from them); historical determinations optionally synthesized from a clearing /executions backfill (A-4386 §5.9).
  2. Booking switch, edition-gated: AIEX stops emitting fee legs, starts accruing. Pick a cutover timestamp; recon invariants (trade_fees_square, fees_are_zero_sum) rewritten to key off determinations after it.
  3. Risk/treasury replication of fee_accrual_balances.
  4. Realizer (depends on at least a daily clearing /executions fetch — the A-4386 sync's execution stream, or an interim daily job).
  5. SDK change, then repoint btnl-execution-fees-match as variance monitor.

Land on aiex-demo first; soak the realizer signal-only (park-and-alert, no postings) until match rates prove the join key (O1), then enable postings.

Testing

Non-goals

Open questions


Code references as of current main. Clearing API facts per rs/bitnomial/src/clearing/mod.rs and the btnl-reconciliation SoW; treat merged Bitnomial fee behavior as untested until the O1/O2 probes run.