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
- Tags match the functional page: MVP PHASE 2 LATER.
- Hot path means code that runs for every order on the matching thread. It has the strictest rules: no I/O, no locks, no system clock, as little allocation as possible.
- Build vs. library boxes. We write our own code to learn. Each component has a box showing what using an open-source library would look like, its licence, and why we did or did not use it. Licences were checked on GitHub on 3 Oct 2026.
- Exact decisions live in
docs/BUILD_PLAN.mdsection 16 anddocs/adr/. This page explains them; it does not override them.
1. Architecture
| Deployable | Tech | Job | State it owns |
|---|---|---|---|
services/exchange | Java 21, Spring Boot 4 (WebFlux), Disruptor; engine in plain-Java exchange-core | Accept orders, match, publish everything | Order books in memory; journal files on /data/journal |
services/post-trade | Java 21, Spring Boot, spring-kafka, jOOQ, Flyway | Turn trade events into positions, P&L, charges, leaderboard | PostgreSQL tables; Redis sorted set |
services/agents | Python 3.12, asyncio | Simulated traders | None (seeded random) |
sdk/python | Python, httpx, websockets, pydantic | Client library for bots | None |
apps/web | React 19, Redux Toolkit, RTK Query, Lightweight Charts | Terminal and ops page | Browser memory only |
contracts | JSON Schema 2020-12 + Java records | The shared message shapes | None |
2. Principles, and why each exists
| Principle | Why | What breaks without it |
|---|---|---|
| One writer thread owns the books | No 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 events | Two threads matching the same book need locks or can corrupt it; event order depends on thread timing |
| Integer paise everywhere | Floating 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 input | If the engine read the system clock, replaying yesterday would see today's time | Replays differ; tests depend on wall-clock speed |
| Determinism: same inputs → byte-identical events | The 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 path | A network call takes a millisecond or more on a bad day; matching takes about a microsecond. One slow call would stall every order behind it | Latency spikes; a database outage stops trading |
| Everything after the trade goes through Kafka | Post-trade work (P&L, charges, reports) can be slow, can fail and can be replayed, without touching trading | A slow P&L query slows matching |
| Agents use the public API | The simulation tests the real system end to end | Special 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.
- Client → Traefik → gateway. HTTPS
POST /orderswith a JSON body and a bearer token. Traefik routes by hostname. MVP runs plain HTTP locally. - 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.jsonrules, and turn JSON into aNewOrdercommand. A failure here returns HTTP 4xx with an RFC 9457 problem body. The engine never sees it. - 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.
- Sequence. The matching thread takes the command and gives it the next input sequence number. That number fixes its place in history.
- Validate. Symbol, quantity, tick, band, session state, account enabled. The first failure gives
OrderRejected(reason). - 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. - Emit events to the event sink, each with the next event sequence number and the sim time:
OrderAccepted, then theTrades. - Make durable. The journal handler appends the input command and its events to files. At the end of each Disruptor batch it calls
fsynconce, so many orders share one disk flush ("group commit"). - 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.
- 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.
- 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.
4. Feature → mechanism map
| Feature | Where | How it works technically | Session |
|---|---|---|---|
| Sign in, API keys | Keycloak, gateway | OIDC; web uses Authorization Code + PKCE, bots use client credentials. Gateway is an OAuth2 resource server that validates JWTs locally with cached signing keys | MVP S2, S10 |
| Rate limit | Gateway | Token bucket per account in a ConcurrentHashMap. Refill is computed lazily from elapsed time, so no timer thread is needed | MVP S10 |
| Message validation | Gateway, contracts | Shapes from JSON Schema; the engine re-checks business rules (tick, band), because only it knows the instrument and session | MVP S3, S10 |
| Limit matching | exchange-core | Sorted price levels, FIFO queues, order index (section 5.3) | MVP S4 ✓ |
| Market orders | exchange-core | Same loop with the band edge as the limit; remainder → OrderCancelled(NO_LIQUIDITY) | MVP S5 |
| Cancel | exchange-core | Look up by ID (hash map), unlink from the level's list in O(1), drop the level if empty | MVP S5 |
| Modify | exchange-core | Reduce: change quantity in place (keeps the queue position). Increase or reprice: remove and re-add at the back, matching if it now crosses | MVP S5 |
| Price bands | exchange-core | Precompute [low, high] per instrument from the reference price; integer compare on validate | MVP S6 |
| Sessions, halt | exchange-core | A state field checked on validate; changed only by journaled commands (ops or clock), so replay sees the same changes | MVP S6 |
| Self-trade prevention | exchange-core | In the match loop, before filling: if the resting order's account equals the incoming account, cancel the incoming remainder and stop | MVP S6 |
| DAY expiry | exchange-core | On the CLOSED transition, walk the books in symbol, then order-ID order and cancel each live order | MVP S6 |
| Kill switch | exchange-core | Disabled-accounts set checked on validate; per-account order index to cancel all its orders (in ID order) | MVP S6/S10 |
| Sequencing | Disruptor + matching thread | Many gateway threads publish into one ring; one consumer assigns sequence numbers | MVP S7 |
| Durability, replay | Journal handler | Append-only binary records [len][seq][type][payload][crc32c], segment files, fsync per batch; replay tool and SHA-256 checksum of the event log | MVP S8 |
| Book and trade feeds | Market-data handler | Level changes become book.update messages, plus a snapshot on subscribe; sequence numbers let clients detect gaps | MVP S11 |
| Kafka events | Kafka publisher | Tails the event log; idempotent producer, acks=all; remembers the last published sequence | MVP S15 |
| Positions, P&L, charges | post-trade | Consumes trades; applies each event once using its seq as the idempotency key; writes with jOOQ | MVP S16 |
| Leaderboard | post-trade, Redis | ZADD prayog:leaderboard <netPnl> <account>; read with ZREVRANGE | MVP S17 |
| IOC | exchange-core | Same as limit, but skip the "rest" step and cancel the remainder | PHASE 2 |
| Stop-loss | exchange-core | Separate 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 order | PHASE 2 |
| Buying power | Risk stage before matching | In-memory balances per account; reserve on accept, release on cancel or fill. Must be in the deterministic pipeline, not a DB call | PHASE 2 |
| Call auction | exchange-core | Collect 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 price | PHASE 2 |
| Hot standby | Infrastructure | Replicate 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
- Validate the JWT locally (cached public keys from Keycloak). Calling Keycloak per request would put the network on every order.
- Token-bucket rate limit, written by hand (BUILD_PLAN 16.1 #22). It is ~30 lines, and writing it teaches the algorithm.
- Never block an event-loop thread. Publishing to the ring is non-blocking. If the ring is full, fail fast with 503.
- Correlate responses by
clientOrderId. The engine's reply comes back on another thread, and the gateway completes the waiting HTTP response.
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.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.
Key design points
- Pre-allocated ring of 65,536 mutable slots. Producers copy data in; nobody allocates per message, so the garbage collector has little to do.
- Handlers form a dependency graph. Journal and publishers run after matching on the same slots, in parallel with each other, each on its own thread.
- Wait strategy:
BLOCKING(low CPU, for laptops) by default;BUSY_SPINburns a whole core to shave microseconds (benchmarks). - Batching for free: a handler that falls behind sees many ready slots and processes them as a batch. That is where group-commit fsync comes from.
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
| Operation | Cost | How |
|---|---|---|
| Best bid/ask | O(log L)* | firstEntry() of the tree (L = number of price levels) |
| Add order | O(log L) | Find or create the level; append to the tail |
| Fill oldest at best | O(1) per fill | Take the head; unlink when filled |
| Cancel by ID | O(1) + O(log L) if the level empties | Hash 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)
| Part | Prayog now | Production | Why they do it | When for us |
|---|---|---|---|---|
| Price → level | TreeMap | Array 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 slots | After S6 (bands) if S9 shows the tree costs |
| Order index | HashMap<Long,…> | Primitive-key map (Agrona Long2ObjectHashMap) | No Long boxing, no per-entry objects | With the array change; Agrona is approved |
| Order objects | new per order | Object pool, reused | Zero garbage → no GC pauses on the matching thread | If GC pauses show in S9 latency histograms |
| Events | Records allocated per event | Written straight into pre-allocated buffers ("flyweights") | Zero allocation | Later; records are clearer while learning |
| Queue per level | Intrusive doubly linked list | Same (or arrays with tombstones for cache locality) | O(1) cancel; cancels dominate real order flow | Already production-standard |
How it is tested
- Unit tests: one per rule, readable as a specification.
- Property tests (jqwik): random order flows, checking that the book is never crossed, quantity is conserved, sequences have no gaps, and runs are deterministic.
- Reference model: a naive list-scanning matcher must produce identical events. It catches "plausible but wrong" bugs like LIFO instead of FIFO.
checkInvariants()walks the whole structure after each step in tests.
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.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):
- Input journal: every command in sequence order. Replaying it rebuilds the engine state exactly. This is event sourcing: the log is the truth, and memory is a cache of it.
- Event log: every event produced. It is used for the determinism checksum and as the source the Kafka publisher reads.
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)
- CRC32C detects a torn final record after a crash: replay stops at the first bad checksum.
- fsync per batch: the OS caches writes in memory, and only
fsyncguarantees they reached the disk. One fsync per Disruptor batch amortises the ~1 ms cost. - Replay check in CI: run a recorded session, replay it, and compare SHA-256 of the two event logs.
- LATER Snapshots: replaying a whole day is slow, so periodically serialise the books with the sequence number they reflect, then replay only from there.
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
- Snapshot + deltas: on subscribe, send
book.snapshot(all levels, with the seq it reflects), thenbook.updatefor each level change. The client applies deltas with seq > snapshot seq. - Gap detection: per-symbol seq numbers; a gap means "re-request the snapshot". This is the same pattern as exchange multicast feeds with a recovery channel.
- Slow consumers: each WebSocket has a bounded outgoing buffer. If it fills, the client is too slow, so drop its queued updates and send a fresh snapshot (conflation), or disconnect it. Never let one slow browser hold memory for ever or slow the publisher.
- Privacy: public trades carry no account IDs (the schema forbids them); private order updates go only to the owner's sessions.
- The engine emits
BookUpdatefrom the level totals it already keeps, so market data never reads the book from another thread (which would need locks).
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.
- Source: the publisher tails the event log, not the ring, so it can be minutes behind without slowing anything. It remembers "last published seq" and resumes from seq + 1 after a restart or Kafka outage.
- At-least-once: if the publisher crashes after Kafka stored a record but before it noted the seq, it sends that record again. Duplicates are possible; loss is not.
- Producer settings:
acks=all(written to all replicas before success), idempotent producer on (Kafka drops its own retry duplicates within a session). - Consumers must be idempotent: because of at-least-once delivery, post-trade skips events whose
seqit has already applied (section 5.8).
5.8 Post-trade service MVP S16
- Consumes
Tradeevents; for each trade updates two accounts (buyer and seller). - Exactly-once effect from at-least-once delivery: in one database transaction, insert the event's
seqintoapplied_event(primary key) and update positions. A duplicate fails the insert, so it is skipped. The Kafka offset is committed after the transaction. - Positions:
(account, symbol) → quantity, total cost in paise. Average price = cost ÷ quantity, computed when shown, so nothing is rounded in storage. - Unrealised P&L needs the last price: kept from trade prints in memory or Redis, not recomputed in SQL per tick.
- jOOQ + Flyway: SQL-first, type-safe queries generated from the migrated schema; migrations run once as a job before services start.
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
- SDK: async Python client. It gets a token, places orders over REST, streams over WebSocket, keeps a local book from snapshot + deltas, and checks seq gaps.
- Agents: one asyncio process running many traders. Each trader has a strategy and a seeded random generator. A hidden "fair value" per symbol follows a random walk with occasional jumps, and market makers quote around it.
- They connect like any user (client credentials), which load-tests the gateway for free.
5.11 Web terminal MVP S18–S19
- React + Redux Toolkit. RTK Query handles REST (orders, blotter, leaderboard); a WebSocket middleware feeds book and trade updates into the store.
- Book rendering is throttled to animation frames: updates may arrive faster than 60 per second, and nobody can read faster.
- All prices are integers in the store, formatted as ₹ only in the view. Every integer from the server is below 253 by schema, so JavaScript numbers are exact.
- Charts: TradingView Lightweight Charts (attribution link required).
5.12 Observability MVP S21
- Micrometer metrics → Prometheus → Grafana: orders/s, rejects by reason, ring fill level, journal fsync time, Kafka lag (last journaled seq − last published seq), consumer lag.
- Latency as HdrHistogram percentiles (p50/p99/p99.9/max), never averages: averages hide the tail that traders feel.
- Structured JSON logs to stdout, with the order's seq so one order can be traced across services.
6. Failure modes
| What fails | What happens | Recovery |
|---|---|---|
| Exchange process crashes | Trading stops (single instance). Nothing acknowledged is lost: acks happen after fsync | Restart → replay the input journal → identical state → reopen. LATER: hot standby takes over in milliseconds |
| Torn write at crash | Last record half-written | CRC fails → truncate to the last good record. That command was never acknowledged |
| Kafka down | Trading continues; publisher lag grows | Publisher resumes from last published seq + 1 |
| Post-trade or PostgreSQL down | Positions and P&L go stale; trading unaffected | Consumer resumes from its committed offset; duplicates skipped by seq |
| Redis lost | Leaderboard empty | Rebuild from PostgreSQL |
| Ring full (overload) | Gateway returns 503 / RATE_LIMITED | Clients back off; investigate the slow handler |
| Slow WebSocket client | Its buffer fills | Conflate to a snapshot, or disconnect it; others unaffected |
| Runaway bot | Rate limit absorbs it | Per-account kill switch |
| Disk full | Journal cannot write → cannot ack | Halt the market (fail safe), alert. Never trade without durability |
| Bug in matching | Wrong trades | Fix → 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:
| Concern | MVP | Production |
|---|---|---|
| Availability | One instance; restart + replay | Replicated 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 time | Replay the whole journal | Snapshots + replay the tail |
| Latency | Java objects, TreeMap, GC | Pooling, primitive maps, price arrays, CPU pinning, kernel-bypass networking |
| Scale | One shard (all symbols, one thread) | Symbols spread over shards, each with its own thread and journal |
| Risk | Unlimited money | Pre-trade risk stage: buying power, position limits, order-value limits, all in memory and deterministic |
| Protocols | REST + WebSocket JSON | FIX for brokers; binary feeds (ITCH/SBE-style) for market data; drop copy for compliance |
| Audit | Journals | Tamper-evident retention, regulator reporting, surveillance analytics |
| Disaster recovery | None | A second site receiving the replicated journal |
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
| Library | Licence | Area | Our choice | Why |
|---|---|---|---|---|
| LMAX Disruptor | Apache-2.0 | Sequencer ring | Use | Lock-free ring is research-grade work; we learn by writing the handlers |
| networknt json-schema-validator | Apache-2.0 | Schema tests | Use | Standard validator; no learning in writing one |
| Spring Boot, spring-kafka, jOOQ, Flyway | Apache-2.0 (jOOQ OSS edition: Apache-2.0) | App plumbing | Use | Industry standard |
| Agrona | Apache-2.0 | Primitive maps, buffers | Use when benchmarks ask | Production step for the order index |
| HdrHistogram | CC0 / BSD-2 | Latency stats | Use (S9) | The standard for latency percentiles |
| exchange-core | Apache-2.0 | Whole engine | Learn from | Would replace the core learning; crypto-style model; inactive since 2023 |
| CoralME | Apache-2.0 | Order book | Learn from | Compare with ours after S6 |
| Liquibook | OCI custom | Order book (C++) | Learn from | Classic design reference |
| Chronicle Queue | Apache-2.0 | Journal | Fallback | Use if our journal is too slow (BUILD_PLAN section 8) |
| Bucket4j | Apache-2.0 | Rate limiting | Not now | Ours is tiny; revisit for several gateways |
| Aeron / Aeron Cluster | Apache-2.0 | Messaging, replication | Later | The production HA path |
| QuickFIX/J | QuickFIX (BSD-style) | FIX protocol | Use if we add FIX | Protocol 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
| Decision | Options | Recommended | Decide in |
|---|---|---|---|
| When does a client get its response? | After matching / after fsync | After fsync (durable first). Cost: one batched fsync of latency | S7/S8 |
| Market data before or after fsync? | Before (faster) / after (never shows a trade that could vanish) | After, same reason | S11 |
| Price-level structure | TreeMap / price array + bitmap | Keep TreeMap; switch only on S9 evidence | S9 |
| Where do instruments come from? | Config file at startup / journaled admin commands | Config for MVP; journaled commands when instruments change at runtime | S7 |
| Snapshots | None / periodic | None in MVP (a day replays in seconds); add when replay time grows | Later |
| Slow WebSocket policy | Disconnect / conflate to snapshot | Conflate, then disconnect if it keeps happening | S11 |
| Post-trade idempotency key | Event seq table / last-seq per partition | Applied-event table: simple, and survives repartitioning | S16 |
| Buying power location | Inside the engine / separate risk stage in the ring | Separate stage before matching (exchange-core does this): same determinism, clearer code | Phase 2 |
Sources
- Martin Fowler, "The LMAX Architecture" (martinfowler.com/articles/lmax.html): single-threaded business logic, event sourcing, Disruptor.
- LMAX Disruptor technical paper and repository: github.com/LMAX-Exchange/disruptor (Apache-2.0).
- exchange-core: github.com/exchange-core/exchange-core (Apache-2.0): README,
OrderBookNaiveImpl,OrderBookDirectImpl. - CoralME: github.com/coralblocks/CoralME (Apache-2.0).
- Liquibook: github.com/enewhuis/liquibook (OCI licence).
- WK Selph, "How to Build a Fast Limit Order Book" (2011): tree of levels + linked lists + order map.
- Aeron and Aeron Cluster: github.com/aeron-io/aeron (Apache-2.0); Agrona: github.com/aeron-io/agrona.
- Chronicle Queue: github.com/OpenHFT/Chronicle-Queue (Apache-2.0).
- Apache Kafka documentation: delivery semantics, idempotent producer.