Prayog technical overview

How each feature is built, how an order and its consequences flow through the system, and what a production exchange does differently. Read with the functional overview.

How to read this

1. Architecture

Web terminalReact · browser User botsPython SDK Simulated tradersservices/agents Keycloaktokens (JWT) Traefik · app. / api. / auth. .prayog.localhost login / client credentials services/exchange (one process, one instance) GatewayWebFlux REST + WS Disruptor ring65,536 slots Matching threadexchange-core Market databook + trades → WS Journal writerinputs + event log Kafka publisherreads event log /data/journal tail push to clients Kafka (KRaft)exchange.events.v1 Post-tradepositions · P&L · charges PostgreSQL Redis (leaderboard) Legend hot path: per order, single thread, no I/O off the order path: may block, retry, lag
DeployableTechJobState it owns
services/exchangeJava 21, Spring Boot 4 (WebFlux), Disruptor; engine in plain-Java exchange-coreAccept orders, match, publish everythingOrder books in memory; journal files on /data/journal
services/post-tradeJava 21, Spring Boot, spring-kafka, jOOQ, FlywayTurn trade events into positions, P&L, charges, leaderboardPostgreSQL tables; Redis sorted set
services/agentsPython 3.12, asyncioSimulated tradersNone (seeded random)
sdk/pythonPython, httpx, websockets, pydanticClient library for botsNone
apps/webReact 19, Redux Toolkit, RTK Query, Lightweight ChartsTerminal and ops pageBrowser memory only
contractsJSON Schema 2020-12 + Java recordsThe shared message shapesNone

2. Principles, and why each exists

PrincipleWhyWhat breaks without it
One writer thread owns the booksNo locks in matching. A lock costs time even when nobody else wants it, and contended locks cost far more. One thread also gives one obvious order of eventsTwo threads matching the same book need locks or can corrupt it; event order depends on thread timing
Integer paise everywhereFloating point cannot represent 0.10 exactly; sums drift₹0.1 + ₹0.2 ≠ ₹0.3; P&L off by fractions of a paisa that reconcile nowhere
Time is an inputIf the engine read the system clock, replaying yesterday would see today's timeReplays differ; tests depend on wall-clock speed
Determinism: same inputs → byte-identical eventsThe input journal then becomes a complete backup: rebuild state by replaying it. It also enables hot standbys later (section 7)Crash recovery and bug reproduction become guesswork
No network or database on the order pathA network call takes a millisecond or more on a bad day; matching takes about a microsecond. One slow call would stall every order behind itLatency spikes; a database outage stops trading
Everything after the trade goes through KafkaPost-trade work (P&L, charges, reports) can be slow, can fail and can be replayed, without touching tradingA slow P&L query slows matching
Agents use the public APIThe simulation tests the real system end to endSpecial back doors hide bugs that real users hit

3. The order path, step by step

A bot sends BUY 10 ABC @ ₹100.05 and it partly trades. Steps 4–7 are the hot path.

  1. Client → Traefik → gateway. HTTPS POST /orders with a JSON body and a bearer token. Traefik routes by hostname. MVP runs plain HTTP locally.
  2. Gateway checks (on a WebFlux event-loop thread): verify the JWT signature with Keycloak's public key (cached; no network call per request), take a token from the account's rate-limit bucket, check the body against new-order-request.schema.json rules, and turn JSON into a NewOrder command. A failure here returns HTTP 4xx with an RFC 9457 problem body. The engine never sees it.
  3. Publish to the ring. The gateway claims the next slot in the Disruptor ring buffer, copies the command in and publishes it. This is a few atomic operations with no lock. If the ring is full the gateway rejects with 503 ("back-pressure") instead of queueing without limit.
  4. Sequence. The matching thread takes the command and gives it the next input sequence number. That number fixes its place in history.
  5. Validate. Symbol, quantity, tick, band, session state, account enabled. The first failure gives OrderRejected(reason).
  6. Match. Look at the best ask level. While it crosses the limit, fill against the oldest order there, emit a Trade, and remove filled orders and empty levels. Then rest the remainder at the back of its price queue.
  7. Emit events to the event sink, each with the next event sequence number and the sim time: OrderAccepted, then the Trades.
  8. Make durable. The journal handler appends the input command and its events to files. At the end of each Disruptor batch it calls fsync once, so many orders share one disk flush ("group commit").
  9. Respond and publish, only after step 8: the gateway completes the HTTP response, the market-data handler pushes book changes and trade prints to WebSocket subscribers, and the owner gets private order updates.
  10. Kafka publisher (own thread) reads new records from the event log and sends them to Kafka, keyed by symbol. If Kafka is down it simply falls behind and catches up later; trading never waits for it.
  11. Post-trade (another service) consumes the trades, updates positions, P&L and charges in PostgreSQL inside one transaction per event batch, and updates the leaderboard in Redis.
