← All docs
Initial Draft · Trading & Prediction Markets

Crypto.com FCM — Technical Integration Design

An initial design draft for how Swivel would integrate Crypto.com FCM (CDC FCM) prediction markets over FIX — the engineering view: which services we build, how they map onto the existing NestJS monorepo, the data model, the internal API surface, and the runtime flows. It assumes familiarity with trading terms such as instrument, order book, collateral, and settlement.

📝
Initial draft — not a final implementation spec
0 TL;DR for the reviewer
  • 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 own fcm_* 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.
1 Where it fits in the monorepo

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.

graph TB subgraph New["🆕 New code"] GW["apps/backend/apps/<b>fcm-gateway</b><br/>owns FIX sockets (OE + MD)<br/>FIX ⇄ domain translation"] LIB["libs/common/src/<b>fcm</b><br/>DTOs · entities · enums · event contracts"] end subgraph Existing["Existing services"] API["apps/api<br/>REST + Socket.io to web"] ENGINE["apps/engine<br/>bet/wallet orchestration"] WORKER["apps/worker<br/>async jobs · reconciliation"] end subgraph Infra["Shared infra"] MQ[("RabbitMQ<br/>domain events")] REDIS[("Redis<br/>live book + instrument cache")] PG[("Postgres<br/>fcm_* tables")] end subgraph Vendor["Crypto.com FCM"] OE["OE Gateway (FIX)"] MD["MD Gateway (FIX)"] end GW <-->|"FIX 4.3 / TLS"| OE GW <-->|"FIX 4.3 / TLS"| MD GW <--> MQ GW --> REDIS GW --> PG API --> REDIS API --> PG API <--> MQ ENGINE <--> MQ ENGINE --> PG WORKER <--> MQ WORKER --> PG API <-->|"REST + WS"| FE["Web / Admin frontend"] LIB -.shared by.-> GW LIB -.shared by.-> API LIB -.shared by.-> ENGINE

Why a dedicated app and not a module inside api?

  1. Single writer to the FIX session. FIX orders/quotes are pinned to the session that placed them and are sequence-numbered. If two api pods both held the socket we'd corrupt the sequence and duplicate orders. fcm-gateway runs as a single active instance (leader-elected via a Redis lock; standby for failover) so exactly one process owns each session.
  2. Blast-radius isolation. A FIX resend storm or a reconnect loop must not degrade the customer-facing api.
  3. 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).
2 Component architecture inside fcm-gateway
graph LR subgraph fcm-gateway direction TB SESS["<b>SessionManager</b><br/>logon · HMAC signing<br/>heartbeat · resend · COD"] ENC["<b>FIX Codec</b><br/>encode/decode 4.3 + custom tags"] OEH["<b>OrderEntryHandler</b><br/>35=D/G/F/q → 35=8/9/j"] MDH["<b>MarketDataHandler</b><br/>35=V/x/e → 35=W/X/y/f"] BOOK["<b>BookBuilder</b><br/>snapshot + apply incremental"] REF["<b>ReferenceSync</b><br/>security list → fcm_instrument"] TRANS["<b>DomainTranslator</b><br/>FIX ⇄ RabbitMQ events"] end SESS --- ENC ENC --- OEH ENC --- MDH MDH --> BOOK MDH --> REF OEH --> TRANS BOOK --> REDIS[("Redis")] REF --> PG[("Postgres")] TRANS --> MQ[("RabbitMQ")] TRANS --> PG
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.
3 Session lifecycle & resilience

The gateway must survive disconnects without losing or duplicating orders. This is the riskiest part of the integration.

sequenceDiagram autonumber participant GW as fcm-gateway (leader) participant OE as OE Gateway participant MD as MD Gateway Note over GW: acquire Redis leader lock GW->>OE: Logon (35=A) — apiKey + HMAC(sig) + HeartBtInt + COD=Y OE-->>GW: Logon (35=A) ✓ GW->>MD: Logon (35=A) — HMAC(sig, no system_label) MD-->>GW: Logon (35=A) ✓ + TradingSessionStatus (35=h) loop steady state GW-->>OE: Heartbeat / orders OE-->>GW: Heartbeat / Execution Reports end Note over GW,OE: 🔌 socket drops Note over OE: Cancel-on-Disconnect pulls GW's resting OE orders GW->>OE: Logon (35=A) with last known MsgSeqNum OE-->>GW: ResendRequest / Sequence Reset (fill the gap) Note over GW: replay missed 35=8 → rebuild order state<br/>re-request 35=V books, re-sync 35=y

