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.
persistOrderToDbreturns immediately; failures are logged but don't fail the REST response (order-router/src/main.ts:600). The engine is authoritative; theorderstable is a mirror used for history queries. - Cancel on REST is two-phase.
OrdersService.cancelOrderfirst reads the order from Postgres to verify ownership (orders.service.ts:143) — only then does it call back intoorder-routerover 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:
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'→ engineGTC(regardless oftimeInForce)type: 'market'→ engineIOC(regardless oftimeInForce)
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, feeCurrency — matching-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
OrderServiceImplchange. - 🟡 Maker rebate per-fill is plumbed end-to-end: the engine sends
makerIsRebate: booleanand amakerFeeBpsfield that can be negative (matching-engine-client.ts:106).order-routerpropagates these into the trade record, andledger-service.settleTradewould credit a rebate ifmakerFeeis 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:103callstransferService.initializeMockData()andpnlService.initializeMockData()in development mode; in production these are skipped but the service does not subscribe totrades.executedat all (grepservices/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.CancelOrderAdminexists and is now wired via admin-panel#2.HaltMarket/PauseSymbol/ResumeSymboldo not exist in the proto — see04-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.
timeInForcefield onCreateOrderDtois persisted but not forwarded to the engine — see §3 above.
Cross-references¶
- 01 — System architecture — service map and transports
- 03 — Ledger & accounting —
settleTrade, deposit sync, idempotency caveats - 04 — Risk controls — what gates an order before it reaches the engine
- 05 — Services reference — per-service deep dive
- 06 — Admin panel —
AdminServicegRPC-web surface docs/milestone-1-status.md— current M1 gate status