Skip to content

02 — Trading system

As of 2026-05-28. Reflects main of QuantaTradeAI/platform plus the slippage-guard branch (PR#4), which is the canonical post-merge shape.

TL;DR

Orders enter via api-gateway (NestJS REST), traverse order-router (NATS request-reply) for pre-trade risk + persistence, and reach the Java matching engine over gRPC. The engine is the source of truth for the book, balances and trade events; everything downstream is a mirror. Fills come back to order-router over a single WebSocket event channel, get republished onto NATS as trades.executed, and fan out to ledger-service (settlement) and ws-gateway (push to subscribed clients). PMS currently serves mocked numbers — it is not yet wired to the trade stream. Stop-orders and external venue execution are not built.

Service map for the trading hot path

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
graph LR
    Client[Trading UI]
    APIGW[api-gateway<br/>NestJS]
    OR[order-router]
    ME[exchange-core<br/>Java]
    LED[ledger-service]
    WS[ws-gateway]
    PMS[pms-service<br/>mock data]

    Client -- "REST<br/>POST /orders" --> APIGW
    APIGW -- "NATS req-reply<br/>orders.submit" --> OR
    OR -- "gRPC<br/>OrderService.PlaceOrder" --> ME
    ME -- "WS event<br/>trade/reduce/reject" --> OR
    OR -- "NATS<br/>trades.executed" --> LED
    OR -- "NATS<br/>trades.executed.SYMBOL" --> WS
    WS -- "WSS push" --> Client

    PMS -.->|"not subscribed (mock)"| OR

1. End-to-end order lifecycle

Sequence (REST → fill → settlement → client push):

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
    autonumber
    participant C as Client
    participant G as api-gateway
    participant R as order-router
    participant E as exchange-core (gRPC)
    participant L as ledger-service
    participant W as ws-gateway

    C->>G: POST /orders (CreateOrderDto)
    G->>G: validateOrder() — orders.service.ts:295
    G->>R: NATS req `orders.submit`<br/>{userId, order} — orders.service.ts:57
    R->>R: RiskChecker.check() — main.ts:541
    R->>R: orderStore.getNextOrderId() (Redis INCR)<br/>main.ts:558
    R->>R: setOrderMapping(internalId, externalId)<br/>main.ts:562
    R->>E: gRPC OrderService.PlaceOrder<br/>matching-engine-client.ts:302
    E-->>R: OrderResponse{status: ACCEPTED|REJECTED}<br/>main.ts:568
    R->>R: persistOrderToDb() (fire-and-forget) — main.ts:600
    R->>R: publish `orders.created` — main.ts:621
    R-->>G: {success: true, order}
    G-->>C: 201 OrderResponseDto

    Note over E: order matches against resting book
    E-->>R: TradeEvent via WS — matching-engine-client.ts:88
    R->>R: handleTradeEvent() — main.ts:827
    R->>R: setLastPrice() (updates RiskChecker)<br/>main.ts:856
    R->>R: publish `trades.executed.SYMBOL`<br/>main.ts:899, :906
    R->>L: publish `trades.executed` (settlement)<br/>main.ts:920
    L->>L: ledger.settleTrade() — ledger-service/main.ts:180
    R->>W: (already published above)
    W-->>C: WSS push `trade`<br/>ws-gateway/main.ts:39

Key implementation points:

  • Two order IDs. Internal numeric (Redis INCR, order-router:order-id-counter) for the engine; external nanoid for the REST surface. Mapping lives in Redis (order-store.ts:64) and is the hot path for translating engine events back to client IDs.
  • DB persistence is fire-and-forget. persistOrderToDb returns immediately; failures are logged but don't fail the REST response (order-router/src/main.ts:600). The engine is authoritative; the orders table is a mirror used for history queries.
  • Cancel on REST is two-phase. OrdersService.cancelOrder first reads the order from Postgres to verify ownership (orders.service.ts:143) — only then does it call back into order-router over NATS. If a user has no DB record (e.g. brand-new order, not yet persisted), the cancel will 404 even if the engine has the order.

2. Order types

The REST DTO (api-gateway/src/orders/orders.dto.ts:29) and shared types (packages/types/src/index.ts:54) both declare:

type OrderType = 'market' | 'limit' | 'stop_limit' | 'stop_market';

But the pre-trade risk checker rejects stop orders early:

// services/order-router/src/risk-checker.ts:113-116
if (order.type === 'stop_limit' || order.type === 'stop_market') {
  return { passed: false, reason: 'Stop orders are not yet supported' };
}
Type Status Notes
limit 🟢 Mapped to engine GTC (main.ts:1027)
market 🟢 Mapped to engine IOC (main.ts:1027). Now slippage-guarded — see 04-risk-controls.md.
stop_limit 🔴 Schema accepts, risk-checker rejects
stop_market 🔴 Schema accepts, risk-checker rejects

The matching engine's gRPC enum exposes four types: GTC / IOC / FOK / FOK_BUDGET (order-router/src/proto/exchange.proto:190-196). The TS layer only ever sends GTC or IOC (main.ts:1029-1032); FOK and FOK_BUDGET are reachable from the engine API but not surfaced through api-gateway.

3. Time-in-force semantics

The REST DTO accepts timeInForce: 'gtc' | 'ioc' | 'fok' (orders.dto.ts:47). The Prisma mapping helper mapTimeInForceToDb (main.ts:1087) is used when persisting to Postgres.

Important gap: timeInForce is stored to the orders row but does not influence what gets sent to the engine. The engine type is derived purely from order.type via mapOrderType (main.ts:1027-1036):

  • type: 'limit' → engine GTC (regardless of timeInForce)
  • type: 'market' → engine IOC (regardless of timeInForce)

So a client that POSTs {type: 'limit', timeInForce: 'fok'} will get a GTC resting order. This needs the order-router to actually combine type + timeInForce before mapping — tracked as a follow-up; the slippage PR's review noted it.

4. Cancel paths

There are two distinct cancel paths:

4a. User cancel (through api-gateway)

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
    participant C as Client
    participant G as api-gateway
    participant R as order-router
    participant E as exchange-core

    C->>G: DELETE /orders/:orderId
    G->>G: db.order.findFirst (ownership check)<br/>orders.service.ts:143
    G->>R: NATS req `orders.cancel`<br/>{userId, orderId, symbol}<br/>orders.service.ts:156
    R->>R: orderStore.getInternalOrderId() (Redis)<br/>main.ts:667
    Note over R: Falls back to DB lookup if Redis miss<br/>(restart safety, main.ts:670-679)
    R->>E: gRPC CancelOrder
    E-->>R: OrderResponse{CANCELLED}
    R->>R: publish `orders.cancelled` — main.ts:693
    R-->>G: {success: true}
    G->>G: re-fetch updated row — orders.service.ts:171
    G-->>C: 200 OrderResponseDto

4b. Admin force-cancel (direct to engine, bypasses order-router)

Admin panel calls AdminService.CancelOrderAdmin over gRPC-web directly. The order-router is not in the loop; the engine emits a ReduceEvent (matching-engine-client.ts:110) with isOrderCompleted: true, which order-router picks up and republishes as orders.cancelled so downstream subscribers stay consistent (main.ts:980-996).

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
    participant A as admin-panel
    participant E as exchange-core
    participant R as order-router (passive)
    participant L as ledger-service
    participant W as ws-gateway

    A->>E: gRPC-web AdminService.CancelOrderAdmin<br/>(see admin-panel#2)
    E->>E: cancel order in-memory, free reserved balance
    E-->>R: WS ReduceEvent (isOrderCompleted=true)
    R->>R: handleReduceEvent() — main.ts:972
    R->>L: publish `orders.cancelled` (via NATS)
    R->>W: publish `orders.cancelled`
    W-->>A: optional update

The admin panel never has the user's auth context, so this is the only way to cancel someone else's order. It's the kill-switch for operations until a proper halt-market RPC exists (see 04-risk-controls.md §7).

5. Trade event fan-out

The engine emits one TradeEvent per fill (matching-engine-client.ts:88-108). order-router is the sole subscriber to the engine WebSocket and is responsible for republishing onto NATS.

handleTradeEvent (main.ts:827) publishes to three subjects in sequence:

# Subject Subscriber Purpose
1 trades.executed.<SYMBOL> (taker) ws-gateway Per-market trade tape for UI charts
2 trades.executed.<SYMBOL> (maker) ws-gateway Same channel, maker side
3 trades.executed (base) ledger-service Settlement: debit/credit both legs
%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
sequenceDiagram
    participant E as exchange-core
    participant R as order-router
    participant W as ws-gateway
    participant L as ledger-service
    participant DB as Postgres

    E->>R: TradeEvent (WS)
    R->>R: handleTradeEvent — main.ts:827
    R->>W: publish trades.executed.BTC-USD (taker)
    R->>W: publish trades.executed.BTC-USD (maker)
    R->>L: publish trades.executed<br/>{id, buyerId, sellerId, price, qty, fees}
    L->>L: ledger.settleTrade(trade)
    L->>DB: debit buyer quote, credit buyer base, debit seller base, credit seller quote
    R->>DB: persistTradeToDb(taker), persistTradeToDb(maker) (fire-and-forget)
    R->>W: publish orders.filled (if completion)

Deduplication. id on the consolidated trades.executed payload is takerTrade.id (main.ts:921), generated by generateTradeId() per call — there is no idempotency key derived from the engine's (takerOrderId, makerOrderId, timestamp) tuple. If order-router is restarted while a WS message is in flight and the engine re-delivers, ledger.settleTrade will double-settle. The engine→router connection is ws (not JetStream); there is no replay buffer. This is a known gap, called out in 03-ledger-accounting.md.

The "engine is source of truth, ledger is mirror" rule. order-router does not reconcile against the engine — it trusts every event it sees. The check that catches drift is the deposit-sync flow (PR#1, see 03-ledger-accounting.md), not the trade flow.

6. Fee tiers

Trading fee tiers live in services/api-gateway/src/fees/fees.service.ts:37-45 as a hardcoded table:

Tier 30-day volume (USD) Maker Taker
Starter 0 – 10K 0.20% 0.30%
Bronze 10K – 50K 0.16% 0.26%
Silver 50K – 100K 0.14% 0.24%
Gold 100K – 500K 0.12% 0.22%
Platinum 500K – 1M 0.10% 0.18%
Diamond 1M – 5M 0.08% 0.16%
VIP 5M+ 0.05% 0.10%

🟡 Not yet enforced. getUserTradingFees returns the tier matched against a hardcoded userVolume30d = 25000 (fees.service.ts:116). The engine's actual fee charge is taken from the per-market takerFeeBps / makerFeeBps fields it returns from /api/v1/markets (order-router/src/main.ts:101-105), not from this tier table. Wiring the user's 30-day rolling volume into the fee schedule is unscheduled.

The engine-side fees applied to a fill come from the TradeEvent itself (takerFeeAmount, takerFeeBps, makerFeeAmount, makerFeeBps, feeCurrencymatching-engine-client.ts:101-107). order-router recomputes them locally in calculateFee for sanity but the engine's number is authoritative.

7. Market-maker support

The matching engine (forked exchange-core2) supports LIMIT_POST_ONLY and maker rebate fee schedules at the Java level. However, the gRPC proto exposed to the TS platform does not surface POST_ONLY as an OrderType — the enum is only GTC / IOC / FOK / FOK_BUDGET (order-router/src/proto/exchange.proto:190-196).

Consequences:

  • 🔴 There is no way for a TS client to submit a post-only order today. Adding it requires a proto change + Java OrderServiceImpl change.
  • 🟡 Maker rebate per-fill is plumbed end-to-end: the engine sends makerIsRebate: boolean and a makerFeeBps field that can be negative (matching-engine-client.ts:106). order-router propagates these into the trade record, and ledger-service.settleTrade would credit a rebate if makerFee is negative — but the fee-tier table above has no rebate tiers, so the per-market rebate has to be configured engine-side.

For an institutional market-maker programme this gap is the highest-impact one in the trading layer.

8. Markets registered

order-router discovers tradable symbols at startup by hitting the matching engine's REST endpoint:

// services/order-router/src/main.ts:76-122
async function loadSymbolSpecs(): Promise<void> {
  const url = `${MATCHING_ENGINE_BASE_URL}/api/v1/markets`;
  // ... 10 attempts with 2s backoff ...
  SYMBOL_SPECS[m.symbol] = {
    baseScaleK: BigInt(m.baseScaleK),
    quoteScaleK: BigInt(m.quoteScaleK),
    takerFeeBps: BigInt(m.takerFee),
    makerFeeBps: BigInt(m.makerFee),
  };
}

So the engine is the source of truth for "what's tradable". If a symbol is added engine-side, order-router must be restarted (or extended with a periodic refresh) to pick it up. 🟡 No hot-reload.

The pre-trade risk checker holds an independent, hardcoded list of 12 markets with deviation/notional thresholds (risk-checker.ts:52-70). These two lists can drift — if the engine adds a market that the risk checker doesn't know about, RiskChecker.check() returns Market <X> not found and rejects the order before it reaches the engine. Documented as a follow-up.

The 12 markets currently configured in risk-checker.ts:

USD pairs:   BTC-USD, ETH-USD, SOL-USD
BRL pairs:   BTC-BRL, ETH-BRL, SOL-BRL
Cross:       ETH-BTC
USDT pairs:  BTC-USDT, ETH-USDT, SOL-USDT
Stable:      USDT-USD, USDC-USD

9. Order state machine

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
stateDiagram-v2
    [*] --> pending: REST received (validated)
    pending --> open: engine ACCEPTED (main.ts:588)
    pending --> rejected: risk-checker rejected (main.ts:543)<br/>or engine rejected
    open --> partially_filled: TradeEvent w/ isTakerCompleted=false<br/>(main.ts:962)
    open --> filled: TradeEvent w/ isTakerCompleted=true<br/>(main.ts:946)
    partially_filled --> filled: subsequent TradeEvent completes
    open --> cancelled: user cancel OR ReduceEvent w/ isOrderCompleted (main.ts:980)
    partially_filled --> cancelled: same
    cancelled --> [*]
    filled --> [*]
    rejected --> [*]

Statuses are persisted to Postgres via updateOrderStatusInDb (fire-and-forget — same caveat as create).

10. Estimate endpoint

POST /orders/estimate (orders.controller.ts:30) returns an unsigned quote: subtotal, fee, total, spend-asset, receive-asset. Logic is in OrdersService.estimateOrder (orders.service.ts:243).

🟡 Mock-priced. The estimate uses a hardcoded mockPrices map (orders.service.ts:9-16) covering only 6 symbols. Fees default to a static maker/taker (0.10% / 0.20% from MAKER_FEE_PERCENT/TAKER_FEE_PERCENT constants at line 18-19), not the tier table. This needs the market-data feed wired in before it's safe to show users.

11. Known gaps in M1 hot path

  • 🟡 PMS UI on mock data. services/pms-service/src/index.ts:103 calls transferService.initializeMockData() and pnlService.initializeMockData() in development mode; in production these are skipped but the service does not subscribe to trades.executed at all (grep services/pms-service/src/ → no NATS subscribers). Positions are not derived from real trades yet. The matching engine's UserRegistry is the only true position view today.
  • 🟡 External venue execution not wired. Binance market-data adapter scaffold landed (PR#6) — read-only public stream only. No signed-endpoint trading client. No smart-order-router.
  • 🟡 Kill-switch partial. AdminService.CancelOrderAdmin exists and is now wired via admin-panel#2. HaltMarket / PauseSymbol / ResumeSymbol do not exist in the proto — see 04-risk-controls.md §7.
  • 🟡 Trade event idempotency. No replay-safe key. A router restart mid-WS-stream can re-settle a fill — see §5 above.
  • 🟡 TimeInForce ignored. timeInForce field on CreateOrderDto is persisted but not forwarded to the engine — see §3 above.

Cross-references