Skip to content

Prayog: pitch and justify

A simulated stock exchange and bot arena. Code: github.com/rj8228/prayog · Docs: rj8228.github.io/prayog

This is the project they will ask about most. Know the pitch, the "why", and three decisions cold.

30 seconds

Prayog is a simulated stock exchange I built from scratch. It has a single-threaded matching engine on an LMAX Disruptor ring, and orders are answered only after they are journaled to disk. Same inputs always give a byte-identical event log, so I can replay any session to prove it. Simulated traders make the market, and people trade by hand or with bots. It has a Kafka-fed post-trade ledger, a leaderboard, and a React trading terminal. The engine matches a command in about 100 nanoseconds, and every number I quote comes from a recorded benchmark.

2 minutes

  1. What: an NSE-style exchange (Indian large caps, prices in paise as integers) plus an arena where people and bots compete on P&L.
  2. The order path: a gateway (Spring Boot WebFlux) puts each order on a Disruptor ring. One thread matches it, with no locks. The journal stage writes it in SBE with a checksum and fsyncs. Only then does the outbound stage answer the client and publish market data. So nothing a client is told can be lost in a crash.
  3. Determinism: time is an input (a simulated clock), money is integers, and rule changes are journaled commands. So replaying the input journal reproduces the event log byte for byte. Recovery, snapshots and the CI replay check all rely on it.
  4. After the trade: a publisher tails the durable event log into Kafka, so Kafka is never on the order path. A post-trade service builds an idempotent ledger in Postgres (average-cost P&L, charges) and a Redis leaderboard. The ledger is checked as zero-sum.
  5. Proof: 23 ADRs, Cucumber scenarios for the exchange rules, failure drills (Kafka stopped mid-session, post-trade killed), Grafana from gateway to ledger. JMH says about 60–120 ns per command in the engine; end to end, the fsync dominates.
  6. What I learned: the hardest bugs were about time and ordering: a rule change that rewrote history on replay, and a pipeline race that lost orders in 261 of 300 runs. Both are in what broke.

Justify Prayog

These are the "why" questions. Interviewers ask them to see judgement, not code.

Why did you build this? Why an exchange?

"In my 2021 induction program I won with an algo-trading app, but the strategy ran against an exchange I could not see inside. I work in finance, and I wanted to understand what actually happens to an order: matching, durability, market data, post-trade. The best way I know to understand a system is to build one and measure it."

Then the bridge to the job: "It's also the deepest Java and Kafka work I've done outside the bank: concurrency, GC, durability, idempotent consumers, all things production systems need."

Why not just use an existing open-source engine (exchange-core, QuickFIX)?

The goal was learning and owning every decision, not running a market. I did study exchange-core and LMAX (Fowler's article) and wrote down where I differ and why (e.g. TreeMap of price levels instead of an array or adaptive radix tree, until a benchmark says otherwise). Reading them told me what; building it taught me why.

Why is the matching engine single-threaded? Isn't multi-threaded faster?

Matching one order book is sequential by nature: price-time priority means every order depends on the book state left by the previous one. With locks, threads would mostly wait on each other, and order would depend on thread scheduling, which kills determinism. One thread has no locks, no contention, warm CPU caches, and a deterministic order. It does ~100 ns per command, so ~10 million/s in theory: the disk (fsync) is the bottleneck, not the CPU. To scale, shard by symbol: each shard is still single-threaded (scale).

Why the LMAX Disruptor instead of a BlockingQueue?

A pre-allocated ring buffer: no allocation per message, no locks (sequence numbers with memory barriers), cache-friendly, and natural batching (a consumer that falls behind reads many slots at once, which is what makes group commit of the journal work). It also gives a pipeline: matching → journal → outbound, each stage reading the same slot in order. ArrayBlockingQueue uses a lock and allocates nodes/wrappers; under load it contends.

Why answer the client only after fsync? It costs 12 ms.

Because otherwise a crash could erase a fill the client already acted on. That's the core promise of an exchange. I accepted the latency (12 ms median on the Mac SSD, 0.8 ms in the Docker volume) and kept throughput with group commit (one fsync per batch covers hundreds of commands). On a server you would replicate the journal to a standby instead of waiting for the disk, as LMAX does.

Why Kafka off the order path?

Kafka is a dependency I don't control on the critical path. The publisher tails the durable event log on disk and publishes with a checkpoint, so a Kafka outage delays the post-trade stream but never stops trading. Duplicates are possible after a crash (at-least-once), so the ledger consumer dedupes by event id. I proved it with a drill: stop the broker mid-session, check every event id 1..N arrives afterwards.

Is it production-ready? Would you run real money on it?

Honest answer: no, and I can say exactly what is missing: a replicated journal and hot standby (single machine today), real auth/compliance, pre-trade risk across symbols, operational hardening, and a security review. What it does have that many toys don't: crash recovery that is tested, deterministic replay checked in CI, failure drills, and dated benchmarks.

How much of this did AI write? Do you understand it?

Be direct; they will probably wonder.

"I used Claude Code as a pair programmer, the same way I use Copilot at work. But I wrote the plan, made every decision (23 ADRs explain them), and the workflow forced understanding: after each session I had to answer five explain-back questions before moving on, and those answers are in the learnings folder. Tests were written first for core logic. Ask me about any part: why the order index allocated, how recovery verifies replay, why ZGC made the tail worse in the container."

Then prove it by going deep on whatever they pick. The failure stories are the strongest proof: AI doesn't debug a 261-in-300 race for you.

What would you do differently?
  • Design the engine's output allocation-free from the start (flyweight events written into ring slots, pooled price levels); retrofitting is a redesign.
  • Make rule versions journaled inputs from day one (learned from failure 1).
  • Give the stack more memory before measuring tails in Docker.
What was the hardest part?

Time and ordering. Determinism means no system clock, no floating point for money, no iteration over unordered collections, and rule changes as inputs. Each was learned by a bug. Tell failure 1 or failure 2.

How does this relate to the job you are interviewing for?

The same skills, at higher intensity: Java concurrency and the memory model, GC tuning with JFR, Spring Boot (WebFlux gateway, post-trade service), Kafka delivery semantics and idempotent consumers, Postgres transactions, Redis, a React + Redux Toolkit + TypeScript frontend with WebSockets, Docker Compose with 11 services, Prometheus and Grafana, and writing decisions down.

Questions to expect (quick answers)

Question One-line answer
How is price-time priority implemented? TreeMap of price levels per side (best = first key); each level a FIFO intrusive linked list of orders
How is cancel O(1)? An index from order id to the resting order node; unlink from its level's list in O(1)
Why integers for prices? Floating point can't represent 0.1 exactly; money must be exact. Price in paise as long
How do you test a matching engine? Unit tests per rule, jqwik property tests (e.g. book never crossed, quantities conserved), Cucumber scenarios, replay check
What's in a snapshot? The book and engine state at a journaled TakeSnapshot point, CRC-checked; restore = snapshot + replay the rest, verified
What is SBE? Simple Binary Encoding: fixed-layout binary messages, no parsing allocation; used for the journal
How do clients get market data? WebSocket per symbol with sequence numbers; a gap marks the book stale and the client resyncs from a snapshot
What happens on a slow WebSocket client? Bounded per-subscriber buffer; overflow disconnects that client, nobody else slows down
How are rate limits done? Token bucket per account and tier from the JWT (people 20/s, bots 50/s, simulated traders 500/s)
How is P&L computed? Average-cost method in the ledger; checked as zero-sum across all accounts (every buy is someone's sell)