Why "respond only after durable" (step 9 after step 8)? If we told a client "you bought 10" and then crashed before writing it down, the trade would vanish on restart. Durable first, announce second. LMAX's public architecture write-up describes the same rule. The cost is that latency includes an fsync, which is amortised across a batch. Open decision for S7/S8: confirm this ordering.

4. Feature → mechanism map

FeatureWhereHow it works technicallySession
Sign in, API keysKeycloak, gatewayOIDC; web uses Authorization Code + PKCE, bots use client credentials. Gateway is an OAuth2 resource server that validates JWTs locally with cached signing keysMVP S2, S10
Rate limitGatewayToken bucket per account in a ConcurrentHashMap. Refill is computed lazily from elapsed time, so no timer thread is neededMVP S10
Message validationGateway, contractsShapes from JSON Schema; the engine re-checks business rules (tick, band), because only it knows the instrument and sessionMVP S3, S10
Limit matchingexchange-coreSorted price levels, FIFO queues, order index (section 5.3)MVP S4 ✓
Market ordersexchange-coreSame loop with the band edge as the limit; remainder → OrderCancelled(NO_LIQUIDITY)MVP S5
Cancelexchange-coreLook up by ID (hash map), unlink from the level's list in O(1), drop the level if emptyMVP S5
Modifyexchange-coreReduce: change quantity in place (keeps the queue position). Increase or reprice: remove and re-add at the back, matching if it now crossesMVP S5
Price bandsexchange-corePrecompute [low, high] per instrument from the reference price; integer compare on validateMVP S6
Sessions, haltexchange-coreA state field checked on validate; changed only by journaled commands (ops or clock), so replay sees the same changesMVP S6
Self-trade preventionexchange-coreIn the match loop, before filling: if the resting order's account equals the incoming account, cancel the incoming remainder and stopMVP S6
DAY expiryexchange-coreOn the CLOSED transition, walk the books in symbol, then order-ID order and cancel each live orderMVP S6
Kill switchexchange-coreDisabled-accounts set checked on validate; per-account order index to cancel all its orders (in ID order)MVP S6/S10
SequencingDisruptor + matching threadMany gateway threads publish into one ring; one consumer assigns sequence numbersMVP S7
Durability, replayJournal handlerAppend-only binary records [len][seq][type][payload][crc32c], segment files, fsync per batch; replay tool and SHA-256 checksum of the event logMVP S8
Book and trade feedsMarket-data handlerLevel changes become book.update messages, plus a snapshot on subscribe; sequence numbers let clients detect gapsMVP S11
Kafka eventsKafka publisherTails the event log; idempotent producer, acks=all; remembers the last published sequenceMVP S15
Positions, P&L, chargespost-tradeConsumes trades; applies each event once using its seq as the idempotency key; writes with jOOQMVP S16
Leaderboardpost-trade, RedisZADD prayog:leaderboard <netPnl> <account>; read with ZREVRANGEMVP S17
IOCexchange-coreSame as limit, but skip the "rest" step and cancel the remainderPHASE 2
Stop-lossexchange-coreSeparate trigger books per side, sorted by trigger price; after each trade, pop every triggered stop and inject it as a new limit order. Loop until no new triggers (cascade), in a deterministic orderPHASE 2
Buying powerRisk stage before matchingIn-memory balances per account; reserve on accept, release on cancel or fill. Must be in the deterministic pipeline, not a DB callPHASE 2
Call auctionexchange-coreCollect without matching; at uncross, build cumulative buy and sell volume per price, choose the price with maximum executable volume (then tie-breakers), fill in priority order at that single pricePHASE 2
Hot standbyInfrastructureReplicate the sequenced input stream to a second engine that replays it live (section 7)LATER

5. Components

5.1 Gateway MVP S10

What it does: the only door into the exchange. It authenticates, rate-limits, validates shape, converts JSON to commands, and sends results and feeds back out. It holds WebSocket sessions and nothing about books.

Why WebFlux: it handles thousands of connections on a few event-loop threads. That suits thousands of long-lived WebSockets better than one thread per connection. Spring's own WebSocket support means no extra server.

Key design points

