- CDC FCM is not a REST API. It's a FIX 4.3 / FIXT.1.1 socket protocol with two long-lived sessions (Order Entry + Market Data), HMAC-signed logon, and a stateful, sequence-numbered message stream. That constraint drives every design decision below.
- We introduce one new NestJS app —
fcm-gateway— whose only job is to own the FIX sockets, translate FIX ⇄ domain events, and never expose FIX to the rest of the platform. - Everything else (
api,engine,worker, frontend) talks to it through RabbitMQ events, Redis (live book/instrument cache), Postgres (durable order/position/instrument state), and a thin REST + Socket.io surface for the web app. - The prediction-market domain (sports → contract types) comes from the vendor's
predict_mapping_json.json+ the per-instrument JSON blob (InstrAttribType 99). We normalize that into our ownfcm_*tables so the frontend never parses vendor JSON. - Money is fully collateralized: no leverage, max loss known upfront. This maps cleanly onto our existing wallet/ledger — a bet is a locked collateral amount — but settlement is asynchronous and exchange-driven, so the ledger must be reconciled from Position Reports, not assumed at order time.
The existing backend (apps/backend) is a NestJS monorepo with four deployables —
api, admin, engine, worker — sharing
libs/common. We add a fifth app and one shared lib.
Why a dedicated app and not a module inside api?
- Single writer to the FIX session. FIX orders/quotes are pinned to the session
that placed them and are sequence-numbered. If two
apipods both held the socket we'd corrupt the sequence and duplicate orders.fcm-gatewayruns as a single active instance (leader-elected via a Redis lock; standby for failover) so exactly one process owns each session. - Blast-radius isolation. A FIX resend storm or a reconnect loop must not degrade the
customer-facing
api. - Protocol containment. No FIX tag numbers leak past this boundary. The rest of the
platform sees clean domain events (
fcm.order.filled,fcm.instrument.settled).
fcm-gateway| Component | Responsibility |
|---|---|
| SessionManager | Two FIX sessions (OE, MD). Logon with API key + HMAC-SHA256 signature, heartbeat
> 5s, resend on gap, per-session Cancel-on-Disconnect. Leader lock. |
| FIX Codec | Serialize/parse FIXT.1.1 / FIX 5.0SP2 messages incl. custom tags
(10300–10302, account-summary 20004/1699/20005). Use a proven
engine, don't hand-roll. |
| OrderEntryHandler | Build New Order Single (35=D), Cancel/Replace (35=G),
Cancel (35=F), Mass Cancel (35=q); consume
Execution Report (35=8), Cancel Reject (35=9),
Business Message Reject (35=j). Owns the order state machine +
ClOrdID↔OrderID map. |
| MarketDataHandler | Request/consume Security List, Security Status, Market Data snapshot/incremental. |
| BookBuilder | Seed local order book from 35=W, apply 35=X New/Change/Delete
deltas, treat empty deltas as heartbeats. Writes top-of-book + depth to Redis. |
| ReferenceSync | Turn Security List (35=y) + the InstrAttribType 99 JSON into
normalized fcm_instrument rows; honor Security Status (35=f) settled
notifications to free reused symbols. |
| DomainTranslator | The anti-corruption layer: FIX → fcm.* RabbitMQ events and back. The only
component the rest of the platform depends on. |
The gateway must survive disconnects without losing or duplicating orders. This is the riskiest part of the integration.
Design rules baked in:
- HMAC signing is per-logon and differs by flow.
signature = HEX(HMAC_SHA256(secret, "public/auth" + MsgSeqNum + apiKey + [system_labelONEEX] + nonce)). Thesystem_labelONEEXfragment is present for OE/drop-copy, omitted for MD. Keep the secret in the platform secret store (never in Postgres/Redis). - Cancel-on-Disconnect (COD) is ON for OE. This is a feature: a gateway crash
auto-cancels our resting orders, so we never have unmonitored live risk. On reconnect we rebuild from
Execution Reports + a
Request For Position (35=AN)truth check, we do not blindly re-place. - Sequence-number persistence. Inbound/outbound
MsgSeqNumper session are persisted (Redis + periodic Postgres checkpoint) so a restart resumes the FIX session instead of forcing a reset. - Standby instance holds the lock-wait; on leader death it acquires the lock, logs on fresh, and reconciles. Target failover < heartbeat-timeout window.
predict_mapping_json.jsonThe vendor gives us two layers of reference data. We normalize both into our own tables so no downstream code ever parses vendor payloads.
predict_mapping_json.json— the catalog taxonomy: 11 sports (CBB, CWBB, CFB, GOLF, MLB, MMA, TENNIS, NBA, NFL, NHL, SOCCER), each with aSPORTS_GROUPING_ID,DISPLAY_NAME, and aPREDICT_CONTRACT_TYPElist (e.g. NBA has 472 contract types: Spread, Total Points, Moneyline, To Go To Overtime, …, each withRecordId / Name / Active). This is a static-ish reference file loaded at deploy/refresh time.InstrAttribType 99JSON blob (per live instrument, arrives insideSecurity List/Definition) — the event-specific payload: which real match, which teams, the strike, the resolution rule. Its schema lives in the "CDC FCM Appendix – Prediction Markets" document (must be requested from CDC FCM — see §11).
libs/common/src/fcm entities)| Table | Purpose | Notable columns |
|---|---|---|
fcm_sport |
Loaded from mapping JSON | sports_grouping_id, code, display_name |
fcm_contract_type |
Loaded from mapping JSON | record_id, sport_id, name, active |
fcm_event |
The real-world match/game | underlying, product_code (102=Event/111=Politics…),
starts_at, home, away, status |
fcm_instrument |
One tradable contract | security_id (48) (natural key, not symbol),
symbol (55), sport_id, contract_type_id,
event_id, payout_type (Fixed/Var), instr_type
(OPT/FUT/BKT), strike, tick_size, active_window,
status, attrib_json (raw blob) |
fcm_order |
Our order ledger | cl_ord_id (11) (we assign), order_id (37) (exchange),
user_id, security_id, side, qty,
price, tif, ord_status, leaves_qty,
cum_qty, avg_px |
fcm_position |
Per user per instrument | user_id, security_id, long_qty, short_qty,
settl_price, pos_status |
fcm_trade |
Fills (from 35=AE/35=8) |
exec_id, order_id, last_px, last_qty,
aggressor_side, trade_report_type |
A Symbol (55) is recycled after an instrument settles. Every FK and cache key uses
SecurityID (48) + active window, never the raw symbol string. ReferenceSync
marks the old fcm_instrument settled on
Security Status 35=f (50=Settled) before any new instrument can claim the symbol.
How a tradable market shows up in the Swivel UI.
Notes:
- Book subscriptions are two-sided and per-instrument. We subscribe on demand for markets a user is viewing (plus a hot set) to bound the number of MD subscriptions.
- Empty
35=X~every 30s = heartbeat, not a change — used to confirm subscription health, never re-subscribe on it. - Auto-cleanup: book + status subs are dropped by the exchange once an instrument
settles;
ReferenceSyncreacts to the settled status rather than polling. fcm.book.updatedis throttled/coalesced in the translator (e.g. max 4–10 Hz per instrument) so we don't flood Socket.io.
A Swivel user "buys YES at 63". This crosses the wallet, the engine, and the FIX session.
Key engineering points:
- Collateral is reserved before the FIX order goes out, released on
reject/cancel, and only finalized at settlement. Because fills are fully collateralized (max
loss =
qty × pricefor a buy,qty × (100 − price)for a sell), the reserve amount is deterministic and known upfront — this maps onto our existingbet-wallet-transactionledger cleanly. ClOrdIDis ours, unique per session;OrderIDis the exchange's.fcm-gatewayowns the mapping. Amends chain viaOrigClOrdID (41).- Parties block identifies member/executor/position-owner. Which of the four cases (individual / entity / FCM client / on-behalf) applies depends on our membership model with CDC — an integration decision to confirm (§11).
- The order state machine lives in
fcm-gateway, driven byExecType (150)+OrdStatus (39). Exchange-initiated cancels (day roll, wash-trade prevention, unfilled IOC/FOK, book→settlement) arrive with noOrigClOrdID— the translator must classify these and emitfcm.order.cancelled{reason}so the wallet releases collateral. - Trade Bust (
150=H) is not a reversal in place — CDC opens aBUST_-prefixed offsetting order. The translator treats it as a new order + a compensating ledger entry, never mutating the original fill.
Settlement is asynchronous and exchange-authoritative. We never decide an outcome; we react to Position Reports and settle the wallet from them.
- Settlement payout math per contract type (Binary $100/$0, Future linear, Bracket
in-range) is computed from
SettlPrice.qty × settlPrice/100is the gross return; the difference vs reserved collateral is the user's P/L. - Void / tie / cancelled outcomes (
EVENT_TIE,EVENT_CANCELED,SettlPriceDeterminationMethod 2451=9 Manual) surface inSecurity Status (35=f)text → we refund reserved collateral rather than settle a P/L. The worker's settlement handler must branch on these. - Account Summary is poll-only (no push) — a ~5 min poll plus a
Request For Positioncross-check is our drift alarm. The internal ledger is authoritative for the user; the exchange balance is authoritative for the house account — reconciliation guards the gap.
The frontend never sees FIX. It sees a normal Swivel REST + Socket.io API served by api.
| Method & path | Purpose | Backed by |
|---|---|---|
GET /fcm/sports |
List sports + active contract types | fcm_sport / fcm_contract_type |
GET /fcm/markets?sport=NBA&status=open |
Browse tradable instruments + event metadata | fcm_instrument + fcm_event |
GET /fcm/markets/:securityId/book |
Current order book (top or depth) | Redis book:{securityId} |
POST /fcm/orders |
Place order (buy/sell) | → fcm.order.submit command |
PATCH /fcm/orders/:clOrdId |
Amend price / remaining qty | → 35=G |
DELETE /fcm/orders/:clOrdId |
Cancel | → 35=F |
GET /fcm/orders?status=open |
User's working/filled orders | fcm_order |
GET /fcm/positions |
Open + settled positions | fcm_position |
GET /fcm/wallet |
Collateral balance + reserved | wallet ledger |
| Event | Payload | Source |
|---|---|---|
fcm:book:{securityId} |
throttled top-of-book / depth diff | fcm.book.updated |
fcm:order:{userId} |
order state transitions | fcm.order.* |
fcm:position:{userId} |
fills + settlement | fcm.position.settled |
fcm:market:{securityId} |
open / halt / settled status | fcm.instrument.* |
These typed events live in libs/common/src/fcm and are the contract between
fcm-gateway and everyone else:
Commands (→ gateway): fcm.order.submit · fcm.order.amend · fcm.order.cancel · fcm.order.massCancel
Events (← gateway): fcm.instrument.listed · fcm.instrument.settled
fcm.book.updated
fcm.order.accepted · fcm.order.rejected · fcm.order.filled
fcm.order.cancelled · fcm.order.replaced · fcm.order.busted
fcm.position.settled
fcm-gateway)Mirrors the ExecType/OrdStatus cases from the FIX order spec, but this is the
authoritative internal state that drives wallet releases and UI.
Each terminal/partial transition emits exactly one RabbitMQ event and (where
money moves) one idempotent wallet ledger entry keyed by ExecID to survive FIX resends.
| Concern | Approach |
|---|---|
| Idempotency | Every inbound FIX message carries ExecID/MsgSeqNum; ledger + event
emission are keyed on them so resends and reconnect-replays are no-ops. |
| Ordering | Per-user and per-instrument order preserved by RabbitMQ routing keys + single-writer gateway. |
| Backpressure | 35=X book deltas are the highest-volume stream; coalesced per instrument before
hitting Redis/WS. Only subscribed/hot instruments get books. |
| Secrets | API key/secret in platform secret manager; injected into fcm-gateway only.
Trading vs read-only key scope confirmed with CDC. IP whitelisting on the FIX endpoint. |
| Observability | FIX session up/down, seqnum gaps, resend count, reject rate, book-staleness, collateral-drift alarm all exported to the existing metrics pipeline. Raw FIX message log (redacted) for dispute forensics. |
| Testing | Contract tests against CDC UAT/sandbox; a recorded-FIX replay harness for the codec + state machine; wallet reconciliation property tests. |
| Env | New config keys via @swivel/global-config; per-environment FIX endpoints (UAT →
prod). |
Framed as blockers:
- 📄 "CDC FCM Appendix – Prediction Markets" — the JSON schema inside
InstrAttribType 99. Blocksfcm_event/fcm_instrumentmodeling and the whole read path. (Highest priority.) - API key/secret provisioning, IP whitelist, key scope (trading vs read-only). Blocks any connection.
- UAT/sandbox endpoint + test instruments. Blocks all integration testing.
- Membership model → which Parties block case (individual / entity / FCM client / on-behalf) we send on every order. Blocks the write path.
- Product scope for launch — Events/Politics only, or also FX/commodities. Bounds the
Security Listfilter and the taxonomy load.
Internal decisions to nail down (PM-specific):
- Mapping of 0–100 price ↔ our odds/probability display and payout math per contract shape.
- Fully-collateralized accounting in our wallet/ledger (reserve → settle → release/forfeit lifecycle).
- Void/tie/cancelled refund handling in the settlement pipeline.
- Leader-election + failover story for the single-writer gateway.
Each phase is independently demoable: P1 ships a read-only prediction-market browser (real value, zero money risk), P2 adds trading behind a flag, P3 closes the money loop, P4 makes it production-grade.