RFC: Harmonizing Expiring Contracts (Bitnomial)

Date: 2026-07-03

Status: Draft

Background

AX ships two exchange editions with opposite expiry semantics: the AX edition over EP3, and the AIEX edition over Bitnomial. Today the AIEX edition lists only perpetuals. This RFC scopes the work to list and operate dated, expiring contracts on AIEX (backed by Bitnomial) and to harmonize that behavior with the EP3-backed AX edition.

Naming, kept precise throughout: an AIEX instrument carries an AX-style display symbol (e.g. XAU-PERP) that masks an underlying Bitnomial product (e.g. product 6662) via additional_product_specs.btp_product_id. The symbol is AX-shaped; the contract underneath is Bitnomial. All expiring-contract work in this RFC lives on the AIEX/Bitnomial side unless it explicitly names EP3.

Two prior RFCs bound this one and are assumed read:

The anchor tickets: A-3486 (done) built the Bitnomial instrument-load pipeline, and A-3404 (Andrew Lee, In Progress) is "Support dated expiring futures on AX (compute MVP)."

The two exchanges sit at opposite ends of the settlement spectrum

EP3 (AX edition) AX must drive settlement. EP3 models dated futures fully: instruments carry InstrumentType::Future + maturity_date, and a lifecycle state machine Pending → Open/Preopen/Suspended/Halted → Expired → Terminated. But per Connamara, quoted in A-3404 EP3 does not auto-resolve futures. At expiry the instrument flips to EXPIRED and positions are left untouched; AX must pick the final price, walk every open position, and book the cash legs itself. A-3404 landed the protocol shape for this (PR #2054): SetFinalSettlementPriceRequest (stage a price before expiry), FinalizeExpirationRequest (idempotent, requires state Expired), and GetFinalSettlementPriceResponse with a finalized_at timestamp (rs/sdk-internal/src/protocol.rs:413-433). These types have zero implementers today the finalizer worker, admin endpoints, and GUI are not in the tree.

Bitnomial (AIEX edition) Bitnomial drives settlement. Bitnomial is the DCM/clearinghouse. Each dated contract is its own product_id with type: "future", month/year, first_trading_day, final_settle_time, product_status: forthcoming | active | expired, and settlement_method: Deliverable. Bitnomial lists forthcoming contracts, expires them at final_settle_time, and performs final settlement itself. Expiring contracts are Bitnomial's normal state a live catalog pull returned ~5,360 products (492 futures, 4,106 options, 718 spreads, only 18 perpetuals) so perps are the exception, not the rule.

So harmonization is asymmetric: on EP3 we originate settlement; on Bitnomial we mirror a settlement Bitnomial already performed. The shared surface is A-3404's protocol primitives, which must become engine-pluggable at the price-source seam.

What AIEX discards today

The Bitnomial REST parser already reads everything we need: RawProductSpec (rs/bitnomial/src/rest/products.rs:39) parses kind (#[serde(rename = "type")]), product_status (:44), month (:75), year (:77), and final_settle_time (:73). We then throw them away: the api-gateway instrument builder treats the mapped Bitnomial product as "purely a tick/data backing; its kind is intentionally ignored perpetual semantics (no expiration) come from the AX row" (rs/api-gateway/src/instruments/bitnomial.rs:112-116), and the built instrument takes expiration: row.expiration from Postgres (:148), which is NULL for every AIEX instrument today.

The mapping itself is static and singular: one AIEX symbol ↔︎ one Bitnomial product_id, stored in the bitnomial_product_config JSONB column on instruments (db/postgres/1.sql:137; BitnomialProductConfig in rs/sdk-internal/db/src/entities.rs:846-850; extracted via DbInstrument::bitnomial_product_id() at :826-830; set via admin-cli's BitnomialInstrumentSpec.btp_product_id).

Goal

  1. List Bitnomial dated contracts as AIEX instruments with the correct contract_date and expiration, driven by Bitnomial spec fields rather than hand-set NULLs.
  2. Enforce the contract lifecycle (forthcoming/active/expired) on the AIEX order path, in risk, and in the GUI.
  3. Mirror Bitnomial final settlement into AIEX ledgers/positions, reusing A-3404's protocol primitives with a Bitnomial-sourced price instead of an operator-chosen one.
  4. Do all of the above without a per-contract code change or redeploy listing a contract stays pure data, as the internal-reforms RFC established.

Non-goals

The work

Six pieces. Items 12 are listing/catalog mechanics; 34 are the lifecycle and settlement core; 5 is surfaces; 6 is a product decision that gates 34.

1. Series-aware instrument mapping

Today one static AIEX symbol maps to one Bitnomial product_id. In the dated world a Bitnomial series (a base_symbol, e.g. Solana Hecto) fans out into many contracts SOUHU26 (Sep-2026), SOUHZ26 (Dec-2026), each its own product_id with its own final_settle_time. We need one AIEX instruments row per contract, where:

Because contract_date and product_id are frozen by the identity trigger (db/postgres/1.sql:157-170), each contract row is immutable identity once created exactly the guarantee dated contracts need.

This needs a listing procedure: given a base_symbol, pull the Bitnomial specs for its forthcoming/active contracts and materialize the rows. Open question below on whether that is admin-gated (admin-cli, matching today's BitnomialInstrumentSpec flow) or automated from the catalog.

2. Catalog refresh at roll

Every exchange-facing loader loads its catalog once at startup and never refreshes:

One perpetual never rolls, so startup-only is fine today. Quarterly (or monthly) contract churn makes it untenable: a newly-listed forthcoming contract, or a contract flipping to expired, must reach every loader without a coordinated manual restart.

The mechanism is not re-specified here: the add side (a newly-listed contract reaching every loader live) is specified cross-engine in live-instrument-add-without-restart.md (A-4046) per-loader reconcile that re-runs the startup load additively, a self-paced schedule whose eager tick is driven by each contract's own lifecycle data (forthcoming status, expiration proximity) rather than a roll calendar, the forthcoming → active window as the not-yet-tradable gate, and a fail-closed guarantee replacing the "lockstep across loaders" invariant the md-btp comment relies on mid-roll skew degrades to rejects/holds, never to one service routing a contract another has expired.

What remains this RFC's scope: the expire side of a roll. A contract flipping to expired is a catalog mutation, which A-4046 explicitly excludes (its reconcile is additive-only) and gates on items 34 below lifecycle enforcement and final-settlement mirroring. Until those land, expiry at roll keeps the A-4040 restart runbook; new-contract listing stops needing a restart as soon as the shared mechanism ships on the AIEX loaders.

3. Expiry lifecycle enforcement

Consume product_status, which we currently parse and discard:

4. Final-settlement mirroring (the crux)

At final_settle_time Bitnomial settles the contract and publishes a settlement_price (via /product/data). AIEX must book that result into its own ledgers and positions so client statements, positions, and PnL reconcile.

Reuse A-3404's protocol shape, but flip the price source:

Mechanically, finalization walks every nonzero (user, contract) position and drives qty to zero against a settlement system account at the settlement price, booking the PnL + basis-reset legs the mechanism sketched in the internal-reforms RFC's "Companion work: expiry settlement" section, which is the same machinery the EP3 side needs.

This is the harmonization crux and requires coordination with Andrew (A-3404): the finalizer worker does not exist yet on either side. Build it once, with an engine-pluggable price source operator-staged for EP3, exchange-read for Bitnomial rather than two parallel finalizers. The A-3404 protocol types are the shared contract; today they have zero implementers, so the seam is still free to design.

Per the project's lifecycle-testing rule (CLAUDE.md), finalization must be exercised under client disconnect, server-initiated disconnect, crash/restart, and recovery before rollout an idempotent FinalizeExpirationRequest keyed on contract state is what makes crash-mid-settlement safe.

5. API / GUI surfaces

Instrument.expiration already flows to clients (rs/api-gateway/src/instruments/bitnomial.rs:148 passes row.expiration), so once listing writes a real expiration the wire carries it for free. GUI work:

The maker/taker sims already price via a nominal expiration, so simulated dated markets are testable end-to-end.

6. Deliverable-settlement scope decision (gates 34)

Bitnomial futures are settlement_method: Deliverable. AIEX must decide what a client holding to expiry experiences:

This is a product/ops decision, not an engineering one, but it gates the lifecycle window (3) and the settlement mechanism (4): force-flatten means 4 rarely fires; cash-mirror means 4 is the primary path. Decide before building 34.

Blast radius

Surface Change
Instrument listing (admin-cli / api-gateway) Series per-contract rows; derive contract_date/expiration from Bitnomial month/year/final_settle_time (already parsed)
Catalog loaders (api-gateway, OG, md-btp, risk-bn, trade-bn) Adopt the shared live-add reconcile (live-instrument-add-without-restart.md); expiry mutation keeps the runbook until items 34 land
Order-gateway admission Block/close-only on expired/near-expiry product_status
Settlement engine New finalizer worker (shared with EP3, engine-pluggable price); A-3404 types gain their first implementer
GUI Dated symbols, expiration column, market-state pill, expired-market UX
Postgres None expiration, contract_date, product_id, identity trigger already exist
InstrumentId / SDK None u64 packing already landed

Explicitly unaffected: the identity model, the TB ledger layout, and the wire shape of Instrument.expiration (a NULL becomes a real value; no schema change).

Alternatives considered

Open questions

Suggested next step

Sync with Andrew on A-3404 (finalizer worker unbuilt, design PR #1888 closed), resolve the deliverable-settlement decision (item 6), then file an AIEX epic with tickets tracking items 15, each referencing the shared A-3404 primitives.