Skip to content

Repository files navigation


Exchange-Lab Logo

A production-inspired centralized exchange simulator - custodial order matching, an in-memory matching engine, event-driven settlement, live WebSocket market data, and TradingView-style charting, built to explore low-latency system design and exchange infrastructure.

Live Demo · Architecture · Getting Started


Next.js TypeScript Node.js Express.js PostgreSQL TimescaleDB Redis Docker Prisma WebSocket

Exchange-Lab Promo Image

Table of Contents

Highlights

  • Custom price-time priority matching engine, running fully in memory
  • Event-driven microservice architecture - every service communicates only through Redis
  • Redis queue for order intake, Redis Pub/Sub for state distribution
  • Live WebSocket market data streaming (order book, trades, ticker)
  • ACID-compliant ledger worker with row-level locking for settlement
  • Custom Snowflake ID generation for sortable, collision-free order IDs
  • TimescaleDB for time-series storage of trades and OHLCV candles
  • TradingView-style charting via Lightweight Charts
  • JWT-based authentication (NextAuth + bcrypt)
  • Fully Dockerized, deployed behind Nginx on a self-managed Linux VM

Try It Live

The hosted demo gives every visitor a virtual ₹50,000 balance, no signup required to browse, and no real money involved - the point is to practice against realistic order matching and slippage, not to risk anything.

Live markets: RIL/INR (trade now) · TATA/INR · SOL/INR

Each terminal includes a live price ticker (24H high/low, volume), candlestick charts across four timeframes, limit and market order types, a live order book, a trade tape, and portfolio/wallet tracking once logged in.

Exchange-Lab trading dashboard - markets overview and live pricing Exchange-Lab trade terminal - order entry, order book, and trade tape

Why I Built This

I wanted to understand how a centralized exchange keeps order matching fast while keeping custodial account balances consistent under concurrent load. Unlike a DEX, there's no on-chain settlement or smart contract here — the platform itself holds user funds and matches orders server-side, the same model real exchanges like Binance or Coinbase run internally. That intersection of low-latency systems and correctness is genuinely hard to get right, and reading about it isn't the same as building it.

The focus areas were:

  • Matching engine design (price-time priority, partial fills, maker/taker tracking)
  • Event-driven architecture and service decoupling
  • Concurrency-safe settlement (no double-spends, no lost updates)
  • Real-time data delivery over WebSockets
  • Time-series data modeling for financial tick data

Features

  • Matching engine - in-memory, price-time priority order book with partial fill support, operating in a fully custodial (CEX-style) model.
  • Event-driven core - services communicate exclusively via Redis queue + Pub/Sub, no direct service-to-service calls.
  • Live market data - WebSocket streaming for order book depth, trade tape, and ticker updates.
  • Async settlement - a dedicated ledger worker persists trades under ACID transactions, off the matching engine's hot path.
  • Charting - TradingView-style candlestick charts via Lightweight Charts, backed by TimescaleDB continuous aggregates.
  • Auth - JWT-based session auth via NextAuth, with bcrypt password hashing.

Tech Stack

Category Technologies
Frontend Next.js 16, React 19, Tailwind CSS v4, shadcn/ui
Backend Node.js, Express.js, TypeScript
Messaging Redis (queue + Pub/Sub)
Database PostgreSQL, TimescaleDB extension
ORM Prisma
Charts TradingView Lightweight Charts
Auth NextAuth.js, bcrypt, JWT
Validation Zod
Infrastructure Docker, Docker Compose, Nginx, npm workspaces

Architecture

System Architecture Diagram

Order flow, step by step:

  1. User submits an order via the Next.js client
  2. Request hits the API (an independent Express/Node process)
  3. API pushes the order onto a Redis queue, tagged with a snowflake ID
  4. The Engine (continuous while-loop) pops the order off the queue
  5. Engine validates it against current balances/assets, then matches it against the in-memory order book
  6. Engine publishes the result over Redis Pub/Sub
  7. API (subscribed on that snowflake ID) resolves the original client request
  8. WebSocket server (subscribed independently) broadcasts the update to connected clients
  9. Ledger Worker (also subscribed independently) persists the trade to PostgreSQL under an ACID transaction
  10. A TimescaleDB worker syncs trade data into a kline-optimized hypertable, which the Chart Manager consumes to drive live charts

Why this shape: steps 7, 8, and 9 all react to the same published event, independently, with no awareness of each other. That's the core design choice - the API doesn't wait on the database, the WebSocket server doesn't wait on the ledger, and the ledger doesn't block client-facing latency. Each piece can be scaled, restarted, or replaced without touching the others.

Auth path: registration and login are handled by Next.js via NextAuth, writing user records to PostgreSQL - which the Engine reads from for balance/asset validation during order processing.

