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:57 — POST /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-24 — ValidationPipe 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-35 — remove 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-782 — deposit 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-104 — getBalance 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-181 → ledger.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.
- Client → api-gateway (REST). JWT verified by
auth.guard.ts, DTO validated by globalValidationPipe(main.ts:18-24), throttler enforced (app.module.ts:24-40), audit log written byAuditModulemiddleware. - api-gateway → order-router (NATS RPC
orders.submit).orders/orders.service.ts:57makes the call with the resolveduserId. Default 5s timeout; failure surfaces to the client as a 5xx. - order-router risk-check.
risk-checker.ts:106runs in process: market config lookup → quantity bounds → notional → fat-finger (limit only) → slippage guard (market only) → fail-open balance check vialedger.balances.getRPC. - order-router → matching engine (gRPC
placeOrder). Internal scaled-integer price and qty,reservePriceset for BUY orders so the engine can lock balance. - 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. - order-router → NATS pub
orders.createdororders.rejected. Downstream subscribers receive the canonical state change. - (later) matching engine → order-router (WebSocket trade event). The engine fans out trade events as they happen.
- order-router → NATS pub
trades.executed.<SYM>andtrades.executed. Per-market for UI fan-out; consolidated for ledger settlement (see 08-event-bus.md). - ledger-service → Postgres (settlement transaction). Six legs in one atomic
db.$transaction. - 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.confirmed → ledger.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.