If we used a library: Bucket4j (Apache-2.0) for rate limiting
Bucket bucket = Bucket.builder()
    .addLimit(limit -> limit.capacity(50).refillGreedy(50, Duration.ofSeconds(1)))
    .build();
if (!bucket.tryConsume(1)) {
    return problem(429, RejectReason.RATE_LIMITED);
}
Gives: proven token buckets, distributed buckets backed by Redis (useful if we ever run several gateways). We write our own because the single-gateway version is tiny, and the plan explicitly says "no library". Revisit if gateways scale out.
Later: a FIX gateway with QuickFIX/J (QuickFIX licence, BSD-style)
public void fromApp(Message msg, SessionID session) {
    if (msg instanceof NewOrderSingle nos) {
        ring.publish(toNewOrder(nos, accountFor(session)));   // same command as REST
    }
}
FIX is the standard protocol between brokers and exchanges. Writing a FIX engine (session layer, resend, sequence recovery) is large and not where the learning is. This is the one place we would use the library outright.

5.2 Sequencer pipeline (Disruptor) MVP S7

What it does: turns many concurrent producers (gateway threads, the clock thread, ops) into one ordered stream for the matching thread.

New to ring buffers? The ring buffer, explained walks through a business example, the mechanics, an interactive simulator and Prayog's code.

Producersgateway threads, clock, ops 101102103 104105106 107… claim next slot, write, publish matching handler (at 105) journal (gated: ≤ matching) The ring is a fixed array reused forever: no allocation per message, no locks, consumers track a sequence counter each.

Key design points

We use the library: LMAX Disruptor (Apache-2.0) Writing a correct lock-free ring (memory barriers, false sharing, wrap-around) is a research project. We use the Disruptor and write the handlers. To learn how it works, read its "Disruptor technical paper" and the Sequence and RingBuffer classes.
Alternative: Agrona's ManyToOneRingBuffer (Apache-2.0): lower level, binary messages, used inside Aeron.

5.3 Matching engine and order book MVP S4–S6

What it does: applies the exchange rules to each command, keeps the books and emits events. Pure Java, no framework, no I/O: it can be tested with plain unit and property tests.

Data structures (built in S4)

OrderBook (one per symbol)
  bids : TreeMap<price, PriceLevel>  highest first
  asks : TreeMap<price, PriceLevel>  lowest first
  ordersById : HashMap<orderId, RestingOrder>

PriceLevel                         RestingOrder (is the list node)
  head ⇄ order ⇄ order ⇄ tail        orderId, accountId, side, price, leaves
  totalQuantity, orderCount          prev, next, level
OperationCostHow
Best bid/askO(log L)*firstEntry() of the tree (L = number of price levels)
Add orderO(log L)Find or create the level; append to the tail
Fill oldest at bestO(1) per fillTake the head; unlink when filled
Cancel by IDO(1) + O(log L) if the level emptiesHash lookup, unlink using prev/next

*In practice L is small (hundreds), so the tree is shallow. Benchmarks in S9 decide whether to change it.

What production engines do differently (and when we would follow)

PartPrayog nowProductionWhy they do itWhen for us
Price → levelTreeMapArray indexed by price: slot = (price − bandLow) / tick, plus a bitmap of non-empty slots. Or an adaptive radix tree (exchange-core)O(1) lookup, no allocation. Bands bound the range: ₹100 ±20%, tick 5p → 800 slotsAfter S6 (bands) if S9 shows the tree costs
Order indexHashMap<Long,…>Primitive-key map (Agrona Long2ObjectHashMap)No Long boxing, no per-entry objectsWith the array change; Agrona is approved
Order objectsnew per orderObject pool, reusedZero garbage → no GC pauses on the matching threadIf GC pauses show in S9 latency histograms
EventsRecords allocated per eventWritten straight into pre-allocated buffers ("flyweights")Zero allocationLater; records are clearer while learning
Queue per levelIntrusive doubly linked listSame (or arrays with tombstones for cache locality)O(1) cancel; cancels dominate real order flowAlready production-standard

How it is tested

If we used a library: exchange-core (Apache-2.0; last commit Oct 2023)
ExchangeCore core = ExchangeCore.builder()
    .resultsConsumer(new SimpleEventsProcessor(handler))   // trade / reduce / reject callbacks
    .exchangeConfiguration(ExchangeConfiguration.defaultBuilder().build())
    .build();
core.startup();
core.getApi().submitCommandAsync(ApiPlaceOrder.builder()
    .uid(301L).orderId(5001L).price(10_005L).size(10L)
    .action(OrderAction.BID).orderType(OrderType.GTC).symbol(ABC_ID)
    .build());