Design rules baked in:

4 Domain model & the predict_mapping_json.json

The vendor gives us two layers of reference data. We normalize both into our own tables so no downstream code ever parses vendor payloads.

4.1 What the vendor provides
  1. predict_mapping_json.json — the catalog taxonomy: 11 sports (CBB, CWBB, CFB, GOLF, MLB, MMA, TENNIS, NBA, NFL, NHL, SOCCER), each with a SPORTS_GROUPING_ID, DISPLAY_NAME, and a PREDICT_CONTRACT_TYPE list (e.g. NBA has 472 contract types: Spread, Total Points, Moneyline, To Go To Overtime, …, each with RecordId / Name / Active). This is a static-ish reference file loaded at deploy/refresh time.
  2. InstrAttribType 99 JSON blob (per live instrument, arrives inside Security 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).
graph TD MAP["predict_mapping_json.json<br/>(static taxonomy)"] --> SPORT["fcm_sport<br/>(11 rows)"] MAP --> CTYPE["fcm_contract_type<br/>(~3,000 rows, Active flag)"] SECLIST["Security List 35=y<br/>+ InstrAttribType 99 JSON<br/>(live from MD gateway)"] --> INSTR["fcm_instrument"] SPORT --> INSTR CTYPE --> INSTR INSTR --> EVENT["fcm_event<br/>(the real match/game)"]
4.2 Proposed tables (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
⚠️
Symbol reuse gotcha

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.

5 Flow A — Instrument & market-data ingestion (read path)

How a tradable market shows up in the Swivel UI.

sequenceDiagram autonumber participant MD as CDC MD Gateway participant GW as fcm-gateway participant REDIS as Redis participant PG as Postgres participant MQ as RabbitMQ participant API as api participant FE as Web GW->>MD: Security List Request (35=x, by Product=102 Event) MD-->>GW: Security List (35=y) × N chunks (reassemble via LastFragment 893) GW->>PG: upsert fcm_instrument (key=SecurityID, parse InstrAttribType 99) GW->>MQ: emit fcm.instrument.listed GW->>MD: Security Status Request (35=e, snapshot+subscribe) MD-->>GW: Security Status (35=f) Open/Closed/Halted/Settled GW->>MD: Market Data Request (35=V, snapshot+subscribe, Bid+Offer, depth) MD-->>GW: Market Data Snapshot (35=W) — full book GW->>REDIS: write book:{securityId} (top + depth) loop on change MD-->>GW: Market Data Incremental (35=X) New/Change/Delete GW->>REDIS: apply delta → book:{securityId} GW->>MQ: emit fcm.book.updated (throttled) end FE->>API: GET /fcm/markets (browse) API->>PG: query fcm_instrument + fcm_event API->>REDIS: read book:{securityId} (live price) API-->>FE: markets + live prices Note over API,FE: live price stream via Socket.io<br/>fed by fcm.book.updated

Notes:

6 Flow B — Placing a bet / order (write path)

A Swivel user "buys YES at 63". This crosses the wallet, the engine, and the FIX session.

sequenceDiagram autonumber participant FE as Web participant API as api participant ENG as engine (wallet) participant MQ as RabbitMQ participant GW as fcm-gateway participant OE as CDC OE Gateway participant PG as Postgres FE->>API: POST /fcm/orders {securityId, side:BUY, qty:100, price:63, tif:GTC} API->>API: validate tick size, market open, user KYC/limits API->>ENG: reserve collateral (qty × price = $6,300) ENG->>PG: lock funds (wallet ledger: PENDING) ENG-->>API: reservation OK (else 402) API->>PG: insert fcm_order (ClOrdID, status=PENDING_NEW) API->>MQ: command fcm.order.submit GW->>OE: New Order Single (35=D)<br/>ClOrdID, Symbol, Side, Qty, Price, TIF, Parties block alt gateway/exchange rejects OE-->>GW: Business Message Reject (35=j) / ExecReport 150=8 GW->>MQ: emit fcm.order.rejected API->>ENG: release reserved collateral API-->>FE: order rejected (reason) else accepted OE-->>GW: Execution Report (35=8) 150=0 New GW->>PG: update fcm_order (OrderID, status=NEW) GW->>MQ: emit fcm.order.accepted OE-->>GW: Execution Report (35=8) 150=F Trade (39=1/2) GW->>PG: update fill (LeavesQty, CumQty, AvgPx) GW->>MQ: emit fcm.order.filled API-->>FE: live order status (WS) end

Key engineering points:

7 Flow C — Settlement & reconciliation (money truth)

Settlement is asynchronous and exchange-authoritative. We never decide an outcome; we react to Position Reports and settle the wallet from them.

sequenceDiagram autonumber participant CDC as CDC (MD + OE) participant GW as fcm-gateway participant MQ as RabbitMQ participant WORK as worker participant ENG as engine (wallet) participant PG as Postgres Note over CDC: instrument expires CDC-->>GW: Security Status (35=f) 50=Settled (+ SettlPrice, symbol freed) GW->>MQ: emit fcm.instrument.settled CDC-->>GW: Position Report (35=AP) SettlPrice, PosQtyStatus=10 Settled GW->>PG: update fcm_position (settled) GW->>MQ: emit fcm.position.settled WORK->>WORK: consume fcm.position.settled WORK->>ENG: finalize collateral → payout or forfeit ENG->>PG: wallet ledger: PENDING → SETTLED (credit/debit) Note over WORK: periodic reconciliation loop every ~5 min GW->>CDC: Account Summary Request (35=UA) CDC-->>GW: Account Summary (35=CQ) CASH_BALANCE (poll — no push) WORK->>GW: Request For Position (35=AN) GW-->>WORK: Position Report (35=AP) (source of truth) WORK->>PG: reconcile fcm_position vs internal ledger, alert on drift end
8 Internal API surface (what the frontend consumes)

The frontend never sees FIX. It sees a normal Swivel REST + Socket.io API served by api.

8.1 REST (representative)
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
8.2 Socket.io channels (push)
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.*
8.3 Event contracts (RabbitMQ — the real integration seam)

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
9 Order state machine (owned by 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.

stateDiagram-v2 [*] --> PendingNew: POST /fcm/orders (collateral reserved) PendingNew --> New: 150=0 accepted PendingNew --> Rejected: 150=8 / 35=j (release collateral) New --> PartiallyFilled: 150=F (39=1) New --> Filled: 150=F (39=2) PartiallyFilled --> Filled: 150=F (39=2) New --> Replaced: 150=5 amend (new ClOrdID) PartiallyFilled --> Replaced: 150=5 New --> Cancelled: 150=4 (user / exchange / wash / IOC-FOK) PartiallyFilled --> Cancelled: 150=4 (release remaining) New --> Suspended: 150=9 stop resting Suspended --> New: 150=L triggered Filled --> Busted: 150=H (BUST_ offsetting order) Rejected --> [*] Cancelled --> [*] Filled --> [*] Busted --> [*]

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.

10 Cross-cutting concerns
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).
11 Open items — get from CDC FCM before build

Framed as blockers:

  1. 📄 "CDC FCM Appendix – Prediction Markets" — the JSON schema inside InstrAttribType 99. Blocks fcm_event/fcm_instrument modeling and the whole read path. (Highest priority.)
  2. API key/secret provisioning, IP whitelist, key scope (trading vs read-only). Blocks any connection.
  3. UAT/sandbox endpoint + test instruments. Blocks all integration testing.
  4. Membership model → which Parties block case (individual / entity / FCM client / on-behalf) we send on every order. Blocks the write path.
  5. Product scope for launch — Events/Politics only, or also FX/commodities. Bounds the Security List filter and the taxonomy load.

Internal decisions to nail down (PM-specific):

12 Phased delivery
graph LR P0["<b>P0 Connectivity</b><br/>FIX engine · logon/HMAC<br/>heartbeat · UAT session up"] --> P1 P1["<b>P1 Read path</b><br/>ReferenceSync · BookBuilder<br/>markets + live prices in UI (no trading)"] --> P2 P2["<b>P2 Write path</b><br/>order submit/amend/cancel<br/>collateral reserve · state machine"] --> P3 P3["<b>P3 Settlement</b><br/>position reports · wallet finalize<br/>void/tie · reconciliation"] --> P4 P4["<b>P4 Hardening</b><br/>failover · backpressure<br/>observability · load test (K6)"]

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.