- Current State
- Target Architecture
- New Services Inventory
- Overall Changes Summary
- Phase 0 — Groundwork: Shared Package + Env Config
- Phase 1 — Redis Streams + Per-Backend Response Pub/Sub
- Phase 2 — Database Persistence
- Phase 2A — Liquidation Orders via Orderbook
- Phase 2B — Snapshot & Replay (WAL + State Checkpointing)
- Phase 3 — Decouple Binance: Mark Price Poller Service
- Phase 3A — Funding Rate
- Phase 4 — Engine Event Publishing (Fills, Positions, Orderbook)
- Phase 5 — WebSocket Server (User Events + Market Data)
- Phase 6 — Poller Service (DB Snapshots → Market WS)
- Phase 7 — Backend WebSocket Route (HTTP Upgrade)
- Phase 8 — Cleanup, Wire-up & Testing
- Complete File Change Inventory
- Environment Variables Reference
- Architecture Diagrams (ASCII)
┌─────────────┐ HTTP ┌────────────┐ LPUSH ┌──────────────┐
│ Client │────────────▶│ Backend │─────────▶│ Redis │
│ (curl/HTTP) │ │ (Express) │ │ LPUSH/BRPOP │
└─────────────┘ └─────┬──────┘ └──────┬───────┘
│ BRPOP response │ BRPOP incoming
│ ▼
│ ┌──────────────┐
└──────────────────│ Engine │
(response queue) │ (in-memory) │
└──────┬───────┘
│ direct call
┌──────▼───────┐
│ BinanceWS │
│ (inside eng) │
└──────────────┘
Problems:
- LPUSH/BRPOP response queues cannot scale horizontally — multiple backend instances compete for responses, pulling messages destined for other instances
- BinancePriceWs lives inside the engine, tight coupling
- No real-time push to clients (no WebSocket)
- All state is in-memory; restart = total data loss
- No rate limit, no persistence, no user event stream
┌──────────────────────────────────────────────────────┐
┌──────────┐ │ BACKEND CLUSTER │
│ Website │──HTTP/WS──▶ │ backend-1 backend-2 backend-N │
│ Mobile │ │ Each instance has unique BACKEND_INSTANCE_ID │
└──────────┘ └─────────┬──────────────────────┬────────────────────┘
│ XADD (stream) │ SUB backend:{id}:responses
▼ │
┌─────────────────┐ │
│ Redis │ │
│ │ PUB backend:{id}:responses
│ Streams: │◀──────────────┤
│ SPOT_stream │ │
│ PERPS_stream │ PUB user:{uid}:events
│ mark_price_ │ PUB market:{sym}:orderbook
│ stream │ │
│ │ │
│ Pub/Sub: │ │
│ backend:* │ │
│ user:*:events │ │
│ market:*: │ │
│ orderbook │ │
└────────┬────────┘ │
│ XREADGROUP │
▼ │
┌─────────────────┐ │
│ Engine │───────────────┘
│ (single, but │ publish events after every match
│ reads 3 │
│ streams) │──────────────▶ Postgres DB
└─────────────────┘ write orders/trades/positions
▲
┌────────┴────────┐
│ mark_price_ │
│ stream (XADD) │
└────────▲────────┘
│
┌────────┴────────┐
│ Mark Price │
│ Poller │──▶ wss://stream.binance.com
└─────────────────┘
┌─────────────────┐
│ WS Server │◀─── Redis SUB user:*:events
│ │◀─── Redis SUB market:*:orderbook
│ /user │ (individual + global events)
│ /market │
└────────┬────────┘
│ push
┌────────▼────────┐
│ Website/Mobile │
│ (live fills, │
│ positions, │
│ orderbook) │
└─────────────────┘
┌─────────────────┐
│ Poller │◀─── Postgres DB (orderbook snapshots)
│ │────▶ Redis PUB market:{sym}:orderbook
└─────────────────┘ OR disk snapshot files
| Service | Location | Role |
|---|---|---|
shared |
shared/ |
Shared TypeScript types, queue names, event schemas |
mark-price-poller |
mark-price-poller/ |
Binance WS → Redis mark_price_stream XADD |
ws-server |
ws-server/ |
WebSocket server: user events + market data channels |
poller |
poller/ |
Reads orderbook snapshots from DB → publishes to market WS channel |
funding-timer |
funding-timer/ |
Emits funding settlement triggers every 8h to funding_rate_stream |
Modified existing services:
backend/— Replace broker LPUSH/BRPOP with Streams + per-instance pub/sub subengine/— Replace BRPOP with XREADGROUP on 5 streams; publish events; write to DB; WAL; liquidation orders; funding settlement
| Layer | Current | Target | Why |
|---|---|---|---|
| Request transport | LPUSH |
XADD Redis Stream |
Persistent, replayable, consumer groups |
| Response transport | BRPOP shared queue |
PUBLISH backend:{id}:responses |
Each backend reads only its own responses |
| Mark price feed | Direct in engine (BinancePriceWs) |
mark-price-poller → mark_price_stream |
Decouple, can swap price source later |
| State persistence | In-memory only | Postgres (orders, trades, positions, balances) | Survive restarts, audit log |
| Client real-time | HTTP response only | WebSocket server + Redis pub/sub | Live fills, position, orderbook |
| Orderbook snapshots | None | Poller reads DB → files / WS push | Historical depth + real-time feed |
| Binance coupling | binance-ws.ts inside engine |
Standalone mark-price-poller service |
Independent scaling and replacement |
| Backend scaling | Single instance only | N instances, each identified by BACKEND_INSTANCE_ID |
Horizontal scaling |
| Liquidation | Direct margin seize, no orderbook | Liquidation order placed in orderbook; surplus/deficit to insurance fund; ADL fallback | Real exchange behavior, fair price discovery |
| Engine restart recovery | Total state loss | WAL stream + periodic snapshots; replay delta on restart | Sub-second recovery; no data loss |
| Funding rate | None | Periodic settlement via funding-timer + engine settlement loop |
Perps price anchored to spot/index |
Create a shared/ package with queue names, event type enums, and message contracts so
all services stay in sync when message shapes change.
shared/
src/
queue-names.ts ← all Redis key/stream/channel name builders
events.ts ← typed event payloads (FillEvent, PositionEvent, etc.)
messages.ts ← move engine request/response types here (from engine/src/types/messages.ts)
package.json
tsconfig.json
shared/src/queue-names.ts:
// Single source of truth for all Redis key names.
// Import in backend, engine, ws-server, poller.
export const SPOT_INCOMING_STREAM = "SPOT_incoming_stream";
export const PERPS_INCOMING_STREAM = "PERPS_incoming_stream";
export const MARK_PRICE_STREAM = "mark_price_stream";
export const ENGINE_CONSUMER_GROUP = "engine-group";
export const ENGINE_CONSUMER_NAME = "engine-consumer-1";
// Per-backend response channel (pub/sub)
export const backendResponseChannel = (backendId: string) =>
`backend:${backendId}:responses`;
// Per-user event channel (pub/sub)
export const userEventsChannel = (userId: string) =>
`user:${userId}:events`;
// Per-market orderbook channel (pub/sub)
export const orderbookChannel = (market: string) =>
`market:${market}:orderbook`;shared/src/events.ts:
export type EventType =
| "fill"
| "order_update"
| "position_update"
| "liquidation"
| "orderbook_snapshot"
| "mark_price";
export interface FillEvent {
type: "fill";
orderId: string;
market: string;
side: "buy" | "sell";
price: number;
quantity: number;
fee: number;
timestamp: number;
}
export interface PositionUpdateEvent {
type: "position_update";
userId: string;
market: string;
side: "long" | "short";
quantity: number;
entryPrice: number;
liquidationPrice: number;
unrealizedPnl: number;
margin: number;
leverage: number;
}
export interface LiquidationEvent {
type: "liquidation";
userId: string;
market: string;
side: "long" | "short";
markPrice: number;
liquidationPrice: number;
marginLost: number;
}
export interface OrderbookSnapshotEvent {
type: "orderbook_snapshot";
market: string;
bids: [string, string][]; // [price, qty]
asks: [string, string][];
timestamp: number;
}
export interface MarkPriceEvent {
type: "mark_price";
market: string;
price: number;
timestamp: number;
}
export type UserEvent = FillEvent | PositionUpdateEvent | LiquidationEvent;
export type MarketEvent = OrderbookSnapshotEvent | MarkPriceEvent;// backend/package.json, engine/package.json, ws-server/package.json, poller/package.json
"dependencies": {
"shared": "file:../shared",
...
}Each backend process needs a unique identity so the engine can route responses back.
# backend/.env
BACKEND_INSTANCE_ID=backend-1 # change per deployment instance / pod name
In Kubernetes / Docker Compose this is typically the pod name or container hostname:
BACKEND_INSTANCE_ID=${HOSTNAME}
Replace the LPUSH/BRPOP pattern with Redis Streams for requests and Redis Pub/Sub for responses. After this phase, multiple backend instances can run without stealing each other's responses.
| Property | LPUSH/BRPOP | Redis Streams |
|---|---|---|
| Competing consumers | Only one consumer wins | Consumer groups — engine reads each message once |
| Message loss on crash | Gone if engine crashes mid-process | Message stays pending in stream until XACK |
| Replay / audit | Not possible | XRANGE lets you replay history |
| Multi-stream fan-in | BRPOP on N queues — one at a time, order-dependent | XREADGROUP can read from multiple streams in one call |
| Back-pressure visibility | Queue depth with LLEN | Stream lag with XPENDING |
File: engine/src/index.ts — replace the main() loop:
// Before: BRPOP
const result = await redis.brpop(SPOT_IQ, PERPS_IQ, 0);
// After: XREADGROUP from two streams simultaneously
async function ensureConsumerGroups() {
for (const stream of [SPOT_INCOMING_STREAM, PERPS_INCOMING_STREAM, MARK_PRICE_STREAM]) {
try {
// "0" = start from the beginning if group is new
await redis.xgroup("CREATE", stream, ENGINE_CONSUMER_GROUP, "0", "MKSTREAM");
} catch (e: any) {
if (!e.message.includes("BUSYGROUP")) throw e; // ignore "group already exists"
}
}
}
async function main() {
await ensureConsumerGroups();
console.log("Engine listening on streams...");
while (true) {
try {
// Read from SPOT + PERPS incoming streams in one blocking call
// ">" = only undelivered messages (not pending retries)
const results = await redis.xreadgroup(
"GROUP", ENGINE_CONSUMER_GROUP, ENGINE_CONSUMER_NAME,
"COUNT", "1",
"BLOCK", "0", // block until a message arrives
"STREAMS",
SPOT_INCOMING_STREAM, PERPS_INCOMING_STREAM,
">", ">" // one ">" per stream
);
if (!results) continue;
for (const [streamName, messages] of results) {
for (const [msgId, fields] of messages) {
// fields is an alternating [key, val, key, val, ...] array from ioredis
const raw = fieldsToObject(fields);
const request = JSON.parse(raw.payload) as SpotEngineRequest | PerpsEngineRequest;
let payload: unknown;
try {
if (streamName === SPOT_INCOMING_STREAM) {
payload = handleEngineRequestForSpot(request);
} else {
payload = handleEngineRequestForPerps(request);
}
} catch (err) {
payload = { error: (err as Error).message };
}
// Publish response to the originating backend instance's pub/sub channel
// backendId was embedded in the request by the broker
await redis.publish(
backendResponseChannel(request.backendId),
JSON.stringify({ correlationId: request.correlationId, payload })
);
// Acknowledge the message so it's removed from pending
await redis.xack(streamName, ENGINE_CONSUMER_GROUP, msgId);
}
}
} catch (err) {
console.error("engine loop error:", err);
await new Promise(r => setTimeout(r, 1000));
}
}
}
// ioredis xreadgroup returns fields as flat [k,v,k,v] array
function fieldsToObject(fields: string[]): Record<string, string> {
const obj: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) obj[fields[i]] = fields[i + 1];
return obj;
}Add backendId + streamMsgId to engine message types in shared/src/messages.ts:
export type SpotEngineRequest = {
type: EngineRequestTypes;
data: SpotEngineRequestData;
correlationId: string;
backendId: string; // NEW — which backend instance sent this
responseQueue?: string; // DEPRECATED — remove after full migration
};File: backend/src/utils/broker.ts — full replacement:
import { redis, makeRedisClient } from "./redis.js";
import { v4 as uuidv4 } from "uuid";
import {
SPOT_INCOMING_STREAM, PERPS_INCOMING_STREAM,
backendResponseChannel
} from "shared/src/queue-names.js";
const BACKEND_ID = process.env.BACKEND_INSTANCE_ID!; // must be set, unique per instance
const TIMEOUT_MS = Number(process.env.ENGINE_TIMEOUT_MS) || 30_000;
// Dedicated subscriber connection — must not be shared with XADD connection
const redisSub = makeRedisClient();
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
// Subscribe ONCE at startup to this backend's response channel
export async function initBroker(): Promise<void> {
const channel = backendResponseChannel(BACKEND_ID);
await redisSub.subscribe(channel);
redisSub.on("message", (_chan: string, raw: string) => {
const msg = JSON.parse(raw) as { correlationId: string; payload: unknown };
const handler = pending.get(msg.correlationId);
if (!handler) return; // not ours or already timed out
clearTimeout(handler.timer);
pending.delete(msg.correlationId);
handler.resolve(msg.payload);
});
console.log(`[broker] subscribed to ${channel}`);
}
export async function sendToEngine<TResponse = unknown>(
type: string,
data: unknown,
queue: "SPOT" | "PERPS"
): Promise<TResponse> {
const correlationId = uuidv4();
const stream = queue === "SPOT" ? SPOT_INCOMING_STREAM : PERPS_INCOMING_STREAM;
return new Promise<TResponse>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(correlationId);
reject(new Error("engine timeout"));
}, TIMEOUT_MS);
pending.set(correlationId, { resolve: resolve as (v: unknown) => void, reject, timer });
// XADD: fields are key-value pairs
// We put the full request as a single JSON field "payload"
redis.xadd(
stream,
"*", // auto-generate message ID
"payload", JSON.stringify({ type, data, correlationId, backendId: BACKEND_ID })
).catch(err => {
clearTimeout(timer);
pending.delete(correlationId);
reject(err);
});
});
}File: backend/src/index.ts — call initBroker() on startup (replace startResponseLoop()):
import { initBroker } from "./utils/broker.js";
// ...
await initBroker(); // subscribe to this instance's pub/sub channel before accepting requests
app.listen(PORT, () => console.log(`backend ${BACKEND_ID} listening on :${PORT}`));When the engine crashes mid-processing, messages remain in the stream's "pending entries list" (PEL) unacknowledged. On startup, process these first:
// engine/src/index.ts — add after ensureConsumerGroups()
async function drainPending() {
for (const stream of [SPOT_INCOMING_STREAM, PERPS_INCOMING_STREAM]) {
// "0" instead of ">" reads pending (previously delivered but unacknowledged) messages
const pending = await redis.xreadgroup(
"GROUP", ENGINE_CONSUMER_GROUP, ENGINE_CONSUMER_NAME,
"COUNT", "100", "STREAMS", stream, "0"
);
if (!pending) continue;
for (const [streamName, messages] of pending) {
for (const [msgId, fields] of messages) {
// re-process or dead-letter if too old
const raw = fieldsToObject(fields);
const idleMs = Date.now() - parseInt(msgId.split("-")[0]);
if (idleMs > 60_000) {
// too old: XACK to remove, log for investigation
console.warn(`[engine] dead-lettering msgId=${msgId}`);
await redis.xack(streamName, ENGINE_CONSUMER_GROUP, msgId);
continue;
}
// re-process normally
const request = JSON.parse(raw.payload);
// ... same processing logic
}
}
}
}- Create
shared/package withqueue-names.tsand updatetsconfig.jsonpaths - Add
BACKEND_INSTANCE_IDenv var to backend.envand deployment configs - Update
engine/src/types/messages.ts→ addbackendIdfield to request types - Replace
engine/src/index.tsBRPOP loop with XREADGROUP loop - Replace
backend/src/utils/broker.tsLPUSH with XADD + pub/sub subscription - Remove
startResponseLoop()call frombackend/src/index.ts, addinitBroker() - Remove
RESPONSE_QUEUEenv var (no longer needed) - Test: send order from backend-1 only backend-1 receives response (simulate with two processes)
- Test: engine crash + restart picks up pending messages from PEL
Write orders, fills, positions, and balance snapshots to Postgres so state survives engine restarts and provides an audit trail.
File: backend/prisma/schema.prisma — add to existing schema:
model User {
id String @id @default(uuid())
username String @unique
password String
orders Order[]
positions Position[]
balances Balance[]
}
model Order {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
market String
side String // "buy" | "sell"
orderType String // "limit" | "market"
price Decimal @db.Decimal(20, 8)
quantity Decimal @db.Decimal(20, 8)
filledQty Decimal @db.Decimal(20, 8) @default(0)
avgFillPrice Decimal? @db.Decimal(20, 8)
status String // "OPEN" | "FILLED" | "PARTIALLY_FILLED" | "CANCELLED" | "PARTIALLY_CANCELLED"
leverage Int? // perps only
reduceOnly Boolean @default(false)
exchange String // "SPOT" | "PERPS"
fills Fill[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Fill {
id String @id @default(uuid())
orderId String
order Order @relation(fields: [orderId], references: [id])
price Decimal @db.Decimal(20, 8)
quantity Decimal @db.Decimal(20, 8)
makerSide String // "buy" | "sell"
createdAt DateTime @default(now())
}
model Position {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
market String
side String // "long" | "short"
quantity Decimal @db.Decimal(20, 8)
entryPrice Decimal @db.Decimal(20, 8)
liquidationPrice Decimal @db.Decimal(20, 8)
margin Decimal @db.Decimal(20, 8)
leverage Int
stopLoss Decimal? @db.Decimal(20, 8)
takeProfit Decimal? @db.Decimal(20, 8)
equity Decimal? @db.Decimal(20, 8)
realizedPnl Decimal @db.Decimal(20, 8) @default(0)
status String // "OPEN" | "CLOSED"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, market, status]) // one open position per user per market
}
model Balance {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
asset String
available Decimal @db.Decimal(20, 8) @default(0)
locked Decimal @db.Decimal(20, 8) @default(0)
updatedAt DateTime @updatedAt
@@unique([userId, asset])
}
model OrderbookSnapshot {
id String @id @default(uuid())
market String
exchange String // "SPOT" | "PERPS"
bids Json // [[price, qty], ...]
asks Json
createdAt DateTime @default(now())
@@index([market, createdAt])
}cd backend
bunx prisma migrate dev --name add_orders_positions_balances
bunx prisma generateThe engine writes to Postgres after every successful match. Two options:
Option A — Direct write (simpler, recommended for single engine): Add Prisma client to engine, write synchronously after each match but without blocking the matching loop (fire-and-forget writes to a local async queue).
// engine/src/db/writer.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// Local in-memory write queue to avoid blocking the matching loop
const writeQueue: (() => Promise<void>)[] = [];
let flushing = false;
export function enqueueWrite(fn: () => Promise<void>) {
writeQueue.push(fn);
if (!flushing) flushWriteQueue();
}
async function flushWriteQueue() {
flushing = true;
while (writeQueue.length > 0) {
const fn = writeQueue.shift()!;
try { await fn(); } catch (e) { console.error("[db-writer] write error:", e); }
}
flushing = false;
}
export function writeOrder(order: /* Order type from shared */) {
enqueueWrite(() =>
prisma.order.upsert({
where: { id: order.id },
update: {
status: order.status,
filledQty: order.filledQuantity.toString(),
avgFillPrice: order.avgPrice?.toString(),
updatedAt: new Date(),
},
create: {
id: order.id,
userId: order.userId,
market: order.market,
side: order.side,
orderType: order.orderType,
price: order.price.toString(),
quantity: order.quantity.toString(),
filledQty: "0",
status: order.status,
exchange: order.exchange,
}
})
);
}
export function writeFill(fill: /* Fill type */) {
enqueueWrite(() =>
prisma.fill.create({ data: { ...fill } })
);
}
export function writePosition(position: /* Position type */) {
enqueueWrite(() =>
prisma.position.upsert({
where: { userId_market_status: { userId: position.userId, market: position.market, status: "OPEN" } },
update: { ...position, updatedAt: new Date() },
create: { ...position }
})
);
}
export function writeBalance(userId: string, asset: string, available: number, locked: number) {
enqueueWrite(() =>
prisma.balance.upsert({
where: { userId_asset: { userId, asset } },
update: { available: available.toString(), locked: locked.toString(), updatedAt: new Date() },
create: { userId, asset, available: available.toString(), locked: locked.toString() }
})
);
}Option B — Event stream (deferred, for later scaling):
Engine publishes write events to db_write_stream, a separate db-writer process consumes
and writes. Keeps engine pure. Add this if engine becomes bottlenecked on DB I/O.
In spot-exchange-store.ts and perps-exchange-store.ts, after every fill and order update:
// After match in createOrder():
for (const fill of fills) {
writeFill(fill);
}
writeOrder(incomingOrder);
writeOrder(matchedOrder);
writeBalance(userId, baseAsset, ...);
writeBalance(userId, quoteAsset, ...);// In perps store, after updatePositionFromFill():
writePosition(updatedPosition);- Update
backend/prisma/schema.prismawith new models - Run
prisma migrate dev - Create
engine/src/db/writer.tswith async write queue - Add DB write calls in
spot-exchange-store.tsafter fills - Add DB write calls in
perps-exchange-store.tsafter fills and position updates - Add DB write calls in
balance-store.tson credit/deduct - Test: place order, restart engine, verify order still queryable from DB via backend API
- Test: DB write errors do not break matching loop
Replace the current direct-margin-seize liquidation with a proper liquidation order placed into the orderbook. This is how real perpetual exchanges (Binance, Bybit, dYdX) work: the liquidation engine becomes a participant in the orderbook, and price discovery is preserved. Surplus fills flow into an insurance fund; deficits draw from it. If the fund runs dry, Auto-Deleveraging (ADL) force-closes the most-profitable opposing positions.
Current (checkAndLiquidate):
mark price crosses liquidation price
→ seize all margin directly
→ close position in memory
→ done
Problems: ignores real market price, unfair to user, no price discovery.
Target:
mark price crosses liquidation price
→ engine creates a LIQUIDATION market order (opposite side, same qty)
→ order enters the normal matching loop (price-time priority)
→ fills happen at real market prices
→ compare fill price vs bankruptcy price:
fill > bankruptcy price → surplus = (fillPrice - bankruptcyPrice) * qty
→ credit insurance fund
fill < bankruptcy price → deficit absorbed by insurance fund
→ if fund exhausted → trigger ADL
→ publish LiquidationEvent to user's pub/sub channel
| Term | Definition |
|---|---|
| Liquidation price | Price at which engine force-closes position. Already computed in engine. |
| Bankruptcy price | Price at which margin = 0. Long: entryPrice × (1 − 1/leverage). Short: entryPrice × (1 + 1/leverage) |
| Insurance fund | Pool funded by liquidation surpluses; absorbs deficits. Tracked as a special system balance. |
| ADL (Auto-Deleveraging) | Last resort: force-close the most profitable opposing positions when insurance fund is empty and no liquidity exists. |
| Liquidation bot | A virtual userId (SYSTEM_LIQUIDATION) used to place liquidation orders. Exempt from margin requirements. |
Step 1 — Add SYSTEM_LIQUIDATION virtual userId to engine constants:
// engine/src/constants.ts
export const LIQUIDATION_BOT_USER_ID = "SYSTEM_LIQUIDATION";
export const INSURANCE_FUND_USER_ID = "SYSTEM_INSURANCE_FUND";Step 2 — Add isLiquidation flag to order types:
// shared/src/messages.ts
export type perpsCreateOrderRequest = baseCreateOrderRequest<{
leverage: number;
isLiquidation?: boolean; // NEW — bypasses margin validation
liquidatedUserId?: string; // NEW — whose position is being liquidated
}>;Step 3 — Refactor checkAndLiquidate in perps-exchange-store.ts:
checkAndLiquidate(market: string, markPrice: number): void {
const bookPositions = this.perpsPosition.get(market);
if (!bookPositions) return;
for (const [userId, position] of bookPositions) {
const shouldLiquidate =
position.side === "long" ? markPrice <= position.liquidationPrice :
position.side === "short" ? markPrice >= position.liquidationPrice : false;
if (!shouldLiquidate) continue;
const bankruptcyPrice = position.side === "long"
? position.entryPrice * (1 - 1 / position.leverage)
: position.entryPrice * (1 + 1 / position.leverage);
// Place a market order on behalf of LIQUIDATION_BOT (opposite side, reduce-only)
const liquidationOrder = this.createPerpsOrder({
userId: LIQUIDATION_BOT_USER_ID,
market,
side: position.side === "long" ? "sell" : "buy",
orderType: "market",
price: markPrice, // reference price only (market order)
quantity: position.quantity,
leverage: 1, // LIQUIDATION_BOT needs no leverage
reduceOnly: true,
isLiquidation: true,
liquidatedUserId: userId,
});
// After fills: compute surplus/deficit vs bankruptcy price
for (const fill of liquidationOrder.fills) {
const surplusPerUnit = position.side === "long"
? fill.price - bankruptcyPrice
: bankruptcyPrice - fill.price;
const surplus = surplusPerUnit * fill.qty;
if (surplus >= 0) {
// Fill was better than bankruptcy — surplus to insurance fund
balanceStore.credit(INSURANCE_FUND_USER_ID, "USDT", surplus);
} else {
// Fill worse than bankruptcy — draw from insurance fund
const fundBalance = balanceStore.getBalance(INSURANCE_FUND_USER_ID)?.get("USDT") ?? 0;
if (fundBalance >= Math.abs(surplus)) {
balanceStore.deductLocked(INSURANCE_FUND_USER_ID, "USDT", Math.abs(surplus));
} else {
// Insurance fund exhausted — trigger ADL for the remaining shortfall
this.triggerADL(market, position.side === "long" ? "short" : "long", Math.abs(surplus) - fundBalance);
}
}
}
// Publish liquidation event to the user
publishUserEvent(userId, {
type: "liquidation",
userId,
market,
side: position.side,
markPrice,
liquidationPrice: position.liquidationPrice,
bankruptcyPrice,
marginLost: position.margin,
}).catch(console.error);
}
}Step 4 — Bypass margin check for liquidation orders in createPerpsOrder:
createPerpsOrder(data: perpsCreateOrderRequest) {
// Skip margin lock for LIQUIDATION_BOT — it has no balance to lock
if (data.userId !== LIQUIDATION_BOT_USER_ID) {
// ... existing margin lock logic ...
}
// ... rest of matching logic unchanged ...
}Step 5 — ADL (Auto-Deleveraging): ADL is complex; implement as a stub first, then complete:
triggerADL(market: string, side: "long" | "short", shortfall: number): void {
// Get all open positions on the given side, sorted by unrealized PnL descending
// (most profitable positions are deleveraged first)
const positions = [...(this.perpsPosition.get(market)?.values() ?? [])]
.filter(p => p.side === side && p.status === "OPEN")
.sort((a, b) => b.unrealizedPnl - a.unrealizedPnl);
let remaining = shortfall;
for (const pos of positions) {
if (remaining <= 0) break;
// Force-close this position at mark price, no premium/discount
// Credit their account at bankruptcy price (they absorb the loss)
// Emit ADL event to affected user
publishUserEvent(pos.userId, {
type: "adl",
userId: pos.userId,
market,
amountAbsorbed: Math.min(remaining, pos.margin),
}).catch(console.error);
remaining -= pos.margin;
}
}Step 6 — Add to Prisma schema:
model InsuranceFundEvent {
id String @id @default(uuid())
market String
amount Decimal @db.Decimal(20, 8) // positive = inflow, negative = outflow
reason String // "liquidation_surplus" | "liquidation_deficit" | "adl"
createdAt DateTime @default(now())
@@index([market, createdAt])
}Step 7 — Add adl event to shared events:
// shared/src/events.ts
export interface ADLEvent {
type: "adl";
userId: string;
market: string;
amountAbsorbed: number;
}
export type UserEvent = FillEvent | PositionUpdateEvent | LiquidationEvent | ADLEvent | FundingPaymentEvent;- The existing
checkAndLiquidatedirectly sets position to closed and seizes margin at mark price — that is not how any real exchange works. A $50k position being liquidated at a fixed price with no counterparty participation is incorrect. - By routing through the orderbook: if there are resting orders, the liquidation fills at those prices (potentially better than mark); if the book is empty, the liquidation order goes unfilled and ADL is triggered.
- The insurance fund exists precisely to handle the gap between liquidation price and bankruptcy price — without it, every losing liquidation is a free loss.
- Add
engine/src/constants.tswith LIQUIDATION_BOT and INSURANCE_FUND user IDs - Add
isLiquidation+liquidatedUserIdfields toperpsCreateOrderRequest - Refactor
checkAndLiquidateto callcreatePerpsOrderinstead of direct seize - Add bypass in
createPerpsOrderforLIQUIDATION_BOT_USER_IDmargin check - Add
computeBankruptcyPricehelper function - Implement
triggerADLstub (log only for now, full implementation later) - Add
InsuranceFundEventPrisma model and migration - Add
adlevent type toshared/src/events.ts - Seed insurance fund with initial balance for testability
- Test: open 100x long → drive mark price below liq price → observe liquidation order in book
- Test: liquidation fills at better-than-bankruptcy → insurance fund balance increases
- Test: no resting orders → liquidation order unfilled → ADL stub logs
Replace the naive "read all open orders from DB on restart" approach with a proper Write-Ahead Log (WAL) + periodic state snapshot pattern. This gives:
- Sub-second engine restarts (replay only the delta since last snapshot)
- Guaranteed no state loss (every mutation is logged before being applied)
- Ability to replay the engine's history for debugging or audit
Time ─────────────────────────────────────────────────────▶
snapshot-1 snapshot-2 snapshot-3
│ │ │
├── WAL entry ────┼── WAL entry ────┼── WAL entry ──▶ current
├── WAL entry ├── WAL entry ├── WAL entry
└── WAL entry └── WAL entry └── WAL entry
On restart:
1. Load latest snapshot (snapshot-3)
2. XRANGE wal_stream snapshot-3.walCursor + ← replay only the delta
3. Apply delta to in-memory state
4. Engine is live
Every state-changing operation the engine performs must append a WAL entry BEFORE (or atomically with) mutating in-memory state:
// shared/src/wal.ts
export type WalEventType =
| "order_created"
| "order_fill"
| "order_cancelled"
| "position_opened"
| "position_updated"
| "position_closed"
| "balance_deposit"
| "balance_deduct"
| "balance_credit"
| "balance_lock"
| "balance_unlock"
| "liquidation_triggered"
| "funding_settled";
export interface WalEntry<T = unknown> {
type: WalEventType;
market?: string;
userId?: string;
data: T;
timestamp: number; // Date.now() at write time
}// engine/src/wal/writer.ts
import { redis } from "../utils/redis.js";
export const WAL_STREAM = "wal_stream";
// Keep 7 days of WAL. At ~1000 trades/sec that's ~600M entries — use MAXLEN with APPROXIMATE trim
const WAL_MAXLEN = 10_000_000; // tune based on throughput
export async function appendWAL(entry: WalEntry): Promise<string> {
// XADD returns the auto-generated stream ID (used as WAL cursor in snapshots)
return redis.xadd(
WAL_STREAM,
"MAXLEN", "~", WAL_MAXLEN.toString(), // approximate trim for performance
"*",
"payload", JSON.stringify(entry)
);
}Usage in stores — write WAL before mutating state:
// In createOrder (spot-exchange-store.ts), before any balance mutation:
await appendWAL({ type: "order_created", market, userId, data: newOrder, timestamp: Date.now() });
balanceStore.lock(userId, asset, amount); // in-memory mutation happens after WAL write
// After each fill:
await appendWAL({ type: "order_fill", market, data: { orderId, fillPrice, fillQty }, timestamp: Date.now() });A snapshot captures the complete engine state at a known WAL cursor position:
// engine/src/wal/snapshot.ts
import { prisma } from "../db/client.js";
import { redis } from "../utils/redis.js";
import { WAL_STREAM } from "./writer.js";
import { spotExchangeStore } from "../store/spot-exchange-store.js";
import { perpsExchangeStore } from "../store/perps-exchange-store.js";
import { balanceStore } from "../store/balance-store.js";
const SNAPSHOT_INTERVAL_MS = Number(process.env.SNAPSHOT_INTERVAL_MS) || 5 * 60_000; // 5 min
export async function takeSnapshot(): Promise<void> {
// Get the current WAL cursor — the last message ID in the stream
const lastEntry = await redis.xrevrange(WAL_STREAM, "+", "-", "COUNT", "1");
const walCursor = lastEntry?.[0]?.[0] ?? "0-0";
const state = {
balances: balanceStore.serialize(), // all userId→asset→{available, locked}
spotOrders: spotExchangeStore.serializeOpenOrders(),
spotOrderBooks: spotExchangeStore.serializeOrderBooks(),
perpsOrders: perpsExchangeStore.serializeOpenOrders(),
perpsPositions: perpsExchangeStore.serializePositions(),
perpsOrderBooks:perpsExchangeStore.serializeOrderBooks(),
};
await prisma.engineSnapshot.create({
data: {
walCursor,
state: JSON.stringify(state),
createdAt: new Date(),
}
});
console.log(`[snapshot] taken at walCursor=${walCursor}`);
}
export function startSnapshotLoop(): void {
setInterval(() => takeSnapshot().catch(e => console.error("[snapshot] failed:", e)), SNAPSHOT_INTERVAL_MS);
}Add serialize / restore methods to each store:
Each store needs two new methods:
serializeOpenOrders(): OpenOrder[]— return all in-memory open orders as plain objectsrestoreFromSnapshot(data: SnapshotData): void— rebuild in-memory state from snapshot object
// engine/src/wal/replay.ts
import { prisma } from "../db/client.js";
import { redis } from "../utils/redis.js";
import { WAL_STREAM } from "./writer.js";
import { applyWalEntry } from "./apply.js";
export async function restoreFromSnapshotAndReplay(): Promise<void> {
// 1. Load the most recent snapshot
const snapshot = await prisma.engineSnapshot.findFirst({
orderBy: { createdAt: "desc" }
});
if (!snapshot) {
console.log("[replay] no snapshot found — starting fresh (or hydrating from DB)");
await hydrateFromDB(); // fallback to Phase 2's DB hydration for the very first boot
return;
}
// 2. Restore in-memory state from snapshot
const state = JSON.parse(snapshot.state);
balanceStore.restoreFromSnapshot(state.balances);
spotExchangeStore.restoreFromSnapshot(state.spotOrders, state.spotOrderBooks);
perpsExchangeStore.restoreFromSnapshot(state.perpsOrders, state.perpsPositions, state.perpsOrderBooks);
console.log(`[replay] snapshot loaded (walCursor=${snapshot.walCursor})`);
// 3. Replay WAL entries since the snapshot's cursor
let cursor = snapshot.walCursor;
let replayed = 0;
while (true) {
// XRANGE: read 500 entries after cursor (exclusive, hence the increment)
const entries = await redis.xrange(WAL_STREAM, `(${cursor}`, "+", "COUNT", "500");
if (!entries || entries.length === 0) break;
for (const [msgId, fields] of entries) {
const obj: Record<string, string> = {};
for (let i = 0; i < fields.length; i += 2) obj[fields[i]] = fields[i + 1];
const entry = JSON.parse(obj.payload) as WalEntry;
applyWalEntry(entry); // re-apply the mutation to in-memory state
cursor = msgId;
replayed++;
}
if (entries.length < 500) break; // reached end of stream
}
console.log(`[replay] replayed ${replayed} WAL entries since snapshot`);
}engine/src/wal/apply.ts — maps WAL event types back to in-memory mutations:
export function applyWalEntry(entry: WalEntry): void {
switch (entry.type) {
case "balance_deposit":
balanceStore.credit(entry.data.userId, entry.data.asset, entry.data.amount);
break;
case "balance_lock":
balanceStore.lock(entry.data.userId, entry.data.asset, entry.data.amount);
break;
case "order_fill":
// find order in store, update filledQty + avgPrice
spotExchangeStore.applyFill(entry.data);
break;
case "position_updated":
perpsExchangeStore.applyPositionUpdate(entry.data);
break;
// ... one case per WalEventType ...
}
}model EngineSnapshot {
id String @id @default(uuid())
walCursor String // Redis stream ID at snapshot time
state Json // full serialized engine state
createdAt DateTime @default(now())
@@index([createdAt])
}| Scenario | DB hydration only | WAL + snapshot |
|---|---|---|
| 1M open orders | Load 1M rows from DB on start | Load snapshot (one JSON blob) + replay delta (tiny) |
| Engine crash mid-fill | Partial fill in memory, no DB write → state mismatch | WAL captured the fill intent before memory mutation → can replay exactly |
| Debugging a wrong state | No history | XRANGE wal_stream 0 + shows every mutation ever made |
| Hot reload | Minutes of DB queries | < 1 second snapshot restore + sub-second WAL replay |
WAL grows forever without trimming. Strategy:
XADD ... MAXLEN ~ 10_000_000— Redis trims approximately (efficient, not exact)- After taking a snapshot:
XTRIM wal_stream MINID <snapshot.walCursor>— trim everything before the snapshot cursor (we no longer need it for replay since snapshot covers it) - Keep at least 2 snapshots worth of WAL as a safety margin
- Add
EngineSnapshotPrisma model and migration - Create
engine/src/wal/writer.ts—appendWAL(entry) - Create
engine/src/wal/snapshot.ts—takeSnapshot()+startSnapshotLoop() - Create
engine/src/wal/replay.ts—restoreFromSnapshotAndReplay() - Create
engine/src/wal/apply.ts—applyWalEntry()for all event types - Add
serialize+restoreFromSnapshotmethods toBalanceStore - Add
serialize+restoreFromSnapshotmethods toSpotExchangeStore - Add
serialize+restoreFromSnapshotmethods toPerpsExchangeStore - Add WAL write calls in
balance-store.tsfor every mutation (lock, unlock, credit, deduct) - Add WAL write calls in
spot-exchange-store.ts(order create, fill, cancel) - Add WAL write calls in
perps-exchange-store.ts(same + position open/update/close) - Call
restoreFromSnapshotAndReplay()inengine/src/index.tsbefore main loop - Call
startSnapshotLoop()inengine/src/index.tsafter main loop starts - Add
SNAPSHOT_INTERVAL_MStoengine/.env - Test: fill 100 orders, kill engine, restart → all orders/positions back in memory
- Test: snapshot taken, WAL trimmed before cursor → restart still works from snapshot
- Benchmark: restart time with 10k open orders (should be < 1s with snapshot)
Move BinancePriceWs out of the engine into a standalone mark-price-poller/ service.
Engine receives mark prices via mark_price_stream Redis stream instead of direct function calls.
mark-price-poller/
src/
index.ts ← main entry point
binance-ws.ts ← moved from engine/src/services/binance-ws.ts
package.json
tsconfig.json
.env
mark-price-poller/src/index.ts:
import "dotenv/config";
import WebSocket from "ws";
import { redis } from "./redis.js";
import { MARK_PRICE_STREAM } from "shared/src/queue-names.js";
const MARKETS: Record<string, string> = {
// binance symbol → our internal market
"BTCUSDT": "BTC_USD",
"ETHUSDT": "ETH_USD",
// add more here without touching engine
};
function connect(symbol: string) {
const url = `wss://stream.binance.com:9443/ws/${symbol.toLowerCase()}@miniTicker`;
const ws = new WebSocket(url);
ws.on("message", async (data) => {
const msg = JSON.parse(data.toString());
const price = Number(msg.c);
if (!price || isNaN(price)) return;
const market = MARKETS[msg.s];
if (!market) return;
// XADD to mark_price_stream — engine reads this
await redis.xadd(
MARK_PRICE_STREAM,
"*",
"market", market,
"price", price.toString(),
"timestamp", Date.now().toString()
);
console.log(`[mark-price-poller] ${market} = ${price}`);
});
ws.on("close", () => setTimeout(() => connect(symbol), 3000));
ws.on("error", (e) => console.error(`[mark-price-poller] ws error:`, e.message));
}
// Subscribe to all configured markets
for (const symbol of Object.keys(MARKETS)) {
connect(symbol);
}File: engine/src/index.ts — add a separate loop (or merge into main loop with multi-stream XREADGROUP):
// In main() — extend the XREADGROUP call to include MARK_PRICE_STREAM
const results = await redis.xreadgroup(
"GROUP", ENGINE_CONSUMER_GROUP, ENGINE_CONSUMER_NAME,
"COUNT", "10",
"BLOCK", "100", // short block so we alternate between order and price streams
"STREAMS",
SPOT_INCOMING_STREAM, PERPS_INCOMING_STREAM, MARK_PRICE_STREAM,
">", ">", ">"
);
// In processing loop:
if (streamName === MARK_PRICE_STREAM) {
const { market, price } = raw;
perpsExchangeStore.checkAndLiquidate(market, Number(price));
await redis.xack(MARK_PRICE_STREAM, ENGINE_CONSUMER_GROUP, msgId);
continue;
}- Delete
engine/src/services/binance-ws.ts - Remove
const bs = new BinancePriceWs(); bs.connect();fromengine/src/index.ts - Remove
wsfrom engine'spackage.jsondependencies (no longer needed)
| Concern | Explanation |
|---|---|
| Backpressure | If engine is behind, price updates queue up in stream instead of being dropped |
| Multiple markets | Add ETH_USD, SOL_USD without changing engine code — just expand MARKETS map |
| Swap source | Replace Binance WS with internal oracle by changing only mark-price-poller |
| Engine restart | On restart, engine can replay recent mark prices from stream to recalculate liquidation state |
- Create
mark-price-poller/with ownpackage.json,tsconfig.json - Move and adapt
binance-ws.ts→ publishes tomark_price_stream - Create consumer group for
MARK_PRICE_STREAMin engineensureConsumerGroups() - Engine main loop reads from 3 streams (SPOT + PERPS + MARK_PRICE)
- Delete
engine/src/services/binance-ws.ts - Remove BinancePriceWs instantiation from
engine/src/index.ts - Test: set BTC price via mark-price-poller mock → engine triggers liquidation
- Test: engine restart → replays recent mark prices, liquidation state re-evaluated
Implement periodic funding rate settlement for perpetual futures. Every 8 hours (configurable), longs pay shorts (or vice versa) based on the gap between the mark price and the index price. This is the primary mechanism that keeps the perps price anchored to spot.
Without funding rate, a perpetual future can trade at a persistent premium or discount to spot indefinitely — there's no expiry to force convergence. Funding rate creates a continuous cash flow incentive: if perps trade above spot, longs pay shorts (discouraging overbidding); if below, shorts pay longs (discouraging overselling).
Standard formula (simplified):
premiumIndex = (markPrice - indexPrice) / indexPrice
fundingRate = clamp(premiumIndex, −0.0005, +0.0005) // ±0.05% per 8h interval
notionalValue = positionQuantity × markPrice
fundingPayment = notionalValue × fundingRate
fundingRate > 0 → longs PAY shorts (mark > index, market overbought)
fundingRate < 0 → shorts PAY longs (mark < index, market oversold)
indexPrice = spot price from Binance (already available via mark_price_stream).
For a simpler v1, markPrice == indexPrice so fundingRate defaults to the configured
interest rate (0.01% / 8h). Add premium calculation in v2.
A dedicated service that emits a funding trigger to funding_rate_stream every 8 hours
(or on configured interval). Keeping this outside the engine ensures the timer is durable
across engine restarts.
funding-timer/
src/
index.ts ← setInterval → XADD funding_rate_stream
package.json
.env ← REDIS_URL, FUNDING_INTERVAL_MS
funding-timer/src/index.ts:
import "dotenv/config";
import { redis } from "./redis.js";
import { FUNDING_RATE_STREAM } from "shared/src/queue-names.js";
const INTERVAL_MS = Number(process.env.FUNDING_INTERVAL_MS) || 8 * 60 * 60 * 1000; // 8h
const MARKETS = (process.env.MARKETS || "BTC_USD,ETH_USD").split(",");
async function emitFundingTrigger() {
for (const market of MARKETS) {
await redis.xadd(
FUNDING_RATE_STREAM,
"*",
"type", "funding_trigger",
"market", market,
"timestamp", Date.now().toString()
);
console.log(`[funding-timer] emitted trigger for ${market}`);
}
}
// Emit once on startup (to catch up if missed), then on interval
emitFundingTrigger();
setInterval(emitFundingTrigger, INTERVAL_MS);// shared/src/queue-names.ts — add:
export const FUNDING_RATE_STREAM = "funding_rate_stream";
export const fundingRateChannel = (market: string) => `market:${market}:funding_rate`;// shared/src/events.ts — add:
export interface FundingPaymentEvent {
type: "funding_payment";
userId: string;
market: string;
positionSide: "long" | "short";
amount: number; // positive = received, negative = paid from user's margin
rate: number; // the funding rate applied
markPrice: number;
notionalValue: number;
timestamp: number;
}
export interface FundingRateAnnouncementEvent {
type: "funding_rate";
market: string;
rate: number;
markPrice: number;
indexPrice: number;
nextFundingAt: number; // Unix timestamp ms
}
// Add to union types:
export type UserEvent = FillEvent | PositionUpdateEvent | LiquidationEvent | ADLEvent | FundingPaymentEvent;
export type MarketEvent = OrderbookSnapshotEvent | MarkPriceEvent | FundingRateAnnouncementEvent;Add FUNDING_RATE_STREAM to ensureConsumerGroups() and to the XREADGROUP call:
// engine/src/index.ts
const results = await redis.xreadgroup(
"GROUP", ENGINE_CONSUMER_GROUP, ENGINE_CONSUMER_NAME,
"COUNT", "10",
"BLOCK", "100",
"STREAMS",
SPOT_INCOMING_STREAM, PERPS_INCOMING_STREAM, MARK_PRICE_STREAM, FUNDING_RATE_STREAM,
">", ">", ">", ">"
);
// In processing loop:
if (streamName === FUNDING_RATE_STREAM) {
const { market } = raw;
await perpsExchangeStore.settleFunding(market);
await redis.xack(FUNDING_RATE_STREAM, ENGINE_CONSUMER_GROUP, msgId);
continue;
}settleFunding method in perps-exchange-store.ts:
async settleFunding(market: string): Promise<void> {
const markPrice = this.lastMarkPrice.get(market);
if (!markPrice) {
console.warn(`[funding] no mark price for ${market}, skipping settlement`);
return;
}
// v1: use a fixed interest rate (configurable). v2: compute from premium index.
const FUNDING_RATE = 0.0001; // 0.01% per 8h interval — typical baseline
const bookPositions = this.perpsPosition.get(market);
if (!bookPositions) return;
const payments: { userId: string; amount: number; side: "long" | "short" }[] = [];
for (const [userId, position] of bookPositions) {
if (position.status !== "OPEN") continue;
const notionalValue = position.quantity * markPrice;
let fundingPayment = notionalValue * FUNDING_RATE;
// Positive rate: longs pay, shorts receive
if (position.side === "short") fundingPayment = -fundingPayment; // shorts receive (negative deduction)
// Deduct from or add to user's margin
position.margin -= fundingPayment;
payments.push({ userId, amount: -fundingPayment, side: position.side });
// If margin went negative after funding → trigger liquidation
if (position.margin <= 0) {
this.checkAndLiquidate(market, markPrice);
}
// Publish event to user
publishUserEvent(userId, {
type: "funding_payment",
userId,
market,
positionSide: position.side,
amount: -fundingPayment, // negative = paid, positive = received (from user's perspective)
rate: FUNDING_RATE,
markPrice,
notionalValue,
timestamp: Date.now(),
}).catch(console.error);
// Write to WAL
appendWAL({
type: "funding_settled",
market,
userId,
data: { fundingPayment, rate: FUNDING_RATE, markPrice, notionalValue },
timestamp: Date.now(),
}).catch(console.error);
}
// Persist funding rate to DB
writeFundingRate(market, FUNDING_RATE, markPrice, markPrice, payments);
// Announce the rate on the market channel (so clients can display next funding rate)
publishMarketEvent(market, {
type: "funding_rate",
market,
rate: FUNDING_RATE,
markPrice,
indexPrice: markPrice, // v1: index == mark
nextFundingAt: Date.now() + 8 * 60 * 60 * 1000,
}).catch(console.error);
console.log(`[funding] settled ${market} at rate=${FUNDING_RATE} markPrice=${markPrice}, positions=${bookPositions.size}`);
}Track lastMarkPrice in engine (updated every time MARK_PRICE_STREAM delivers a price):
// perps-exchange-store.ts — add field:
private lastMarkPrice: Map<string, number> = new Map();
// In checkAndLiquidate or a new updateMarkPrice() called from engine index.ts:
updateMarkPrice(market: string, price: number): void {
this.lastMarkPrice.set(market, price);
this.checkAndLiquidate(market, price);
}model FundingRate {
id String @id @default(uuid())
market String
rate Decimal @db.Decimal(20, 10)
markPrice Decimal @db.Decimal(20, 8)
indexPrice Decimal @db.Decimal(20, 8)
settledAt DateTime @default(now())
payments FundingPayment[]
@@index([market, settledAt])
}
model FundingPayment {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
market String
positionSide String // "long" | "short"
amount Decimal @db.Decimal(20, 8) // negative = paid, positive = received
fundingRateId String
fundingRate FundingRate @relation(fields: [fundingRateId], references: [id])
settledAt DateTime @default(now())
@@index([userId, market])
}Add to backend/src/routes/market-route.ts:
GET /perps/funding-rate/:market ← current rate + next funding timestamp
GET /perps/funding-history/:market ← paginated historical rates
GET /perps/funding-payments/:market ← user's funding payment history (auth required)
- Create
funding-timer/service - Add
FUNDING_RATE_STREAMtoshared/src/queue-names.ts - Add
FundingPaymentEvent+FundingRateAnnouncementEventtoshared/src/events.ts - Add
FundingRate+FundingPaymentPrisma models and run migration - Add
lastMarkPricemap toperps-exchange-store.ts; update on every mark price message - Implement
settleFunding(market)inperps-exchange-store.ts - Add
FUNDING_RATE_STREAMto engineensureConsumerGroups()and XREADGROUP call - Add
writeFundingRatetoengine/src/db/writer.ts - Add funding rate backend routes (
GET /perps/funding-rate/:market, etc.) - Add
ADLEventto shared events (depends on Phase 2A) - Test: trigger funding settlement manually → verify per-position margin deduction
- Test: long position pays when rate > 0; short position receives
- Test: funding pushes margin to 0 → liquidation triggered immediately after settlement
- Test: WS client subscribed to
/marketreceivesfunding_rateannouncement event
After every fill and position update, the engine publishes events to Redis pub/sub channels. These events power the WebSocket server in Phase 5.
| Event | Channel | Trigger | Shape |
|---|---|---|---|
| Fill (taker) | user:{takerId}:events |
After every fill | FillEvent |
| Fill (maker) | user:{makerId}:events |
After every fill | FillEvent |
| Order update | user:{userId}:events |
After status change | OrderUpdateEvent |
| Position update | user:{userId}:events |
After updatePositionFromFill |
PositionUpdateEvent |
| Liquidation | user:{userId}:events |
After checkAndLiquidate closes position |
LiquidationEvent |
| Orderbook snapshot | market:{symbol}:orderbook |
After any fill (orderbook changed) | OrderbookSnapshotEvent |
import { redis } from "./utils/redis.js";
import { userEventsChannel, orderbookChannel } from "shared/src/queue-names.js";
import type { UserEvent, OrderbookSnapshotEvent } from "shared/src/events.js";
export async function publishUserEvent(userId: string, event: UserEvent): Promise<void> {
await redis.publish(userEventsChannel(userId), JSON.stringify(event));
}
export async function publishOrderbookSnapshot(market: string, bids: [string, string][], asks: [string, string][]): Promise<void> {
const event: OrderbookSnapshotEvent = {
type: "orderbook_snapshot",
market,
bids,
asks,
timestamp: Date.now(),
};
await redis.publish(orderbookChannel(market), JSON.stringify(event));
}In spot-exchange-store.ts:
// After match loop completes, for each fill:
for (const fill of newFills) {
await publishUserEvent(takerUserId, {
type: "fill",
orderId: incomingOrder.id,
market,
side: incomingOrder.side,
price: fill.price,
quantity: fill.qty,
fee: 0,
timestamp: Date.now(),
});
await publishUserEvent(makerUserId, { /* same shape, different orderId */ });
}
// After updating orderbook:
await publishOrderbookSnapshot(market,
bids.map(o => [o.price.toString(), o.remainingQuantity.toString()]),
asks.map(o => [o.price.toString(), o.remainingQuantity.toString()])
);In perps-exchange-store.ts:
// After updatePositionFromFill():
await publishUserEvent(userId, {
type: "position_update",
userId,
market,
side: position.side,
quantity: position.quantity,
entryPrice: position.entryPrice,
liquidationPrice: position.liquidationPrice,
unrealizedPnl: /* calculate */ 0,
margin: position.margin,
leverage: position.leverage,
});
// After checkAndLiquidate closes a position:
await publishUserEvent(userId, {
type: "liquidation",
userId,
market,
side: position.side,
markPrice,
liquidationPrice: position.liquidationPrice,
marginLost: position.margin,
});Important: publishUserEvent / publishOrderbookSnapshot must be called asynchronously
but without blocking the matching loop. Use fire-and-forget (.catch(console.error)):
publishUserEvent(userId, event).catch(e => console.error("[publisher]", e));- Create
engine/src/publisher.ts - Add fill event publish in
spot-exchange-store.ts(taker + maker) - Add fill event publish in
perps-exchange-store.ts(taker + maker) - Add position update publish in
perps-exchange-store.ts - Add liquidation event publish in
perps-exchange-store.ts - Add orderbook snapshot publish in both stores after each match
- Test: place order, check Redis SUBSCRIBE shows fill event on correct channel
New ws-server/ service that maintains WebSocket connections from clients and relays
Redis pub/sub events in real time. Two namespaces:
/user— authenticated user receives fills, position updates, liquidations/market— public; receive orderbook snapshots for a market
ws-server/
src/
index.ts ← HTTP server + WebSocket upgrade handler
auth.ts ← JWT verification (reuse from backend/src/utils/auth.ts)
user-handler.ts ← manage /user connections + subscriptions
market-handler.ts ← manage /market connections + subscriptions
redis-sub.ts ← single shared Redis subscriber connection
package.json
tsconfig.json
.env
Client → WS Server messages:
// Authenticate (must be first message on /user)
{ "type": "auth", "token": "<jwt>" }
// Subscribe to a market orderbook
{ "type": "subscribe_market", "market": "BTC_USD" }
// Unsubscribe
{ "type": "unsubscribe_market", "market": "BTC_USD" }WS Server → Client messages:
// Auth result
{ "type": "auth_ok", "userId": "..." }
{ "type": "auth_error", "message": "Invalid token" }
// User events (fill, position_update, liquidation)
{ "type": "fill", "orderId": "...", "market": "BTC_USD", "price": 45000, "quantity": 0.1, ... }
{ "type": "position_update", "market": "BTC_USD", "side": "long", ... }
{ "type": "liquidation", "market": "BTC_USD", ... }
// Market events
{ "type": "orderbook_snapshot", "market": "BTC_USD", "bids": [...], "asks": [...], "timestamp": ... }import "dotenv/config";
import http from "http";
import { WebSocket, WebSocketServer } from "ws";
import { handleUserConnection } from "./user-handler.js";
import { handleMarketConnection } from "./market-handler.js";
const PORT = Number(process.env.WS_PORT) || 4000;
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
const url = new URL(req.url!, `http://localhost`);
if (url.pathname === "/user") {
wss.handleUpgrade(req, socket, head, (ws) => handleUserConnection(ws, req));
} else if (url.pathname === "/market") {
wss.handleUpgrade(req, socket, head, (ws) => handleMarketConnection(ws, req));
} else {
socket.destroy();
}
});
server.listen(PORT, () => console.log(`[ws-server] listening on :${PORT}`));// Single shared subscriber connection — all subscriptions use one connection
import { makeRedisClient } from "./redis.js";
import type { UserEvent, MarketEvent } from "shared/src/events.js";
const redisSub = makeRedisClient();
type Callback = (event: UserEvent | MarketEvent) => void;
const listeners = new Map<string, Set<Callback>>();
redisSub.on("message", (channel: string, raw: string) => {
const event = JSON.parse(raw);
listeners.get(channel)?.forEach(cb => cb(event));
});
export async function subscribeChannel(channel: string, cb: Callback): Promise<void> {
if (!listeners.has(channel)) {
listeners.set(channel, new Set());
await redisSub.subscribe(channel);
}
listeners.get(channel)!.add(cb);
}
export async function unsubscribeChannel(channel: string, cb: Callback): Promise<void> {
const set = listeners.get(channel);
if (!set) return;
set.delete(cb);
if (set.size === 0) {
listeners.delete(channel);
await redisSub.unsubscribe(channel); // only unsubscribe from Redis when no listeners remain
}
}import type { WebSocket, IncomingMessage } from "ws";
import { verifyToken } from "./auth.js";
import { subscribeChannel, unsubscribeChannel } from "./redis-sub.js";
import { userEventsChannel } from "shared/src/queue-names.js";
export function handleUserConnection(ws: WebSocket, req: IncomingMessage) {
let userId: string | null = null;
let redisCb: ((event: unknown) => void) | null = null;
ws.on("message", async (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "auth") {
try {
const payload = verifyToken(msg.token);
userId = payload.userId;
// Subscribe to user's event channel in Redis
redisCb = (event) => ws.send(JSON.stringify(event));
await subscribeChannel(userEventsChannel(userId), redisCb);
ws.send(JSON.stringify({ type: "auth_ok", userId }));
console.log(`[ws-server] user ${userId} connected`);
} catch {
ws.send(JSON.stringify({ type: "auth_error", message: "Invalid token" }));
ws.close();
}
}
});
ws.on("close", async () => {
if (userId && redisCb) {
await unsubscribeChannel(userEventsChannel(userId), redisCb);
console.log(`[ws-server] user ${userId} disconnected`);
}
});
}import type { WebSocket } from "ws";
import { subscribeChannel, unsubscribeChannel } from "./redis-sub.js";
import { orderbookChannel } from "shared/src/queue-names.js";
export function handleMarketConnection(ws: WebSocket, _req: unknown) {
const subscribed = new Map<string, (e: unknown) => void>();
ws.on("message", async (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.type === "subscribe_market") {
const { market } = msg;
if (subscribed.has(market)) return;
const cb = (event: unknown) => ws.send(JSON.stringify(event));
subscribed.set(market, cb);
await subscribeChannel(orderbookChannel(market), cb);
}
if (msg.type === "unsubscribe_market") {
const cb = subscribed.get(msg.market);
if (cb) {
await unsubscribeChannel(orderbookChannel(msg.market), cb);
subscribed.delete(msg.market);
}
}
});
ws.on("close", async () => {
for (const [market, cb] of subscribed) {
await unsubscribeChannel(orderbookChannel(market), cb);
}
});
}- Create
ws-server/with ownpackage.json,tsconfig.json,.env - Implement
index.ts,redis-sub.ts,user-handler.ts,market-handler.ts - Copy
verifyTokenlogic intows-server/src/auth.ts(or import from shared) - Test: connect to
ws://localhost:4000/user, authenticate, place order, observe fill event - Test: connect to
ws://localhost:4000/market, subscribe to BTC_USD, observe orderbook updates - Test: disconnect client → unsubscribed from Redis (no memory leak)
Periodically snapshot the orderbook from DB and write timestamped files (as shown in the
diagram with orderbook_19_march_2026_9:15PM.txt) AND publish to the Redis orderbook
channel so WS clients get snapshots on an interval even between fills.
- File snapshots — written to disk at configurable intervals (e.g., every 15 min) for historical analysis and backfill
- Live push — publish
orderbook_snapshotto Redis pub/sub at shorter interval (e.g., every 1s) so WS clients see orderbook even when no trades happen
poller/
src/
index.ts ← entry point, schedules snapshot loops
db-reader.ts ← reads latest orderbook snapshot from DB (OrderbookSnapshot model)
file-writer.ts ← writes timestamped snapshot files
publisher.ts ← publishes to orderbookChannel
snapshots/ ← output directory for snapshot files
package.json
tsconfig.json
import "dotenv/config";
import { readLatestSnapshot } from "./db-reader.js";
import { writeSnapshotFile } from "./file-writer.js";
import { publishSnapshot } from "./publisher.js";
const MARKETS = (process.env.MARKETS || "BTC_USD,ETH_USD").split(",");
const LIVE_INTERVAL_MS = Number(process.env.LIVE_INTERVAL_MS) || 1000; // 1s live push
const FILE_INTERVAL_MS = Number(process.env.FILE_INTERVAL_MS) || 900_000; // 15min file snapshot
// Live push loop (short interval)
async function liveLoop() {
while (true) {
for (const market of MARKETS) {
const snapshot = await readLatestSnapshot(market);
if (snapshot) await publishSnapshot(market, snapshot);
}
await new Promise(r => setTimeout(r, LIVE_INTERVAL_MS));
}
}
// File snapshot loop (long interval)
async function fileLoop() {
while (true) {
for (const market of MARKETS) {
const snapshot = await readLatestSnapshot(market);
if (snapshot) await writeSnapshotFile(market, snapshot);
}
await new Promise(r => setTimeout(r, FILE_INTERVAL_MS));
}
}
liveLoop().catch(e => { console.error("[poller] live loop crashed:", e); process.exit(1); });
fileLoop().catch(e => { console.error("[poller] file loop crashed:", e); process.exit(1); });import fs from "fs/promises";
import path from "path";
const SNAPSHOTS_DIR = path.join(process.cwd(), "snapshots");
export async function writeSnapshotFile(market: string, snapshot: object) {
await fs.mkdir(SNAPSHOTS_DIR, { recursive: true });
const ts = new Date();
const filename = `orderbook_${market}_${ts.getDate()}_${ts.toLocaleString("default", {month: "long"})}_${ts.getFullYear()}_${ts.getHours()}:${String(ts.getMinutes()).padStart(2, "0")}PM.txt`;
const filepath = path.join(SNAPSHOTS_DIR, filename);
await fs.writeFile(filepath, JSON.stringify(snapshot, null, 2));
console.log(`[poller] wrote ${filepath}`);
}For the poller to read from DB, the engine must write OrderbookSnapshot rows after each
fill (in the DB writer from Phase 2):
// engine/src/db/writer.ts — add:
export function writeOrderbookSnapshot(market: string, exchange: string, bids: unknown[], asks: unknown[]) {
enqueueWrite(() =>
prisma.orderbookSnapshot.create({
data: { market, exchange, bids, asks }
})
);
}And call from stores after each match, same as publishOrderbookSnapshot in Phase 4.
- Create
poller/service - Implement
db-reader.tsusing Prisma client - Implement
file-writer.tswith timestamped filenames matching diagram format - Implement
publisher.ts(thin wrapper around orderbookChannel PUBLISH) - Engine writes
OrderbookSnapshotrows to DB indb/writer.ts - Verify file snapshots appear in
poller/snapshots/directory - Verify WS clients subscribed to
/marketreceive periodic orderbook pushes without trading
Allow clients to upgrade their HTTP backend connection to WebSocket for receiving responses inline — useful for long-running order requests on slow markets.
This is optional based on client needs. If the frontend connects directly to ws-server
for all real-time data, this phase can be skipped. But if you want one endpoint:
ws://backend:3000/ws/user → upgrade to WS, proxied to ws-server user handler
OR: skip Phase 7 entirely — clients connect to ws://ws-server:4000/user directly.
Recommendation: Skip Phase 7 for now. Clients use:
http://backend:3000/*for order/auth operationsws://ws-server:4000/userfor user eventsws://ws-server:4000/marketfor market data
- Remove
backend/src/utils/broker.ts—startResponseLoop()function entirely - Remove
RESPONSE_QUEUEenv var from all.envfiles and docs - Remove
responseQueuefield from engine request types inshared/src/messages.ts - Delete
engine/src/services/binance-ws.ts - Delete unused
test-binance-ws.ts,test-binance-ws-native.tsin root - Remove
wsfromengine/package.json(moved to mark-price-poller)
Services must start in this order:
1. Postgres (already running - Aiven)
2. Redis (already running - Upstash)
3. engine/ ← creates consumer groups, restores from snapshot+WAL, starts processing
4. mark-price-poller/ ← starts feeding mark_price_stream
5. funding-timer/ ← starts feeding funding_rate_stream on interval
6. backend/ (×N) ← each subscribes to its own backend:{id}:responses channel
7. ws-server/ ← subscribes to user:* and market:* pub/sub channels
8. poller/ ← starts reading DB snapshots and pushing to orderbook channels
# docker-compose.yml
services:
engine:
build: ./engine
env_file: ./engine/.env
depends_on: [] # external Redis/Postgres
mark-price-poller:
build: ./mark-price-poller
env_file: ./mark-price-poller/.env
funding-timer:
build: ./funding-timer
env_file: ./funding-timer/.env
# runs independently; engine reads from funding_rate_stream
backend:
build: ./backend
ports: ["3000:3000"]
env_file: ./backend/.env
environment:
BACKEND_INSTANCE_ID: backend-1 # unique per replica; use ${HOSTNAME} in k8s
depends_on:
engine:
condition: service_started
ws-server:
build: ./ws-server
ports: ["4000:4000"]
env_file: ./ws-server/.env
poller:
build: ./poller
env_file: ./poller/.env
depends_on:
engine:
condition: service_startedScenario 1: Order fill → real-time user event
# Terminal 1: connect as user A
wscat -c ws://localhost:4000/user
> { "type": "auth", "token": "<token-A>" }
< { "type": "auth_ok", "userId": "user-A" }
# Terminal 2: connect as user B (maker)
wscat -c ws://localhost:4000/user
> { "type": "auth", "token": "<token-B>" }
< { "type": "auth_ok", "userId": "user-B" }
# Terminal 3: user B places limit buy at 40000
curl -X POST http://localhost:3000/spot/order ... -d '{"side":"buy","price":40000,...}'
# Terminal 4: user A places market sell (crosses with B's order)
curl -X POST http://localhost:3000/spot/order ... -d '{"side":"sell","orderType":"market",...}'
# Expect: Terminal 1 (user A) receives fill event
# Expect: Terminal 2 (user B) receives fill eventScenario 2: Multi-backend response isolation
# Start two backends with different BACKEND_INSTANCE_IDs
BACKEND_INSTANCE_ID=backend-1 PORT=3000 bun run dev &
BACKEND_INSTANCE_ID=backend-2 PORT=3001 bun run dev &
# Send 100 concurrent requests equally to both
# All responses should be received by the correct backend
# No "unknown correlationId" warnings in logsScenario 3: Mark price → liquidation → user event
# 1. User opens 100x long BTC_USD position at 40000
# 2. Send mark price of 39600 via mark-price-poller mock
# (or temporarily lower the liquidation trigger threshold in tests)
# 3. Expect: user receives { type: "liquidation", ... } on WS
# 4. Expect: position row in DB has status = "CLOSED"Scenario 4: Orderbook snapshot via poller
wscat -c ws://localhost:4000/market
> { "type": "subscribe_market", "market": "BTC_USD" }
# Without any trades: receive periodic orderbook snapshots from poller
# After placing orders: receive immediate snapshots from engineScenario 5: Engine restart — no data loss
# 1. Place 5 limit orders across two users
# 2. Kill the engine process
# 3. Restart engine
# 4. Query GET /spot/order/:id — still returns orders from DB
# 5. Place new matching order — fills against existing open orders from in-memory (re-hydrated from DB)
# NOTE: on Phase 2 completion, implement hydration of in-memory store from DB on startupThis is needed to make Phase 2 persistence complete. On startup, before processing any new requests, the engine should load open orders and positions from DB back into memory:
// engine/src/store/hydrator.ts
import { prisma } from "../db/client.js";
import { spotExchangeStore } from "./spot-exchange-store.js";
import { perpsExchangeStore } from "./perps-exchange-store.js";
import { balanceStore } from "./balance-store.js";
export async function hydrateFromDB() {
console.log("[hydrator] loading state from DB...");
// Restore balances
const balances = await prisma.balance.findMany();
for (const b of balances) {
balanceStore.setBalance(b.userId, b.asset, Number(b.available), Number(b.locked));
}
// Restore open spot orders into order books
const openSpotOrders = await prisma.order.findMany({
where: { status: { in: ["OPEN", "PARTIALLY_FILLED"] }, exchange: "SPOT" }
});
for (const o of openSpotOrders) {
spotExchangeStore.restoreOrder(o); // adds to order book without re-locking balance
}
// Restore open perps orders + positions
const openPerpsOrders = await prisma.order.findMany({
where: { status: { in: ["OPEN", "PARTIALLY_FILLED"] }, exchange: "PERPS" }
});
const openPositions = await prisma.position.findMany({ where: { status: "OPEN" } });
for (const o of openPerpsOrders) perpsExchangeStore.restoreOrder(o);
for (const p of openPositions) perpsExchangeStore.restorePosition(p);
console.log(`[hydrator] loaded ${balances.length} balances, ${openSpotOrders.length} spot orders, ${openPositions.length} positions`);
}Call await hydrateFromDB() in engine/src/index.ts before starting the main loop.
| Path | Description |
|---|---|
shared/package.json |
Shared package config |
shared/src/queue-names.ts |
All Redis key/stream/channel names (incl. WAL, funding) |
shared/src/events.ts |
Typed event payloads (incl. FundingPaymentEvent, ADLEvent) |
shared/src/messages.ts |
Engine request/response types (moved from engine) |
shared/src/wal.ts |
WAL entry types (WalEventType, WalEntry) |
mark-price-poller/package.json |
Mark price poller package |
mark-price-poller/src/index.ts |
Binance WS → XADD mark_price_stream |
mark-price-poller/.env |
REDIS_URL |
funding-timer/package.json |
Funding timer package |
funding-timer/src/index.ts |
setInterval → XADD funding_rate_stream every 8h |
funding-timer/.env |
REDIS_URL, FUNDING_INTERVAL_MS, MARKETS |
ws-server/package.json |
WS server package |
ws-server/src/index.ts |
HTTP upgrade router |
ws-server/src/redis-sub.ts |
Shared Redis subscriber |
ws-server/src/user-handler.ts |
/user WS handler |
ws-server/src/market-handler.ts |
/market WS handler |
ws-server/src/auth.ts |
JWT verification |
ws-server/.env |
REDIS_URL, JWT_SECRET, WS_PORT |
poller/package.json |
Poller package |
poller/src/index.ts |
Schedule loops |
poller/src/db-reader.ts |
Read from OrderbookSnapshot table |
poller/src/file-writer.ts |
Write timestamped snapshot files |
poller/src/publisher.ts |
Publish to orderbookChannel |
poller/.env |
DATABASE_URL, REDIS_URL, MARKETS, intervals |
engine/src/constants.ts |
LIQUIDATION_BOT_USER_ID, INSURANCE_FUND_USER_ID |
engine/src/db/writer.ts |
Async DB write queue (incl. writeFundingRate, writeInsuranceFundEvent) |
engine/src/db/client.ts |
Prisma client singleton for engine |
engine/src/store/hydrator.ts |
Fallback DB hydration for first boot (no snapshot yet) |
engine/src/publisher.ts |
Publish fill/position/liquidation/funding events to Redis pub/sub |
engine/src/wal/writer.ts |
appendWAL() — XADD to wal_stream before every state mutation |
engine/src/wal/snapshot.ts |
takeSnapshot() + startSnapshotLoop() |
engine/src/wal/replay.ts |
restoreFromSnapshotAndReplay() on startup |
engine/src/wal/apply.ts |
applyWalEntry() — re-applies WAL entries to in-memory state |
| Path | Change |
|---|---|
backend/src/utils/broker.ts |
XADD request, subscribe to own pub/sub channel |
backend/src/index.ts |
Call initBroker() instead of startResponseLoop() |
backend/.env |
Add BACKEND_INSTANCE_ID |
backend/prisma/schema.prisma |
Add Order, Fill, Position, Balance, OrderbookSnapshot, EngineSnapshot, FundingRate, FundingPayment, InsuranceFundEvent |
engine/src/index.ts |
XREADGROUP loop (5 streams: SPOT + PERPS + MARK_PRICE + FUNDING + WAL), ensureConsumerGroups, drainPending, restoreFromSnapshotAndReplay, startSnapshotLoop |
engine/src/handler.ts |
Add isLiquidation bypass path; no other changes |
engine/src/store/spot-exchange-store.ts |
appendWAL before mutations; publishUserEvent + publishOrderbookSnapshot + writeOrder + writeFill after matches; serialize/restoreFromSnapshot methods |
engine/src/store/perps-exchange-store.ts |
Same as above + writePosition, publishLiquidation, settleFunding, updateMarkPrice, triggerADL, lastMarkPrice map, serialize/restoreFromSnapshot |
engine/src/store/balance-store.ts |
appendWAL + writeBalance after every credit/deduct/lock/unlock; serialize/restoreFromSnapshot |
engine/package.json |
Add prisma client, remove ws dependency |
| Path | Reason |
|---|---|
engine/src/services/binance-ws.ts |
Moved to mark-price-poller |
test-binance-ws.ts |
Superseded |
test-binance-ws-native.ts |
Superseded |
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
JWT_SECRET=...
BACKEND_INSTANCE_ID=backend-1 # NEW — unique per process/pod
INCOMING_QUEUE=backend-to-engine # becomes stream name prefix
ENGINE_TIMEOUT_MS=30000
PORT=3000REDIS_URL=redis://...
DATABASE_URL=postgresql://... # NEW — for DB persistence
INCOMING_QUEUE=backend-to-engine # must match backend
SNAPSHOT_INTERVAL_MS=300000 # NEW — 5 min between state snapshotsREDIS_URL=redis://...
MARK_PRICE_STREAM=mark_price_stream # must match engineREDIS_URL=redis://...
FUNDING_INTERVAL_MS=28800000 # 8 hours in ms (8 * 60 * 60 * 1000)
MARKETS=BTC_USD,ETH_USDREDIS_URL=redis://...
JWT_SECRET=... # same as backend
WS_PORT=4000DATABASE_URL=postgresql://...
REDIS_URL=redis://...
MARKETS=BTC_USD,ETH_USD
LIVE_INTERVAL_MS=1000
FILE_INTERVAL_MS=900000backend-1 (BACKEND_INSTANCE_ID=backend-1)
│
│ 1. XADD SPOT_incoming_stream * payload {..., backendId:"backend-1", correlationId:"abc"}
▼
Redis Stream: SPOT_incoming_stream
│
│ 2. XREADGROUP GROUP engine-group engine-consumer-1
▼
engine
│ 3. process order, get result
│
│ 4. PUBLISH backend:backend-1:responses {"correlationId":"abc", "payload":{...}}
▼
Redis Pub/Sub: backend:backend-1:responses
│
│ 5. redisSub.on("message") fires in backend-1 only
▼
backend-1 pending map → resolve Promise → HTTP response to client
backend-2: subscribed to backend:backend-2:responses → never sees backend-1's messages ✓
Client (user A) ──WS──▶ ws-server:/user ──SUB──▶ Redis user:userA:events
▲
│ PUBLISH
engine
│ (after fill)
ws-server:/market ◀──SUB── Redis market:BTC_USD:orderbook
▲ ▲
│ WS push │ PUBLISH
client subscribers engine (after every match)
poller (every 1s from DB)
[Postgres] ◀─────────────── [engine] ───────────────▶ [Redis]
│ ▲ │
│ │ XADD mark prices │
▼ │ │
[poller] ──────────────▶ [mark-price-poller] │
│ │
│ PUBLISH orderbook │
▼ │
[Redis] ◀──────────────────────────────────────────────┘
│
│ SUB
▼
[ws-server]
│
│ WS push
▼
[Website / Mobile App]
│
│ HTTP
▼
[backend cluster]
Do phases in strict order — each one is a testable checkpoint before the next begins. Phases 2A, 2B, and 3A are inserted between their parent phases because they depend on the infrastructure those phases establish.
| Step | Phase | What | Dependency |
|---|---|---|---|
| 1 | Phase 0 | Shared package, types, queue names | — |
| 2 | Phase 1 | Redis Streams + per-backend pub/sub responses | Phase 0 |
| 3 | Phase 3 | Mark price poller (decouple Binance first, before adding more engine logic) | Phase 1 |
| 4 | Phase 2 | DB persistence (orders, fills, positions, balances) | Phase 1 |
| 5 | Phase 2A | Liquidation orders via orderbook (needs DB to persist liquidation orders as normal orders) | Phase 2 + Phase 3 (mark price from stream) |
| 6 | Phase 2B | WAL + snapshot/replay (builds on DB persistence; replaces Phase 8's hydration) | Phase 2 |
| 7 | Phase 3A | Funding rate (needs mark price stream from Phase 3 + DB models from Phase 2) | Phase 2 + Phase 3 |
| 8 | Phase 4 | Engine event publishing (fill/position/liquidation/funding events) | Phase 2A + Phase 3A |
| 9 | Phase 5 | WebSocket server (needs Phase 4 pub/sub events to relay) | Phase 4 |
| 10 | Phase 6 | Poller (orderbook snapshots from DB → WS + disk files) | Phase 2 + Phase 5 |
| 11 | Phase 8 | Cleanup, Docker Compose, end-to-end tests | All phases |
Rule: Each phase ends with a passing, manually-verified test scenario before committing and moving to the next phase. No half-phases — a phase is either complete or not started.
1. Postgres (external — Aiven)
2. Redis (external — Upstash)
3. engine/ ← creates consumer groups, restores from snapshot+WAL, starts loop
4. mark-price-poller/ ← starts feeding mark_price_stream
5. funding-timer/ ← starts funding interval loop
6. backend/ (N replicas) ← each subscribes to its own response channel
7. ws-server/ ← subscribes to user:* and market:* channels
8. poller/ ← starts reading DB snapshots, publishing to orderbook channels