SoW: Bitnomial ledger reconciliation

Date: 2026-09-24

Tracking: A-4816 (statement ingest), A-4817 (planner).

Replaces: the btnl-clearing-live-sync RFC, which is deleted. Uses: the Support Queue (price-band-automation), the bitnomial-reconciler service, and the recon-engine check framework. Related: aiex-venue-fee-accrual, btnl-pg-dropcopy-pipeline, continuous-recon-checks. Applies to: the AIEX edition only.

Objective. Every business day, AX compares the cash, positions, fees and funding of each introduced account with Bitnomial's statement for that account. When they differ, AX writes a plan of corrections and opens a task in the Support Queue. An admin approves the plan, and AX applies it while order entry on the account is frozen. An admin can also run the same comparison for one account at any time, against Bitnomial's live API. Bitnomial is the source of truth for settled cash, positions, fees and funding.

Terms

The problem

AX can lose fills, and nothing repairs the ledger when it does. btnl-trade-engine drops a fill when its clearing firm and account are not in the account index, and the REST /fills backfill looks back only 7 days. The btnl-* checks in recon-engine find the difference but do not correct it.

Account BC000004cf shows the effect. It has cash and a position at Bitnomial and zeros in AX. Its two fills, on 2026-08-01 and 2026-08-04, came before the account was linked on 2026-08-28. AX dropped them, and they are older than the backfill window. A repair from Bitnomial's records is the only fix. This account is the acceptance test for this SoW.

Current behavior

The model

S3 statement ──ingest──> ClickHouse statement tables ─┐
                                                      ├─planner─> plan + actions (Postgres)
our ledger (ClickHouse, Postgres) ────────────────────┘                │
                                                                       ▼
                               #ax-support <── notifier <── support task
                                                                       │
                                                             admin accepts
                                                                       ▼
                                        executor: freeze, drain, check, apply, unfreeze
Record Meaning Where
Statement file One S3 object, raw, with its ingest state ClickHouse btnl_statement_files
Statement summary, position The parsed contents of a statement ClickHouse btnl_statements, btnl_statement_positions
Plan The difference for one account and bracket, and its status Postgres bitnomial_reconciler.plans
Action One correction in a plan, with an idempotency key Postgres bitnomial_reconciler.plan_actions
Support task One account with an unapplied plan Postgres support_tasks

bitnomial-reconciler runs the statement ingest, the planner and the ad-hoc mode. It runs as one instance, so no loop needs a leader. The planner runs after each ingest pass, so a new statement starts planning.

recon-engine stays read-only. Its checks read what bitnomial-reconciler writes, and its notifier sends the alerts for support tasks.

The executor applies an approved plan. It runs in bitnomial-reconciler (decision 10).