Gives: a complete Disruptor-based engine with risk, journaling, snapshots, IOC/GTC/FOK-budget and ~1 µs latencies. Costs: it would replace S4–S8 (the core learning); its model is crypto-style (currencies, margin, GTC) rather than NSE sessions, bands and DAY orders; it has been inactive since 2023. Best use for us: read OrderBookNaiveImpl (TreeMap, like ours) next to OrderBookDirectImpl (radix tree + pooling) to see the production step.
Also worth reading: CoralME (Apache-2.0, Java, active): a small garbage-free order book with IOC/GTC/DAY, cancel-incoming self-trade prevention and listener callbacks. It is close to what S5–S6 build, and a good comparison after we finish ours. Liquibook (C++, custom OCI licence): a classic open-source order book, for reading only.

5.4 Simulated clock MVP S7

A small thread off the order path reads wall time every 100 ms and publishes a ClockTick(simTime) command into the ring, where simTime = sessionStart + wallElapsed × multiplier. The engine keeps "current sim time" = the last tick. Session changes (09:15 open, 15:30 close) happen when a tick crosses the boundary.

Why ticks are commands: they are journaled like orders, so a replay sees the exact same times in the same places, even if the replay runs 1,000× faster than real time. The wall clock is read in exactly one place, and that place is outside the engine.

Resolution trade-off: every event between two ticks shares a sim time; the sequence number gives the order. Finer ticks mean more journal records. 100 ms is plenty for a human-speed market.

5.5 Journal and replay MVP S8

Two append-only logs (BUILD_PLAN 16.1 #14):

record = [length:int][seq:long][type:short][payload:bytes][crc32c:int]
segments: /data/journal/input-000001.log, input-000002.log, …   (roll at a size limit)
If we used a library: Chronicle Queue (Apache-2.0)
try (ChronicleQueue queue = ChronicleQueue.singleBuilder("/data/journal/input").build()) {
    ExcerptAppender appender = queue.createAppender();
    appender.writeDocument(w -> w.write("newOrder").marshallable(cmd));
    // replay:
    ExcerptTailer tailer = queue.createTailer();
    while (tailer.readDocument(r -> engine.apply(readCommand(r)))) { }
}
Gives: memory-mapped files, microsecond appends, daily file rolling and tailers with indexing. Costs: it hides the parts worth learning (record framing, checksums, fsync semantics), and some features are in the commercial edition. Plan default: write our own behind a Journal interface; switch if benchmarks demand it (BUILD_PLAN section 8).

5.6 Market data MVP S11

5.7 Kafka publisher MVP S15

Kafka in one paragraph: a topic is an append-only log split into partitions. Producers append; each record gets an offset (its position in the partition). Consumers read in order and periodically save ("commit") the offset they reached, so after a restart they continue from there. Order is guaranteed only within one partition, so we key by symbol: all ABC events land in one partition, in order.

Alternatives: Kafka transactions (exactly-once from Kafka to Kafka) do not cover our PostgreSQL writes, so idempotent consumers are needed anyway. Transactional outbox + Debezium suits apps whose truth is a database; ours is the journal, so tailing the journal is our outbox.

5.8 Post-trade service MVP S16

5.9 Leaderboard MVP S17

A Redis sorted set keeps members ordered by score: ZADD prayog:leaderboard 931.50 acct-7. "Top 20" is ZREVRANGE … 0 19 WITHSCORES, O(log N + 20). Redis is a cache here: it can be rebuilt from PostgreSQL at any time.

5.10 Simulated traders and SDK MVP S12–S13

5.11 Web terminal MVP S18–S19

5.12 Observability MVP S21

6. Failure modes

What failsWhat happensRecovery
Exchange process crashesTrading stops (single instance). Nothing acknowledged is lost: acks happen after fsyncRestart → replay the input journal → identical state → reopen. LATER: hot standby takes over in milliseconds
Torn write at crashLast record half-writtenCRC fails → truncate to the last good record. That command was never acknowledged
Kafka downTrading continues; publisher lag growsPublisher resumes from last published seq + 1
Post-trade or PostgreSQL downPositions and P&L go stale; trading unaffectedConsumer resumes from its committed offset; duplicates skipped by seq
Redis lostLeaderboard emptyRebuild from PostgreSQL
Ring full (overload)Gateway returns 503 / RATE_LIMITEDClients back off; investigate the slow handler
Slow WebSocket clientIts buffer fillsConflate to a snapshot, or disconnect it; others unaffected
Runaway botRate limit absorbs itPer-account kill switch
Disk fullJournal cannot write → cannot ackHalt the market (fail safe), alert. Never trade without durability
Bug in matchingWrong tradesFix → replay the journal into the fixed engine to see the differences; cancel or correct trades (an ops procedure)

7. From MVP to production

The MVP is architecturally faithful to production exchanges. Here is what they add:

ConcernMVPProduction
AvailabilityOne instance; restart + replayReplicated state machine: the sequenced input stream is copied to standby engines that replay it live. Leader election (Raft) promotes one if the leader dies. Determinism is what makes this work, which is why we protect it now. Library: Aeron Cluster (Apache-2.0)
Restart timeReplay the whole journalSnapshots + replay the tail
LatencyJava objects, TreeMap, GCPooling, primitive maps, price arrays, CPU pinning, kernel-bypass networking
ScaleOne shard (all symbols, one thread)Symbols spread over shards, each with its own thread and journal
RiskUnlimited moneyPre-trade risk stage: buying power, position limits, order-value limits, all in memory and deterministic
ProtocolsREST + WebSocket JSONFIX for brokers; binary feeds (ITCH/SBE-style) for market data; drop copy for compliance
AuditJournalsTamper-evident retention, regulator reporting, surveillance analytics
Disaster recoveryNoneA second site receiving the replicated journal
Sketch: hot standby with Aeron Cluster (Apache-2.0)
public final class ExchangeService implements ClusteredService {
    private final MatchingEngine engine = new MatchingEngine(instruments, this::reply);
    @Override
    public void onSessionMessage(ClientSession session, long timestamp,
                                 DirectBuffer buffer, int offset, int length, Header header) {
        engine.submit(decode(buffer, offset, length), simTime);   // same deterministic engine
    }
    // Aeron Cluster replicates each message to every node (Raft) before delivering it,
    // so all nodes run the same inputs in the same order and hold the same books.
}
Our MatchingEngine already fits this shape: commands in, events out, no clock, no I/O. Keeping it that way keeps this door open.

8. Libraries: use or learn from

LibraryLicenceAreaOur choiceWhy
LMAX DisruptorApache-2.0Sequencer ringUseLock-free ring is research-grade work; we learn by writing the handlers
networknt json-schema-validatorApache-2.0Schema testsUseStandard validator; no learning in writing one
Spring Boot, spring-kafka, jOOQ, FlywayApache-2.0 (jOOQ OSS edition: Apache-2.0)App plumbingUseIndustry standard
AgronaApache-2.0Primitive maps, buffersUse when benchmarks askProduction step for the order index
HdrHistogramCC0 / BSD-2Latency statsUse (S9)The standard for latency percentiles
exchange-coreApache-2.0Whole engineLearn fromWould replace the core learning; crypto-style model; inactive since 2023
CoralMEApache-2.0Order bookLearn fromCompare with ours after S6
LiquibookOCI customOrder book (C++)Learn fromClassic design reference
Chronicle QueueApache-2.0JournalFallbackUse if our journal is too slow (BUILD_PLAN section 8)
Bucket4jApache-2.0Rate limitingNot nowOurs is tiny; revisit for several gateways
Aeron / Aeron ClusterApache-2.0Messaging, replicationLaterThe production HA path
QuickFIX/JQuickFIX (BSD-style)FIX protocolUse if we add FIXProtocol plumbing, not core learning

Adding any library not already in BUILD_PLAN section 8 still needs your approval first (CLAUDE.md).

9. Open technical decisions

DecisionOptionsRecommendedDecide in
When does a client get its response?After matching / after fsyncAfter fsync (durable first). Cost: one batched fsync of latencyS7/S8
Market data before or after fsync?Before (faster) / after (never shows a trade that could vanish)After, same reasonS11
Price-level structureTreeMap / price array + bitmapKeep TreeMap; switch only on S9 evidenceS9
Where do instruments come from?Config file at startup / journaled admin commandsConfig for MVP; journaled commands when instruments change at runtimeS7
SnapshotsNone / periodicNone in MVP (a day replays in seconds); add when replay time growsLater
Slow WebSocket policyDisconnect / conflate to snapshotConflate, then disconnect if it keeps happeningS11
Post-trade idempotency keyEvent seq table / last-seq per partitionApplied-event table: simple, and survives repartitioningS16
Buying power locationInside the engine / separate risk stage in the ringSeparate stage before matching (exchange-core does this): same determinism, clearer codePhase 2

Sources