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.
btnl-trade-engine books into our ledger.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.
btnl-trade-engine writes
fills from drop copy to ClickHouse trades,
positions and transactions, and to Postgres
current_balances.btnl-positions-match,
btnl-cash-transfers-match,
btnl-execution-fees-match,
btnl-funding-payments-match,
btnl-orders-match,
btnl-account-state-matches-risk and
btnl-instruments-match. They do not write.docs/internal/overview/engines/bitnomial-reconciler.mdx).terraform/aiex/sftp/). SFTPGo writes
each upload to
s3://8dc8620c-aiex-ext-documents/incoming/bitnomial/. The
bucket is versioned, uses Object Lock, and expires nothing. SFTPGo uses
multipart upload, so an object is visible only when it is complete. The
aiex-prod instance role has a read-only policy on
incoming/* in terraform/aiex/prod/main.tf
(read-clearing-docs); see Ops items.incoming/bitnomial/statements/<externalAccountId>/<date>/statement_<date>.jsonl.
Each file holds one JSON object for one account. On 2026-08-28, 67
accounts had statements. A statement has:
statementStartTime and
statementEndTime;openPositions[]: symbol, market price, signed
netPosition as {tag: Long|Short, contents},
vwaPrice, openTradeEquity;accountSummaries[]: cash by origin (Seg,
NonReg) with beginning and ending balances, fees by type
(ClearingFee, Commission,
ExchangeFee, NFAFee), net liquidating value,
initial and maintenance margin, and funding for the bracket;perpsFundingPayments: one row per 8-hour funding
interval;accountCollateral: BTC and ETH with the haircut
conversionFactor;trades[], cashTransactions[] and
cashAdjustments[]. Every file so far has these empty, so
their schemas are unknown.incoming/bitnomial/: new accounts, trade
activity, cash transactions and open positions
(ARC_*_<date>). AX does not read them.GET /introducing-broker/account-states returns balance,
equity and margin for each account, cached for up to 2 minutes.
/cash-activity (also as an SSE stream) returns deposits and
withdrawals. /trade-activity returns fills with
fees by type and feeTotal. Prices on
/trade-activity are in cents. The client in
rs/bitnomial/src/clearing/ models all three.BC000004cf. It is FIX tag 1 and
trading_accounts.btnl_account_id. The clearing firm code is
DCZ.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).
propose mode in the price-band SoW.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).reference_id = recon://<date>/<plan-id>, like
the fee RFC's clearing-report://, so the ledger records why
it changed.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.sync_conditions opens it again on the next pass.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.
A loop in bitnomial-reconciler, turned on by
BTNL_STATEMENTS_S3_BUCKET.
bitnomial-reconciler clearing-statements runs one pass and
exits.
incoming/bitnomial/statements/, skips
the keys already in btnl_statement_files, downloads the
rest, and writes their raw and parsed content to ClickHouse. There is no
cursor. A rerun, or a restart after a crash, repeats only the missing
work.parse_error set. Each pass parses these files again from
raw, so a parser fix applies without reading S3.btnl-statements-current fails
when the newest parsed bracket is older than expected, or when a file
has a parse error. It expects a business day's statement by 23:00 CT. It
does not know exchange holidays, so a holiday gives a false alert.trades[] and
cashTransactions[] contents are not parsed. They wait for a
populated sample (Ops items).The planner runs for each account after each ingest pass (daily mode), or for one account on request (Stage 4).
Our side. Our ledger at the end of the bracket, from ClickHouse timestamps and sequence numbers: cash, open positions, and the fills in the bracket.
Bitnomial's side. The statement in daily mode, or the IB API in ad-hoc mode.
Actions.
insert_fill: a Bitnomial fill with no matching trade in
AX, as for BC000004cf. The executor books it with the venue
execution id, price, quantity and time.insert_transaction: a missing deposit, withdrawal, fee
or funding payment.position_adjustment: a position difference that no fill
explains, such as a give-up, a transfer or a correction after
settlement. Always parked.balance_adjustment: a cash difference left after all
other actions. Always parked.Until a statement with a populated trades[] exists, the
planner writes only parked adjustments.
Support task. When
BTNL_RECON_SUPPORT_TASKS is true, each run calls
DbSupportTask::sync_conditions with one condition for each
account that has an unapplied non-empty plan.
detail lists each open bracket and its difference.{action: "apply_btnl_recon_plan", plan_id, rev, expires_at, needs_human_reasons}.
plan_id is the newest unapplied plan (decision 11), and
rev is its creation time in microseconds, so a replan
changes it. There is no proposal when the plan has an adjustment
(decision 3) or no AX trading account maps to the Bitnomial account.
detail.unfixable_reasons gives the reason.action for a non-empty plan. It is
info for a difference that the planner expects, such as the
Friday drop-copy reset (Stage 5), so that it sends no alert.self_resolved. An admin
cannot dismiss the task (decision 12).Check. btnl-recon-plans-clean fails
while a plan is pending. It appears on the Invariants page
and sends no alert of its own; the task sends the alert.
Routes. Read-only admin routes return a plan and its actions for the task card.
trading_accounts
that blocks new orders. The order gateway checks it with
is_onboarded and the user's is_frozen. It
differs from is_close_only, which allows orders that reduce
a position. An admin or the executor sets it, and each change writes an
audit row.POST /admin/support-tasks/{id}/accept, price-band stage
U1) accepts only a signed-in person
(require_human_admin_actor). It returns 409 when
rev has changed or the proposal has expired.apply_btnl_recon_plan, the
accept route records accepted with the rev,
returns 202 and does not resolve the task. It returns 409 while an
earlier accept waits for the executor. The executor in
bitnomial-reconciler polls for open recon tasks whose newest event is
accepted. When the rev no longer names the
newest plan, it records execution_failed. Otherwise it
marks the plan applying, and then:
details;insert_fill in
bitnomial_reconciler.fill_bookings. A fill whose execution
id the ledger has on this account counts as applied. A fill whose
execution id the ledger has on another AX account fails the plan,
because the executor books one side of a trade and that trade is between
two AX accounts;trades, with a
time limit;applied,
marks the account's older unapplied plans superseded,
records executed and resolves the task as
accepted. The producer cannot resolve the task as
self_resolved before this transaction commits;fill_bookings for rows that are not booked and sends each
one to its state machine. The state machine skips an execution id it has
booked, and books the rest one side at a time, as for a fill whose
counterparty is another firm. The trade row keeps the fill's time. The
position row and its PnL carry the booking time, so as-of reads before
the booking do not change. The order id is
recon://<date>/<plan-id> (decision 7). Booking
does not include fees. Stage 4 books them as Fee
transactions.aborted, replans the account
for that bracket, and records execution_failed. The new
plan gets a new proposal and rev. Any other failure sets
the plan to pending, records execution_failed
and keeps the task open.applying plan to pending, records
execution_failed and removes its freeze. An admin must
accept the plan again. The rerun skips fills that are already
booked.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.
account-states can be up to 2 minutes old, so the planner
reads it until two reads agree, or parks the difference.expires_at limits this time.
When the proposal expires, the executor removes the freeze and the
planner writes a new proposal.sync_conditions
resolves every open task of a producer whose subject is not in the call.
An ad-hoc run reports one account, so it must not use the daily
producer's name. It adds its account to the daily producer's next call,
or it uses a producer name for each account
(bitnomial-reconciler/adhoc:{external_account_id}).BC000004cf with Stage 4. The
task shows the two missing fills (execution ids
7668829274270413697 and 7669932909071499348,
$0.14 in fees each) and the $1,000 deposit. An admin accepts it. After
the apply, AX matches the statement. This is the definition of
done.btnl-positions-match and
btnl-cash-transfers-match change from report-only to
gating, using the bracket-close state.info severity.accepted and
executed; a proposal expires while the confirm dialog is
open; an incident.io alert differs from its task.| 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 |
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. |
rule-accept route and rules.terraform -chdir=terraform/aiex/prod apply for the
read-clearing-docs policy. On 2026-08-31 the aiex-prod role
got AccessDenied on the bucket.BC000004cf. The next statement
then has a populated trades[], which gives its schema.terraform/incident-io/prod.tf
reads only the ax_prod source. Set
RECON_ENGINE_INCIDENT_IO_* in the AIEX environments.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.