Decisions

  1. Bitnomial is the source of truth for settled state. Between statements, AX tracks each account from drop copy. Each statement corrects it.
  2. Nothing changes the ledger until an admin approves the plan. No action is applied automatically. This is propose mode in the price-band SoW.
  3. The executor does not apply adjustments. A plan with a position_adjustment or balance_adjustment gets no proposal, and its task says why. A person repairs the account by hand. An adjustment has no fill that explains it, and no known case needs one. Most differences are fills that the planner does not see until Stage 4. balance_adjustment depends on ledger corrections (#3509).
  4. The freeze is needed only to apply. The daily comparison uses the ledger at the end of the bracket, which is hours in the past when the planner runs, so it needs no freeze. The executor freezes the account while it applies a plan, so the customer cannot trade on a position that the executor is changing.
  5. Statements are read with the S3 SDK. The IAM read policy exists, and the SDK is one dependency with no host setup. A filesystem mount would add a daemon and a failure mode.
  6. btnl-trade-engine books the fills. The executor queues each fill in Postgres, and btnl-trade-engine books it through the same state machine as drop copy. btnl-trade-engine holds each account's position in memory and writes it on every fill, so it must book every fill on its accounts. The state machine books queued fills and drop-copy fills one at a time, in the order it receives them.
  7. Every applied action is tagged. Each action is append-only and carries reference_id = recon://<date>/<plan-id>, like the fee RFC's clearing-report://, so the ledger records why it changed.
  8. Plans are support tasks. The planner is a support-queue producer. The Support Queue page shows the plan on web and mobile, and the notifier sends the alert. There is no reconciliation page.
  9. One task per account. The subject is btnl_recon:{external_account_id}, and it stays the same while the account has unapplied plans. A subject per bracket would open one task and one alert for each bracket after an outage.
  10. bitnomial-reconciler runs the executor. It runs one instance for each environment and holds the plan store, so the executor needs no leader. btnl-trade-engine books the executor's fills (decision 6), and recon-engine is read-only. The accept route in api-gateway records the approval and returns, because freeze and apply can take longer than one HTTP request.
  11. The newest plan covers the older ones. The proposal names the newest unapplied plan, and applying it closes the older ones. Each plan compares the whole account at the end of its bracket, not the change since the plan before it. A difference that lasts three brackets is in all three plans, so the executor applies only one of them.
  12. A recon task closes only when the account has no unapplied plan. This happens when the executor applies a plan or a replan finds no difference. An admin cannot dismiss the task, because sync_conditions opens it again on the next pass.

Schema

ClickHouse (db/clickhouse/init.sql). All three tables are ReplacingMergeTree(ingested_at), so when a file is ingested again, the newer rows replace the older ones.

Table One row per Contents
btnl_statement_files S3 object Key, ETag, size, raw JSON, parse_error (NULL when parsed)
btnl_statements Account summary in a file Cash by origin, margin, fees by type plus other_fees, funding, counts of the unknown sections
btnl_statement_positions Account, bracket and symbol Signed net position, market price, VWA price, open trade equity

Postgres (db/postgres/1.sql, schema bitnomial_reconciler).

Table Key columns
plans Bracket date, clearing firm, external account id, trading account (NULL when AX has no account), mode (daily, adhoc), status, details (both sides of the difference)
plan_actions Plan, apply order, action_type, payload, status (proposed, parked, applied, skipped), idempotency key

A plan's status is one of these:

Status Meaning
empty No difference. The bracket is closed.
pending A difference waits for an admin.
approved, applying, applied The executor's progress.
superseded A newer plan for the same bracket replaced it.
aborted The executor found a change since the plan was made. A new plan follows.

The support task events record who approved a plan, so plans needs no approver columns.

Stage 1: statement ingest

A loop in bitnomial-reconciler, turned on by BTNL_STATEMENTS_S3_BUCKET. bitnomial-reconciler clearing-statements runs one pass and exits.

Stage 2: planner

The planner runs for each account after each ingest pass (daily mode), or for one account on request (Stage 4).

Stage 3: freeze and executor

Stage 4: ad-hoc mode

An admin starts a comparison for one account now. The planner uses the IB API in place of a statement, and the executor is the same. This is the tool for an account that looks wrong now, and for repairs such as BC000004cf.

Stage 5: acceptance and hardening

Delivery

Stage Scope PR Status
1 Statement ingest, btnl-statements-current #3901 Merged
2 Planner, plan store, btnl-recon-plans-clean, read-only routes #3903, #4282 Merged
3 Freeze, executor, apply_btnl_recon_plan action Not started
4 Ad-hoc mode Not started
5 BC000004cf repair, gating, schedule, tests Not started

Failure and restart

bitnomial-reconciler keeps no state in memory. S3 holds the statements, ClickHouse holds what was ingested, and Postgres holds the plans and tasks. After a restart, the next pass reads all of them again.

Failure Result
S3 or ClickHouse is unreachable The pass retries for up to 30 minutes, then the service exits and its health check fails.
A statement is late btnl-statements-current fails.
A statement does not parse The file is stored raw. btnl-statements-current fails until a parser fix parses it.
bitnomial-reconciler is down No new plans. Open tasks stay open, and last_seen_at stops.
The executor stops during apply The rerun completes the plan through the idempotency keys.
The ledger changes during apply The plan is aborted, and the planner writes a new one.

Out of scope

Ops items (no PRs)

Open questions

  1. When the statement and account-states differ, which one wins? The proposal is that the statement wins for its bracket, and account-states only raises an alert. It is never a source for a cash action.
  2. Does a freeze need a message to the customer, such as a banner in the GUI? A daily freeze lasts seconds. An ad-hoc freeze lasts until the admin decides or the proposal expires.
  3. When AX and Bitnomial disagree on a fee for a long time, which number does the customer pay? This is a business decision.