Engineering Decisions

  • Redis queue for order intake, not a direct function call - the API can acknowledge a submitted order immediately without waiting on the engine to finish matching it, keeping client-facing latency decoupled from matching throughput.
  • In-memory order book - matching needs to happen without a database round-trip in the hot path; the book lives in the engine's memory, and persistence happens asynchronously afterward.
  • Snowflake IDs over UUIDs - IDs need to be chronologically sortable without a timestamp column and generated without a database round-trip; a bit-shifted snowflake ID (timestamp + worker ID + sequence counter) gives both in one 64-bit BigInt, resolving in under a millisecond. (Snowflake.ts)
  • Row-level locking in the ledger worker - concurrent trades touching the same wallet could otherwise race; SELECT ... FOR UPDATE inside an ACID transaction closes that gap. (ledgerWorker.ts)
  • Pub/Sub fan-out instead of a shared callback - the WebSocket server and ledger worker both need to react to the same trade event, but neither needs to know the other exists; Pub/Sub lets them subscribe independently rather than chaining calls.
  • Custodial balance integrity - since this is a centralized exchange, the platform (not the user) holds and moves funds on every trade; any drift between the in-memory engine and the database becomes a real accounting problem, not just a UI bug.
  • TimescaleDB over plain PostgreSQL - OHLCV/kline queries over large tick-data volumes are dramatically cheaper with continuous aggregates than computing candles on read from a raw trades table.

Engineering Challenges

  • Maintaining price-time priority while handling partial fills and maker/taker tracking correctly, without letting book state drift from what's been persisted.
  • Avoiding race conditions in balance settlement when multiple trades against the same wallet can arrive concurrently - solved with row-level locking in the ledger worker rather than trusting application-level checks.
  • Keeping matching entirely in-memory while still guaranteeing every trade eventually lands durably in Postgres - the two halves of the system (fast in-memory execution, slower durable persistence) had to stay correct independently.
  • Synchronizing live UI state across order book depth, trade tape, ticker, and charts, all fed by the same event stream, without the frontend polling anything.
  • Preventing double-spending during high-concurrency settlement - the same problem real exchanges solve with transactional guarantees, tackled here at a smaller scale.
  • BigInt serialization - trade IDs and balances as BigInt don't serialize to JSON by default, which needed explicit handling across the API/WebSocket boundary.
  • A Prisma DateTime vs. timestamptz mismatch that was silently producing future-dated trade timestamps until traced back to timezone handling in the schema.

Directory Structure

exchange-lab/
├── packages/
│   ├── api/       # REST API (Express) - orders, klines, trades, ticker
│   ├── engine/    # In-memory matching engine
│   ├── ws/        # WebSocket server - live market data broadcast
│   ├── db/        # Prisma schema + trades ledger worker
│   ├── web/       # Next.js frontend
│   └── shared/    # Shared types/interfaces across services
├── docker-compose.yml
├── nginx-sites.conf       # Reverse proxy routing
├── nginx-upstream.conf    # Upstream service definitions
└── .env.example

Getting Started

Prerequisites

  • Node.js v20+
  • Docker & Docker Compose

Setup

# 1. Configure environment
cp .env.example .env

# 2. Install dependencies across all workspaces
cd packages && npm install

# 3. Start Postgres + Redis
docker compose up -d postgres redis
# (wait a few seconds for TimescaleDB to initialize)

# 4. Generate Prisma client and push schema
npm run db:generate --workspace=packages/db
npm run db:push --workspace=packages/db

Key environment variables (see .env.example for the full list):

DATABASE_URL=postgresql://postgres:secure_password_here@localhost:5432/exchange_lab?schema=public
REDIS_URL=redis://localhost:6379
PORT_WEB=3000
PORT_API=3001
PORT_WS=3002

Running

Development (all services on the host):

cd packages && npm run dev:all

(or ./packages/start.sh to run built output for backend services alongside Next.js)

Production (fully Dockerized):

docker compose build && docker compose up -d

Deployment

The live demo runs on a self-managed Linux VM, with services orchestrated via Docker Compose and Nginx as the single public-facing reverse proxy (nginx-sites.conf, nginx-upstream.conf).

Nginx handles routing to the correct internal service, TLS termination, and upgrading/proxying WebSocket connections through to the ws service. Internal service ports are never exposed directly to the internet.

Performance

No formal load testing has been run yet - treat the following as architectural reasoning, not benchmarks:

  • Order-to-publish latency: likely low single-digit milliseconds under light load, dominated by Redis round-trips rather than the in-memory matching step itself.
  • Throughput: a single engine process could plausibly handle hundreds to low thousands of orders/sec before the Redis queue or the single-threaded matching loop becomes the bottleneck.
  • Concurrent WebSocket clients: likely comfortable into the low thousands on a single small VM before ws needs to be scaled horizontally.

Real benchmarks (order processing latency, Redis publish latency, WebSocket round-trip time, memory footprint under load) are the top item on the roadmap below - until then, these numbers shouldn't be quoted as measured facts.

Roadmap

  • Automated test coverage - unit tests for the matching engine, integration tests for the settlement flow
  • CI pipeline (build/lint/test on PRs)
  • Load testing to replace the estimates above with measured numbers
  • Additional order types (stop, IOC/FOK)
  • Multi-asset order book support beyond the current pairs
  • Historical backtesting mode

Contributing & License

Contributions are welcome:

  1. Fork the repository
  2. Create a feature branch: git checkout -b feature/amazing-feature
  3. Commit your changes: git commit -m 'Add some amazing feature'
  4. Push and open a pull request

Licensed under the MIT License

About

A full-stack exchange simulation with an in-memory order book, matching engine, WebSocket-based market data, and a trading UI, built to study high-frequency trading systems.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages