Skip to content

05 — Services Reference

As of 2026-05-28.

Per-service deep dive for the eight TypeScript services that make up the QuantaTrade platform. LOC counts are from services/<name>/src/**.ts excluding *.test.ts and *.spec.ts. Source repo is QuantaTradeAI/platform. The Java matching engine is described from the platform's gRPC-client perspective only — see 01-architecture.md for its own repo.

Ordering reflects role, not LOC: edge first (api-gateway, ws-gateway), trading-core next (order-router, ledger, pms, risk), then the long tail (subscription, venue-adapter).

Service map

%%{init: {'theme':'base','themeVariables':{'background':'#ffffff','primaryColor':'#ddf4ff','primaryBorderColor':'#0969da','primaryTextColor':'#0a0a0a','lineColor':'#1f2328','secondaryColor':'#fff8c5','tertiaryColor':'#dafbe1','clusterBkg':'#f6f8fa','clusterBorder':'#d0d7de'}}}%%
graph LR
    subgraph Edge["Edge"]
        APIGW[api-gateway<br/>3001 REST]
        WSGW[ws-gateway<br/>3002 WS]
    end

    subgraph Core["Trading core"]
        OR[order-router<br/>3006]
        LED[ledger-service<br/>3007]
        PMS[pms-service<br/>3008]
        RISK[risk-service<br/>3009]
    end

    subgraph Tail["Long tail"]
        SUB[subscription-service<br/>3060]
        VBIN["venue-adapter-binance<br/>3010 (PR#6)"]
    end

    subgraph External["External"]
        ME[exchange-core<br/>Java engine]
        BINANCE[Binance WS]
    end

    APIGW -.NATS RPC.-> OR
    APIGW -.NATS RPC.-> LED
    APIGW -.HTTP.-> ME
    APIGW -.NATS RPC.-> CUSTODY[custody-service<br/>NATS only, not in repo]
    WSGW -.NATS sub.-> Core
    OR -.gRPC.-> ME
    OR -.NATS RPC.-> LED
    OR -.NATS pub trades.-> LED
    OR -.NATS pub trades.-> PMS
    OR -.NATS pub trades.-> WSGW
    LED -.Postgres.-> DB[(accounts +<br/>ledger_entries)]
    RISK -.NATS sub trades.-> OR
    SUB -.JWT.-> APIGW
    VBIN -.WS.-> BINANCE
    VBIN -.NATS pub venue trades.-> OR

    style Edge fill:#ddf4ff
    style Core fill:#ddf4ff
    style Tail fill:#ddf4ff
    style External fill:#ddf4ff

At-a-glance

# Service Tech Port LOC Transport in Status
1 api-gateway NestJS 10 3001 ~12.4k REST + JWT 🟢
2 ws-gateway uWebSockets.js 3002 ~1.4k WebSocket + JWT 🟢
3 order-router bare TS + http + NATS 3006 ~2.7k NATS RPC 🟢
4 ledger-service bare TS + http + NATS + Temporal 3007 ~1.0k NATS RPC 🟢
5 pms-service Express 4 3008 ~3.0k REST 🟡
6 risk-service bare TS + http + NATS 3009 ~1.5k NATS RPC + pub/sub 🟡
7 subscription-service Express + Prisma (SQLite) 3060 ~0.6k REST 🟡
8 venue-adapter-binance bare TS + http + NATS 3010 ~0.5k n/a (publisher) 🟡 PR#6

🟡 markers reflect: pms-service has dev-only mock data initialised in start(), risk-service is M8-prep (not on the spot-trade hot path), subscription-service uses its own SQLite DB and has no NATS wiring, venue-adapter-binance is on feat/venue-adapter-binance-scaffold and not on platform/main.


1. api-gateway

Tech: NestJS 10, class-validator, Swagger, Helmet, NestJS Throttler Port: 3001 (REST), Swagger at /docs LOC: ~12,354 Status: 🟢 Entry point: services/api-gateway/src/main.ts:7-47

What it does Public REST surface for the platform. Handles JWT auth, user-facing CRUD (orders, accounts, KYC, payments, staking, settings, fees, api-keys, markets), and proxies engine-bound calls into NATS RPC against the internal services. Global prefix api/v1, HS256 JWT, three throttler tiers (10/sec, 50/10sec, 200/min — app.module.ts:24-40).

Feature modules (registered in services/api-gateway/src/app.module.ts:41-54)

Module Path Endpoints What it does
AuthModule auth/ POST /auth/login, register, refresh, logout, forgot-password, reset-password, verify-email (auth.controller.ts:23-72) Email/password auth, HS256 JWT signing, Redis-backed refresh, password-reset flow. Passport strategy in jwt.strategy.ts.
UsersModule users/ 15 routes under /users/me/* (users.controller.ts:40-156) Profile, anti-phishing code, MFA setup/enable/disable/verify, activity log, saved withdrawal addresses, daily check-in, account deletion.
OrdersModule orders/ POST /orders/estimate, POST /orders, GET /orders, GET /orders/history, GET/DELETE /orders/:id, DELETE /orders (orders.controller.ts:23-88) Order lifecycle facade. orders.service.ts:57,157,226 calls NATS RPC orders.submit / orders.cancel on order-router.
AccountsModule accounts/ GET /accounts/balances, GET /accounts/balances/:asset, GET /accounts/deposit-address, POST /accounts/withdraw/{crypto,fiat}, GET /accounts/transactions, GET /accounts/portfolio-history, GET /accounts/deposit-fiat-info (accounts.controller.ts:23-93) Wallet view. Balance queries currently read engine state via REST; the ledger mirror is the audit copy (see 03-ledger-accounting.md).
MarketsModule markets/ GET /markets, tickers, :symbol, :symbol/{ticker,orderbook,trades,candles} (markets.controller.ts:15-73) Read-only market metadata + L2 snapshots, proxied through engine REST.
KycModule kyc/ GET /kyc/status, POST /kyc/start, basic-info, webhook (kyc.controller.ts:28-67); Sumsub client in kyc/sumsub.client.ts Sumsub integration scaffold. M4 deliverable; signed webhook handler exists, kyc.service.ts:352 has a commented-out NATS publish placeholder.
PaymentsModule payments/ GET /payments/routes, routes/:routeId, POST /payments/session, GET /payments, GET /payments/:id (payments.controller.ts:26-72) Fiat on/off-ramp routing scaffold.
FeesModule fees/ GET /fees/network, network/all, payment, trading (fees.controller.ts:21-49) Fee schedule lookup.
StakingModule staking/ GET /staking/products, products/:id, balances, balances/:id, history, pending, POST /staking/stake, unstake (staking.controller.ts:27-106) M5 staking facade. Backed by Prisma StakingProduct/StakingPosition.
ConversionsModule conversions/ GET /conversions/pairs, POST /conversions/quote, POST /conversions, GET /conversions, GET /conversions/:id (conversions.controller.ts:26-67) Instant-conversion quote/execute.
SettingsModule settings/ GET /settings/countries, provinces, notifications, sso, system (settings.controller.ts:18-52) Static + per-user settings reads.
ApiKeysModule api-keys/ POST /api-keys, GET, DELETE :id, POST :id/rotate (api-keys.controller.ts:27-74). Custom api-key.guard.ts + scope.decorator.ts Programmatic-access keys with scopes.
MetricsModule metrics/ GET /metrics (metrics.controller.ts:12) Prometheus scrape endpoint via metrics.interceptor.ts.
HealthModule health/ GET /health, live, ready (health.controller.ts:6-26) + shutdown.service.ts Liveness + readiness, hardcoded nats: 'ok' is a known thinness.
AuditModule audit/audit.module.ts:19 (no controller) Audit-log writer used as middleware by state-changing modules. Persists to Prisma AuditLog.

Custody is wired in via custody/custody.service.ts as an injected NestJS service rather than a feature module — it makes NATS RPC calls (custody.user.addresses.{assign,get}, custody.address.get) to a custody-service that lives outside the platform repo today.

External dependencies - DB: reads/writes every Prisma model — users, accounts, orders, kycRecord, apiKey, subscriptionPlan, stakingProduct, auditLog, etc. - NATS: publisher-only. RPC client for orders.submit, orders.cancel, custody.user.addresses.assign, custody.user.addresses.get, custody.address.get (orders/orders.service.ts:57,157,226; custody/custody.service.ts:96,135,174). - HTTP: matching-engine REST (/api/v1/markets, balances) via MATCHING_ENGINE_URL. - External: Sumsub for KYC (kyc/sumsub.client.ts).

Internal state Mostly stateless. NATS connection is lazy-initialised in OnModuleInit of orders.service.ts:24-46 and custody.service.ts:49-73 — if the broker is down at boot the service still serves non-NATS routes and re-tries on first call.

Notable code paths - orders/orders.service.ts:57POST /orders becomes NATS RPC orders.submit with { userId, order } payload, 5s default timeout. - custody/custody.service.ts:53-73 — fail-soft NATS connect on module init. Custody calls degrade gracefully if NATS is down. - auth/auth.guard.ts — JWT verification gate, used as the default @UseGuards in every authenticated controller. - main.ts:18-24ValidationPipe with whitelist: true, forbidNonWhitelisted: true, transform: true — every DTO is strict by default.

Known issues / gaps - 🟡 No NATS subscriber: api-gateway only publishes/requests. Inbound events (e.g. KYC status change at kyc.service.ts:352) are TODO comments. - 🟡 RBAC is coarse — user vs admin only. Operator tiers are M4. - 🟡 Health probe reports NATS as always-ok regardless of actual state (health.controller.ts:35). - 🟡 Custody assumes an out-of-repo custody-service is responding on custody.* subjects. There's no such service running in the dev compose today; the scaffold exists in QuantaTradeAI/custody-service (separate repo).


2. ws-gateway

Tech: uWebSockets.js, nats, custom JWT verification, Prometheus metrics Port: 3002 (WS) LOC: ~1,382 Status: 🟢 Entry point: services/ws-gateway/src/main.ts:11-129

What it does Authenticated WebSocket fan-out. Clients connect with ?token=<jwt>, subscribe to channels (ticker:BTC-USD, orderbook:ETH-USD, trades:BTC-USD, plus per-user broadcasts), and receive NATS-sourced events. Uses uWebSockets.js (not ws) for higher throughput. No outbound NATS publishes — pure subscriber.

Channels and matching subjects

Client channel Source subject (NATS) Wildcard Route
ticker:<SYM> marketdata.ticker.* * = symbol main.ts:21-28
orderbook:<SYM> marketdata.orderbook.* * = symbol main.ts:30-37
trades:<SYM> marketdata.trade.* * = symbol main.ts:39-46
(per-user) order.created orders.created.> > = trailing tokens main.ts:49-56
(per-user) balance.updated balances.updated.* * = userId main.ts:58-64

External dependencies - NATS subscribe-only: market data + per-user events. - JWT verify against JWT_SECRET env (auth.ts).

Internal state ConnectionManager (connections.ts) maintains three maps: - connections: Map<WebSocket, Set<channel>> - channels: Map<channel, Set<WebSocket>> — used for broadcastToChannel - userConnections: Map<userId, Set<WebSocket>> — used for sendToUser

All in-memory; no Redis. Reconnect logic is client-side. Idle timeout 120s (main.ts:73), payload limit 16 KiB.

Notable code paths - main.ts:74-85 — upgrade handler reads ?token= and attaches UserData to the connection. Anonymous (no token) is allowed but gets no per-user fan-out. - main.ts:131-156 — client message handler accepts {action: 'subscribe'|'unsubscribe'|'ping', channel?} only. - connections.ts:22-35remove cleans both channel and user-index maps to prevent leaks on disconnect.

Known issues / gaps - 🟡 No NATS-published events for market data are produced today (marketdata.ticker.* / marketdata.orderbook.* have no publisher in any service on main). The trade fan-out subject the engine actually emits is trades.executed.<SYM> via order-router (order-router/src/main.ts:899,906), which ws-gateway does not currently subscribe to — there's a real subject-mismatch gap here. - 🟡 No backpressure / slow-consumer disconnect — relies on uWS defaults. - 🟡 No reconnect/resume token; clients must re-subscribe from scratch.


3. order-router

Tech: Plain Node 20 + @grpc/grpc-js + ws (for engine event stream) + nats + Redis Port: 3006 (HTTP — health + metrics) LOC: ~2,725 Status: 🟢 Entry point: services/order-router/src/main.ts:495-808

What it does Trade hot path. Accepts NATS RPC orders.submit / orders.cancel, runs the pre-trade risk-checker (risk-checker.ts), translates external prices/qtys into the matching engine's scaled integer space, places via gRPC, then translates engine events (TradeEvent, ReduceEvent, RejectEvent) back into platform-shaped NATS events for the ledger, ws-gateway, and DB. Also bridges custody deposit confirmations into the engine and into the ledger.

NATS RPC handlers (declared in main.ts)

Subject Input Output Notes
orders.submit {userId, order: NewOrderRequest} {success, order?, error?} main.ts:531-654. Runs risk-check, generates Redis order ID, places via gRPC, persists fire-and-forget. Queue group order-router.
orders.cancel {userId, orderId, symbol} {success, error?} main.ts:657-722. Redis→Postgres fallback for internal-ID lookup.

NATS subscriptions (pub/sub)

Subject Handler Purpose
custody.deposit.confirmed main.ts:725-782 Sync deposit into matching engine + mirror via ledger.credit (txHash as idempotency key).
custody.withdrawal.requested main.ts:785-807 Reserve funds in matching engine before the chain-level send.

NATS publishes

Subject When Note
risk.check.failed risk-check rejection main.ts:543
orders.created engine ACCEPTED main.ts:621
orders.rejected engine non-ACCEPTED / late reject main.ts:627, 1008
orders.cancelled engine CANCELLED + reduce-complete main.ts:693, 981
orders.filled taker fully filled main.ts:947
trades.executed.<SYM> per-trade fan-out (taker + maker) main.ts:899,906
trades.executed consolidated for ledger settlement main.ts:920-929 — payload shape documented in 08-event-bus.md.
ledger.credit (RPC) deposit mirror main.ts:753

External dependencies - DB: prisma.market, prisma.order, prisma.trade (fire-and-forget writes with one retry on P2003 FK race). - Redis: order-ID mapping (internal numeric ↔ external nanoid) via order-store.ts. - gRPC: matching engine — placeOrder, cancelOrder, deposit, withdraw (matching-engine-client.ts:867 LOC total). - WebSocket: matching engine /ws/events for trade/reduce/reject events. - HTTP: matching engine /api/v1/markets for symbol specs at boot (main.ts:76-122).

Internal state - SYMBOL_SPECS (main.ts:57) — populated at startup from engine REST. Falls back to nothing on failure (process exits at attempt 11). - CURRENCY_SCALES (main.ts:45-53) — hardcoded per-asset decimal scale for deposit/withdrawal conversion. Must stay in sync with engine's AdminController.getCurrencies(). - RiskChecker.lastPrices (in-memory Map<symbol, price>) — populated by every executed trade (main.ts:856), used by both fat-finger and market-order slippage guards. - RiskChecker.marketConfigs — hardcoded per-pair bounds (risk-checker.ts:52-70), 12 pairs across USD/USDT/BRL/BTC quotes.

Notable code paths - main.ts:540-554 — risk-checker is the only mandatory gate before the engine. Failure publishes risk.check.failed and short-circuits. - main.ts:725-782deposit mirror (landed 2026-05-27, see 03-ledger-accounting.md §5): engine deposit succeeds → ledger.credit is awaited but never propagates failure back; if ledger fails the engine wins and an alert fires. - main.ts:827-970 — trade event handler. Resolves external IDs from Redis, computes maker/taker fees via calculateFee (main.ts:259-293, pure BigInt), publishes both per-market and consolidated trade events. - risk-checker.ts:87-104getBalance RPC to ledger.balances.get is fail-open by design. The engine is the ultimate authority. - risk-checker.ts:164-197 — pre-trade slippage guard (PR#4, landed 2026-05-26): rejects market orders without a recent reference price.

Known issues / gaps - 🟡 DB writes for orders and trades are fire-and-forget; FK race between order and trade insert is handled with a single 1s retry (main.ts:480). A reconciliation job would close the gap fully. - 🟡 CURRENCY_SCALES is hardcoded; if the engine adds a currency without a code-side update, deposits will fail with "Unknown currency". - 🟡 No subscription to venue.*.trade.* yet — the slippage reference price comes only from internal fills, so a thin-book symbol has no reference until someone trades. PR#6 lays the groundwork.


4. ledger-service

Tech: Plain Node 20 + nats + Prisma + Temporal worker Port: 3007 (HTTP — health/ready) LOC: ~991 Status: 🟢 Entry point: services/ledger-service/src/main.ts:39-181 Temporal worker: services/ledger-service/src/worker.ts

What it does Double-entry ledger. Every money movement creates a balanced ledger entry against an accounts row, idempotent on transactionId (Prisma UNIQUE). Exposes five NATS RPC handlers plus one pub/sub subscription for trade settlement. A Temporal worker (worker.ts) runs the same settlement logic as a workflow for at-least-once retry semantics — see workflows/trade-settlement.ts.

NATS RPC handlers

Subject Input Output Notes
ledger.balances.get {userId, asset?} {balances: {asset, available, locked}[]} main.ts:59-69
ledger.credit {transactionId, userId, asset, amount, entryType, referenceType, referenceId} {success, newBalance?, error?} main.ts:72-106. On success publishes balances.updated.<userId>.
ledger.debit same shape as credit same main.ts:109-143
ledger.lock {userId, asset, amount, referenceId} {success, error?} main.ts:146-155
ledger.unlock same as lock same main.ts:158-167

NATS subscriptions

Subject Handler Purpose
trades.executed main.ts:170-181ledger.settleTrade (ledger.ts:327) Six-leg trade settlement: debit buyer quote, credit buyer base, debit seller base, credit seller quote, debit both fees. All in one db.$transaction.

External dependencies - DB: prisma.account, prisma.ledgerEntry — the only writer to these tables. - NATS: subscriber + publisher (balances.updated.<userId>). - Temporal: workflow trade-settlement runs the same settlement logic with retry — worker.ts:14-19.

Internal state None. Every operation is a pure DB transaction; idempotency lives in the ledger_entries.transactionId UNIQUE constraint, not in process memory.

Notable code paths - ledger.ts:85-90 — idempotency check at the top of every credit/debit. A duplicate transactionId returns success: true with the existing balance — never an error, never a duplicate write. - ledger.ts:93-132 — credit transaction shape: findFirst account → create if missing → update balance → create ledger entry — all in one Prisma $transaction. - ledger.ts:327 (start of settleTrade) — six legs of a trade reconciled as a single atomic Postgres transaction. - activities/trade-settlement-activities.ts:54 — Temporal activity publishes trades.settled after successful settlement (informational, no current subscriber).

Known issues / gaps - 🟢 The 2026-05-27 logger fix (platform PR #2, aed137fd) wired logger.logError / logger.logTrade properly. No outstanding ledger bugs in this service. - 🟡 lock and unlock operations on the platform side are not actually invoked anywhere on main — the matching engine reserves balance internally on order placement, and the ledger only sees the settled trade. The RPC handlers exist for future symmetry (e.g. once order placement debits available immediately for visibility). - 🟡 No periodic reconciliation between engine UserRegistry and accounts table — see 03-ledger-accounting.md.


5. pms-service

Tech: Express 4, Helmet, CORS, Prisma (for transfers/positions tables) Port: 3008 LOC: ~3,019 Status: 🟡 Entry point: services/pms-service/src/index.ts:17-137

What it does Position management — spot positions, deposits/withdrawals, P&L (running, realised, FIFO), per-participant risk summaries, and BVI (broker-of-record) reporting hooks. Today it is read-mostly: writes happen through admin/back-office paths, and the realtime price feed is what keeps unrealised P&L fresh. The hot trading path does not depend on pms-service — it is a downstream analytics layer.

REST endpoints (mounted at /api/pms in index.ts:50, route file routes/pms.ts:38-790)

Method Path Purpose
GET /positions List all positions
GET /positions/:participantId/:symbol Single-position read
POST /positions/:participantId/:symbol/close Close a position
GET /positions/snapshots Historic snapshots
GET /transfers List deposits/withdrawals/adjustments
GET/POST /transfers/:id, /transfers/deposit, /transfers/withdrawal, /transfers/:id/complete, /transfers/:id/cancel, /transfers/adjustment Transfer lifecycle
GET /balances, /balances/:participantId/:assetId Balance view
GET /trades Trade log
GET /pnl/:participantId, /pnl/:participantId/by-symbol Aggregate + per-symbol P&L
GET /risk/:participantId Per-participant risk summary
GET /stats/{participants,summary} Cross-participant analytics
GET /fifo/users, /fifo/users/:userId/currencies, /fifo/:userId/:currency, /fifo/:userId FIFO cost-basis views
POST /fifo/transactions, /fifo/transactions/import Backfill FIFO ledger
POST /bvi/order-completed BVI broker-of-record event ingestion
GET /bvi/exceptions, /bvi/exceptions/:id BVI reconciliation exceptions

28 routes total.

External dependencies - DB: Prisma position, transfer, positionSnapshot, plus FIFO and BVI tables. - HTTP: priceFeedService polls a price source (services/price-feed.ts) — the actual source is configurable.

Internal state - In-memory price cache via priceFeedService (used to mark positions). - Two interval timers (startPeriodicUpdates, index.ts:70-80): - pnlUpdateInterval — mark all positions every config.pnl.updateIntervalMs. - snapshotInterval — take historical snapshots every config.pnl.snapshotIntervalMs.

Notable code paths - index.ts:99-105 — at boot, dev-mode seeds mock data via transferService.initializeMockData() and pnlService.initializeMockData(). This is if (config.nodeEnv === 'development') — it does not run in prod, but the demo flow visible to the client today does pass through this seed. - services/fifo-pnl.ts — FIFO cost-basis engine.

Known issues / gaps - 🟡 Not subscribed to NATS. It does not see trades the moment they happen; downstream consumers either poll /positions or BVI-event POSTs are submitted explicitly. A subscription to trades.executed is the obvious next step but is not on main. - 🟡 Dev-only mock seeding lives in the same file as the production server. Worth disentangling.


6. risk-service

Tech: Plain Node 20 + nats + Prisma + Redis (for mark prices) Port: 3009 (HTTP — health/ready) LOC: ~1,519 Status: 🟡 (M8 prep — not on the spot-trade path) Entry point: services/risk-service/src/main.ts:32-255

What it does Margin & liquidation engine. Despite the generic name, this is not the pre-trade risk-checker (that lives inside order-router and is called the "risk-checker" in code, see 04-risk-controls.md). risk-service exists for M8 derivatives: it monitors margin levels, calculates liquidation thresholds (105% triggers, 150% warning — main.ts:14-15), accrues interest on margin loans, and publishes warning/liquidation events.

NATS RPC handlers

Subject Input Output Notes
risk.margin.check {userId, symbol, side, quantity, leverage} {approved, reason?, liquidationPrice?, requiredMargin?, availableMargin?} main.ts:132-148
risk.balance.check {userId, asset, amount} {sufficient, available} main.ts:151-169. Reads spot balance directly from DB via getUserSpotBalance.
risk.margin.level {userId, marginAccountId?} {marginLevel, equity, debt, isAtRisk} main.ts:172-216

NATS subscriptions

Subject Handler Purpose
margin.position.updated main.ts:55-85 Per-position liquidation check vs mark price from Redis.
trades.executed main.ts:88-125 Recompute margin level for the account that owns the trade; emit margin.liquidation.warning if at risk.

NATS publishes - margin.liquidation.warning (main.ts:113, 293) - margin.position.closed, notifications.send, orders.liquidation, commands.order.cancel (all via liquidation-engine.ts).

External dependencies - DB: prisma.marginAccount, marginPosition, marginLoan. - Redis: mark prices for liquidation checks (redis.ts:182 LOC).

Internal state - Two interval timers in main() (main.ts:223-230): - margin monitoring every 5s — scans all active margin accounts, triggers liquidation for any below 105%. - interest accrual every 60s — adds per-minute interest on every active loan.

Notable code paths - liquidation-engine.ts:59-110 — full liquidation sequence: cancel open orders → publish margin.position.closed → notify user → emit orders.liquidation. - margin-engine.ts:calculateMarginLevel — equity / debt ratio with positions valued at current mark.

Known issues / gaps - 🔴 Not exercised on the current spot path — marginMode is set on the order DTO but no margin RPC is called by order-router today. The service runs but is effectively idle. - 🟡 Mark prices come from Redis but no producer exists on main for that key namespace; PR#6's venue adapter would be a natural feed once the order-router wires it through.


7. subscription-service

Tech: Express 4 + Prisma (its own SQLite DB) + jsonwebtoken Port: 3060 (note: not in the 3xxx-platform range of the others — this is the legacy default) LOC: ~590 Status: 🟡 (standalone, separate DB) Entry point: services/subscription-service/src/main.ts:1-477

What it does M4-tier subscription billing. Three plan tiers, USDC or QTRA payments (with a 10–20% QTRA discount tied to staking balance — main.ts:50-53, 12), proration on upgrades, /api/entitlements for downstream gating. Uses its own Prisma client + SQLite (lib/prisma.ts) — it does not share the main @quantatrade/db package.

REST endpoints

Method Path Auth Notes
GET /api/plans public List active plans
GET /api/plans/:id public Plan detail
GET /api/subscription JWT Current subscription
POST /api/subscribe JWT New subscription (409 if active exists)
POST /api/cancel JWT Cancel (continues until period end)
POST /api/upgrade JWT Upgrade plan, prorate refund vs new price
GET /api/payments JWT Payment history
GET /api/entitlements JWT Feature/limits gate for downstream services
GET /health public Static OK

External dependencies - DB: standalone Prisma over SQLite (separate file from the main platform Postgres). - JWT: HS256, expects JWT_SECRET env (main.ts:9), reads sub and optional stakingBalance claim.

Internal state None.

Notable code paths - main.ts:50-53 — 20% QTRA discount when stakingBalance >= 100,000 QTRA (constant, not configurable). - main.ts:300-321 — proration: currentDailyRate * remainingDays credited against new plan price. - main.ts:382-462 — entitlements endpoint is the gating contract downstream services should call.

Known issues / gaps - 🟡 Separate SQLite DB — no cross-service consistency on user IDs. Subscription state is keyed by JWT sub; if the main user table moves, sync is manual. - 🟡 JWT_SECRET default "quantatrade-dev-secret" (main.ts:9) — must be overridden in prod. - 🟡 No NATS wiring — entitlement changes are not broadcast. - 🟡 No webhook for actual on-chain QTRA payment; status is set to "completed" directly on POST (main.ts:200). This is a billing scaffold, not a payment processor.


8. venue-adapter-binance

Tech: Plain Node 20 + ws + nats Port: 3010 (HTTP — health only) LOC: ~456 Status: 🟡 — PR#6 in flight, on branch feat/venue-adapter-binance-scaffold (not merged to platform/main as of 2026-05-28) Entry point: services/venue-adapter-binance/src/main.ts:56-148 (path is in the quantatrade-binance worktree)

What it does Read-only Binance market-data adapter. Subscribes to Binance combined book-ticker and trade streams for a static list of symbols (symbol-mapper.ts:BINANCE_MARKETS), reconciles against /exchangeInfo at boot (main.ts:33-54), and republishes onto NATS under venue.binance.*. Author's note in main.ts:128-132: trading client (order placement, signed REST, user-data streams) is intentionally out of scope for this PR.

NATS publishes

Subject Payload Purpose
venue.binance.bookTicker.<QT-SYMBOL> {venue, symbol, bidPrice, bidQty, askPrice, askQty, timestamp} (main.ts:101-109) Top-of-book reference.
venue.binance.trade.<QT-SYMBOL> {venue, symbol, price, quantity, tradeId, timestamp, buyerIsMaker} (main.ts:114-122) Per-trade ticks.

<QT-SYMBOL> is the platform format (e.g. BTC-USDT), mapped from Binance (BTCUSDT) by symbol-mapper.ts:binanceToQt.

External dependencies - WebSocket: Binance combined streams (wss://stream.binance.com/stream) via binance-client.ts. - HTTP: https://api.binance.com/api/v3/exchangeInfo for sanity-check at boot. - NATS: publisher only.

Internal state BinanceMarketDataStream (binance-client.ts) holds the WS connection and reconnect logic. No durable state.

Notable code paths - main.ts:75-90 — fail-soft on exchangeInfo: if the REST call fails, falls back to the static BINANCE_MARKETS list rather than blocking startup. Read-only market data is non-critical. - main.ts:97-124 — single-callback fan-out into NATS; symbols unknown to the platform are dropped silently. - symbol-mapper.ts:67 LOC — single source of truth for Binance↔QT symbol mapping.

Known issues / gaps - 🟡 No subscriber yet. The order-router does not call riskChecker.setVenuePrice(...) from venue.*.trade.* on main — that wiring is a follow-up. Internal-wins precedence logic is also a follow-up: once both sources feed lastPrices, the risk-checker needs to prefer internal trades over Binance. - 🟡 No persistence (JetStream not used). A reconnect gap loses ticks. Acceptable for a reference-price feed; not acceptable for an audit trail. - 🟡 PR#6 is stacked on PR#4 (slippage guard). Merging PR#6 first would break the dependency.

Service-to-service call graph

%%{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 Client
    participant APIGW as api-gateway
    participant OR as order-router
    participant ME as exchange-core
    participant LED as ledger-service
    participant WSGW as ws-gateway

    Client->>APIGW: POST /api/v1/orders
    APIGW->>OR: NATS RPC orders.submit (5s timeout)
    OR->>OR: RiskChecker.check()
    OR->>LED: NATS RPC ledger.balances.get (fail-open)
    LED-->>OR: {balances: [...]}
    OR->>ME: gRPC placeOrder
    ME-->>OR: ACCEPTED
    OR-->>APIGW: {success: true, order}
    APIGW-->>Client: 201 Created

    Note over ME: ... matching happens ...
    ME->>OR: WS trade event
    OR->>OR: publish trades.executed (consolidated)
    OR->>WSGW: publish trades.executed.<SYM>
    OR->>LED: NATS sub trades.executed
    LED->>LED: settleTrade() in single Postgres tx
    LED->>WSGW: publish balances.updated.<userId>

Cross-cutting notes

Concern All services
Logging @quantatrade/logger createContextLogger({service, level, nodeEnv}). Two custom methods: logError(msg, err, meta), logTrade(meta). Bug fixed 2026-05-27 — see 03-ledger-accounting.md.
Metrics @quantatrade/metrics exposes getPrometheusMetrics(); each service mounts it at /metrics.
Healthchecks /health and /ready on the HTTP port of every service. Standard 200/503 shape. Implementations vary in depth — api-gateway has the thinnest readiness, order-router the deepest (order-router/src/health.ts).
Config process.env direct in every service. api-gateway uses NestJS ConfigModule + Zod-style validation in config/config.validation.ts.
Auth between services None at the message-bus layer today. NATS RPC subjects are open within the cluster. Engine REST uses x-api-key / x-api-secret / x-participant-type shared-secret.

Common request flow: place a limit order

The flow below stitches together what each service in this doc actually does on the trade hot path. It runs on every POST /api/v1/orders and is the most-exercised cross-service interaction in the platform today.

  1. Client → api-gateway (REST). JWT verified by auth.guard.ts, DTO validated by global ValidationPipe (main.ts:18-24), throttler enforced (app.module.ts:24-40), audit log written by AuditModule middleware.
  2. api-gateway → order-router (NATS RPC orders.submit). orders/orders.service.ts:57 makes the call with the resolved userId. Default 5s timeout; failure surfaces to the client as a 5xx.
  3. order-router risk-check. risk-checker.ts:106 runs in process: market config lookup → quantity bounds → notional → fat-finger (limit only) → slippage guard (market only) → fail-open balance check via ledger.balances.get RPC.
  4. order-router → matching engine (gRPC placeOrder). Internal scaled-integer price and qty, reservePrice set for BUY orders so the engine can lock balance.
  5. order-router → Postgres (fire-and-forget). persistOrderToDb (main.ts:329) writes the order row; a single 2s retry covers transient failures. The Redis order-ID mapping remains regardless.
  6. order-router → NATS pub orders.created or orders.rejected. Downstream subscribers receive the canonical state change.
  7. (later) matching engine → order-router (WebSocket trade event). The engine fans out trade events as they happen.
  8. order-router → NATS pub trades.executed.<SYM> and trades.executed. Per-market for UI fan-out; consolidated for ledger settlement (see 08-event-bus.md).
  9. ledger-service → Postgres (settlement transaction). Six legs in one atomic db.$transaction.
  10. ledger-service → NATS pub balances.updated.<userId>. ws-gateway picks it up and pushes to the connected client.

Total wall-clock from API call to engine ACK is typically <50ms in production; settlement completes asynchronously within a few hundred ms.

Deployment shape

All eight services run as docker containers on a single EC2 t3.2xlarge today (see 01-architecture.md#deployment-topology). Each has its own image, builds from the platform monorepo with npm workspaces resolving @quantatrade/* packages. The Dockerfile per service uses a multi-stage build: install workspace deps → build TS → copy dist/ into a slim runtime image.

Process model:

Service Replicas (today) Restart policy Notes
api-gateway 1 unless-stopped NestJS prefers cluster mode at scale; not enabled.
ws-gateway 1 unless-stopped Stateful in-memory connection table — horizontal scaling needs Redis-backed channel map.
order-router 1 unless-stopped Safe to scale via queue group order-router; Redis order-ID map is shared.
ledger-service 1 + 1 worker unless-stopped Main process + Temporal worker (worker.ts); both queue group ledger-service.
pms-service 1 unless-stopped In-process intervals must not run on >1 replica today.
risk-service 1 unless-stopped Same — periodic margin scan is not leader-elected.
subscription-service 1 unless-stopped SQLite file lives in the container — backup story is unclear.
venue-adapter-binance 0 (PR#6) n/a Not yet deployed.

The container names follow quantatrade-<service>-1 per docker-compose convention.

Shared-package usage by service

Most services pull from the same packages/ directory. The matrix below shows which services import which package — useful when reasoning about blast radius for a shared-package change (e.g. "do I need to redeploy ledger-service if I bump @quantatrade/common?").

Package api-gateway ws-gateway order-router ledger-service pms-service risk-service subscription-service venue-adapter-binance
@quantatrade/common yes no yes yes no yes no no
@quantatrade/db yes no yes yes own client yes own client no
@quantatrade/logger yes no yes yes own logger no no yes
@quantatrade/metrics yes yes yes no no no no no
@quantatrade/nats yes yes yes yes no yes no yes
@quantatrade/temporal no no no yes (worker) no no no no
@quantatrade/types yes yes yes yes own types yes no no

Two outliers worth calling out: - pms-service uses its own logger (src/utils/logger.ts), its own Prisma client setup, and its own types under src/types/. It's the most-isolated platform service. - subscription-service is even more isolated: own Prisma over SQLite, own JWT verification, own types inline. It's effectively a sidecar.

Common failure modes and where they surface

Cross-service errors that have actually been seen in production. Each one is grounded in a code path; the goal here is "where do I look first" when something breaks.

Symptom First check Code path
User reports "deposit not credited" Engine UserRegistry vs accounts.available for that user. If engine has it but Postgres doesn't, the custody.deposit.confirmedledger.credit step failed. order-router/main.ts:725-782
POST /orders returns 500 timeout Is nats container up? Is order-router subscribed on the order-router queue group? subscriber.subscribeService('orders.submit', ..., { queue: 'order-router' }). order-router/main.ts:531-654
Market order rejected with "no recent trade price" RiskChecker's lastPrices map is empty for that symbol — happens on cold-start or after restart. risk-checker.ts:164-171
Order clientOrderId accepted twice No UNIQUE constraint on (userId, clientOrderId) today. gap in Prisma schema
Ledger entry missing for a trade that did execute ledger-service was down when trades.executed was published. Core NATS does not buffer. future-fix: JetStream durable consumer
circuit breaker open for subject: ledger.credit Five failures in 30s tripped the publisher-side breaker. packages/nats/src/publisher.ts:14-69
WS client never receives balance.updated after deposit balances.updated.<userId> published by ledger; ws-gateway subscribes on balances.updated.*. Check user is connected with their userId, not anonymous. ledger-service/main.ts:97; ws-gateway/main.ts:58-64
risk-service margin warnings firing spuriously 5s periodic scan loop running on a stale Redis mark price. risk-service/main.ts:223-225

Service-by-service environment surface

Minimal env vars each service actually reads (process.env.* direct, no wrapper for most). For the full prod values see the EC2 .env per host — never copy between hosts.

Service Required env Optional env (with defaults)
api-gateway JWT_SECRET, DATABASE_URL, NATS_URL, REDIS_URL, SUMSUB_TOKEN (if KYC enabled) PORT=3001, CORS_ORIGINS, throttler tuning
ws-gateway JWT_SECRET, NATS_URL PORT=3002
order-router NATS_URL, MATCHING_ENGINE_URL, MATCHING_ENGINE_WS_URL, MATCHING_ENGINE_GRPC_URL, SERVICE_API_KEY, SERVICE_API_SECRET, REDIS_URL, DATABASE_URL PORT=3006
ledger-service NATS_URL, DATABASE_URL, TEMPORAL_ADDRESS (worker) PORT=3007
pms-service DATABASE_URL, price-feed config PORT=3008, NODE_ENV (gates mock seed)
risk-service NATS_URL, DATABASE_URL, REDIS_URL PORT=3009
subscription-service JWT_SECRET, DATABASE_URL (sqlite path) PORT=3060
venue-adapter-binance NATS_URL PORT=3010

When to update this doc

  • A service gains a new NATS subject → add it to that service's table and to 08-event-bus.md.
  • A controller gains a new route → add it to the api-gateway endpoint table for the module.
  • A service is added (e.g. compliance-service, treasury-service) → new section at the bottom; bump the count in 01-architecture.md.
  • Status flips between 🟢/🟡/🔴 → update both the section header and the at-a-glance table.