Last updated: 2026-09-02
Delisting is a coordinated wind-down: halt trading, settle open
positions at the final settlement price, and terminate the instrument in
EP3. The instrument's historical data (trades, transactions, funding) is
preserved; the Postgres instruments row is
never deleted — it is stamped
delisted_at.
The final settlement price is simply the last settlement that
printed for the symbol (the admin-cli delist
default). Schedule the delist to run shortly after a settlement so that
price is fresh.
Residual positions are closed by final-settlement
prints: one two-sided block trade per holder against the house
settlement account, at the final settlement price, fee-free. These are
real trades in every ledger — EP3, the ClickHouse mirror, and
GET /fills (flagged is_final_settlement) — so
a client's fills for the symbol sum to exactly zero after delisting. The
prints do not appear on the public tape, candles, or
volume; published open interest drops to zero automatically. Communicate
this mechanism in the delisting notice.
Freeze trading (is_closing). At the
announced last trading time, set the freeze from the admin GUI (Markets
→ row actions → Begin Delisting) or via the api-gateway admin route
(POST /admin/instruments/{symbol}/closing). This is an
AX-side-only freeze: it drives EP3 to Open so the closeout
block trades will be accepted, while the order gateway rejects all new
client orders and modifications as ExchangeClosed ("only
cancels are accepted") and still allows cancels. Do not
use set-instrument-state
Closed/Halted — EP3 accepts block trades only
in Open, so those states would block the closeout. Verify
new order entry for the symbol fails cleanly and cancels still succeed.
Resting orders need no manual sweep — the orchestrator cancels them at
the start of the closeout.
Wait for the final settlement. The final settlement price is the last settlement that printed, so run the delist shortly after a scheduled settlement. The settlement engine already pushed that price to EP3; there is nothing to publish manually.
Confirm no funding run is pending. The
orchestrator checks the settlement-pending:{symbol} guard,
but do not start the closeout while a scheduled funding cycle for the
symbol is mid-flight.
Dry-run the closeout.
admin-cli delist --symbol <SYM> --dry-run
The price defaults to the last settlement that printed for the symbol
(from the funding_rates ledger); the command warns if that
settlement is more than a day old. Pass --price to
override.
Review the booking plan: every holder, quantity, side, and notional at the settlement price. Long and short quantities must net to zero.
The counterparty defaults to the canonical AX settlement account
(AX_SETTLEMENT_ACCOUNT_ID under the environment's EP3
firms/prefix); it is provisioned automatically on the first real run.
--settlement-account overrides it, but the override must
already exist in EP3 and its user path must carry a ULID account id —
the command rejects anything else, because trade-engine2 derives account
ids from drop-copy participant paths and a non-conforming party would
stall trade ingestion exchange-wide.
Run the closeout.
admin-cli delist --symbol <SYM> --actually
The command prints the exact procedure it will follow before doing
anything. With the instrument in Closing (EP3 still
Open), the orchestrator takes the
settlement-pending:{symbol} guard, sweeps resting orders,
books one final-settlement block trade per holder (cross_id
prefix F-, journaled in Postgres
final_settlements), waits for the drop-copy mirror to catch
up, verifies EP3 positions, ClickHouse current_positions,
and sum-of-trades positions are all zero, then terminates the instrument
in EP3 (Terminated) and finally stamps
instruments.delisted_at and clears is_closing
in one atomic update. A set delisted_at therefore always
means the delist fully completed.
Once complete, the orchestrator rewrites ticker:{symbol}
as a terminal record — open interest 0, state Delisted, quotes cleared,
mark and last settlement at the final settlement price — with a 7-day
TTL matching the GUI's recently-delisted picker window. A failed rewrite
warns without failing the delist, but leaves the last live ticker (stale
open interest and state) being served until the key is rewritten or
deleted.
If the run fails partway, rerun the same command —
every partial state resumes: holders are enumerated from live EP3
positions, so already-settled holders are skipped; in-flight journal
rows are resolved against EP3 by cross-id; and a run interrupted between
the EP3 terminate and the final stamp is recognized (EP3
Terminated, is_closing still set) and finishes
the stamp. Two cases stop a rerun for an operator:
NEEDS_MANUAL_RECONCILIATION (ambiguous
EP3 outcome) is never retried automatically: confirm against EP3 that
nothing booked, set the row's status to FAILED, then
rerun.BOOKED but who still
shows a nonzero position (e.g. the closeout legs were rejected
asynchronously, or the position moved after booking) is never re-booked
automatically, on any rerun: review the account's fills against the
journal row, book or correct the residual manually, then rerun.Force mode (--force). If EP3 terminated
the instrument out from under open AX-side positions (Terminated is
one-way, and block trades cannot book in it), the normal path cannot
reach the closeouts. --force books the same
final-settlement prints directly into the AX books (ClickHouse trades
and positions, balances via the transaction engine) under the same
journal, with the holders enumerated from ClickHouse and cross-checked
against the trade ledger first. It needs no is_closing
freeze. Use it only for fixing accidents.
EP3 can also purge a Terminated instrument's record
entirely after some interval (observed on demo: TEST-PERP was
gone from list-instruments --show-deleted and from EP3's
instrument collections about six weeks after termination, while its
trades remained). A force run then finds no EP3 record, which it treats
as confirmation the instrument is dead, and resolves the inputs the
record would have supplied AX-side:
contract_size descriptor
("100 oz per contract" → 100). If the descriptor is absent
or prose-only the run refuses to guess and requires
--multiplier, which must agree with any leading number the
descriptor does carry. Confirm the multiplier against
instruments.yml in the environment's config repo before
acknowledging — realized PnL and the balance moves are
multiplied by it.--price, else the
last settlement print).The dry run prints these values and their provenance in a banner
under the plan. With --actually the run stops for a
[y/N] confirmation showing the total dollars of closeouts
it will book; pass --ep3-record-missing-ok to accept the
values in an unattended run (an unattended run without it fails rather
than hangs). Every journal row minted this way carries the provenance in
final_settlements.reason. Without --force, an
absent EP3 record is a hard error.
Remove from the instrument spec. Remove (or
comment out) the product from instruments.yml in each
environment's config repository so a future
instruments sync doesn't attempt to recreate it. Do
not delete the Postgres instruments
row.
Funding stops automatically.
delisted_at gates the settlement engine's symbol selection.
Confirm on the next funding cycle that the symbol is skipped, and stop
extending its cme_future_roll_schedule rows if applicable
(existing rows remain).
Restart catalog-bound services if required. The
trade and risk engines build their instrument catalogs at startup and
are not on the instruments publication; if any behavior
keys off delisted_at in those services, schedule their
restart. (The order gateway and api-gateway pick up the terminal state
from EP3.)
Update documentation. Remove the contract from
the public contract-specs page, note the delisting in the public
changelog, and keep the instrument ID reserved in
ax_sdk_internal::constants::INSTRUMENT_IDS — IDs are never
reused.
Run the automated invariant suite:
admin-cli delist-verify --symbol <SYM>
This re-asserts the terminal state across Postgres, EP3, and
ClickHouse and prints a PASS/FAIL table: lifecycle stamped
(delisted_at set, is_closing cleared), EP3
Terminated with an empty book, positions flat in EP3 /
current_positions / sum-of-trades, published OI zero, every
final_settlements journal row BOOKED at one
price, every closeout print block-flagged and fee-free with the
settlement account on exactly one side and matching its journal row on
holder/quantity/price, and no funding transactions after
delisted_at. If the delist used
--settlement-account, pass the same identity's AX account
id via --settlement-account-id. The command exits nonzero
on any failure.
An EP3 that has no record of the instrument at all passes the
ep3-state check: EP3 purges Terminated records after some
interval, so absence is the expected terminal state of an older delist,
and the EP3 book and position checks report nothing to check.
The remaining items are manual:
qty × multiplier × (final price − last mark) net of prior
funding, and the settlement account nets to zero.query_account_volumes must
exclude final_settlement trades, or a closeout promotes
large holders' fee tiers for 30 days. Check this dependency before any
delist after the fee engine ships.trades should exclude final_settlement rows;
flag the delisting to whoever owns the N.A.T.E. dashboards.