Event-Driven Design: Strategies, Gateways and Deterministic Replay
Structure a trading system as one ordered event log processed by a single-threaded core, so that any run can be replayed bit for bit, then find the hidden sources of nondeterminism.
A bug you can only fix if you can replay it
A strategy in a simulation sends an order that makes no sense: it buys when its own rules say it should be selling. You have the logs. You rerun the program with the same inputs, and this time it behaves. You rerun it again, and it behaves again. The bug happened once, and the system has no way to make it happen twice.
That is the normal outcome when a system's state depends on things that are not part of its input: which thread got the lock first, what the wall clock said, in which order a hash table happened to store its keys, what was left in a piece of uninitialized memory. Each of these changes from run to run, so a run cannot be reproduced, so the bug cannot be studied.
Trading systems are usually designed the other way round. Everything that can change the state of the core is an event, every event is written to a log in one definite order, and the core processes that log on one thread with no other inputs. Then the state after event N is a function of the first N events and nothing else. Run the same log again and you get the same state, the same trades and the same decisions, bit for bit. That property is called deterministic replay, and this lesson builds a small system that has it, tests that it has it, and then breaks it in the ways real systems break it.
The engine from the matching engine lesson is the core. As always in this course, the participants and their orders are synthetic, and the trading strategy is an illustration of the architecture, not a way to make money.
The shape of a trading system
Seen from outside, an exchange or a trading simulator has the same few parts:
traders --> gateways --> sequencer --> core (matching engine, accounts) --> publisher
| |
+--> event log (the source of truth) market data, fills
|
strategies <------------------------------------------------------------------+- A gateway accepts connections from participants, checks that messages are well formed, and forwards them. There are usually several, for capacity and redundancy.
- The sequencer merges what the gateways forward into one stream, gives every message the next sequence number, and appends it to the event log. After this point there is exactly one order of events, and everybody downstream sees the same one.
- The core applies events from the log one at a time: it matches orders, updates positions and cash. It reads nothing but the log.
- The publisher turns what the core did into outgoing messages: market data (the feed from the previous lesson) and private fill reports.
- A strategy is a program on the participants' side that reacts to those messages. It is written as a set of event handlers:
on_book_updatewhen the best prices change,on_tradewhen a trade happens,on_timerwhen a scheduled time arrives. A handler may return new orders, which go back in through a gateway.
The design rule that makes all of this replayable is short: the core's state is a function of the log. Everything else in the diagram exists to produce the log or to report what the core did with it.
Events as a variant, handlers as a visit
An event is one of a fixed, small set of types. std::variant is the natural representation: a value that holds exactly one of the listed alternatives, stored inline without a heap allocation. std::visit calls the right handler for whichever alternative is present.
#include <cstdint>
#include <print>
#include <variant>
#include <vector>
using Price = std::int64_t; // integer ticks
using Qty = std::int64_t; // whole lots
using OrderId = std::uint64_t;
using Time = std::int64_t; // simulated microseconds since the start of the run
struct Trade { OrderId taker; OrderId maker; Price price; Qty qty; };
// What a strategy can be told about. Each event carries its own timestamp.
struct BookUpdate { Time at; Price best_bid; Price best_ask; };
struct TradeEvent { Time at; Trade trade; };
struct TimerEvent { Time at; int timer_id; };
using Event = std::variant<BookUpdate, TradeEvent, TimerEvent>;
template <class... F> struct Overloaded : F... { using F::operator()...; };
class Strategy {
public:
void on_book_update(const BookUpdate& u) {
mid_ = (u.best_bid + u.best_ask) / 2;
std::println("{:>6} book bid {} ask {} -> mid {}", u.at, u.best_bid,
u.best_ask, mid_);
}
void on_trade(const TradeEvent& t) {
std::println("{:>6} trade {} lots @ {}", t.at, t.trade.qty, t.trade.price);
}
void on_timer(const TimerEvent& t) {
std::println("{:>6} timer #{}: would quote around {}", t.at, t.timer_id, mid_);
}
private:
Price mid_ = 0;
};
// The only place that knows the list of event types and where each one goes.
void dispatch(Strategy& s, const Event& ev) {
std::visit(Overloaded{
[&](const BookUpdate& e) { s.on_book_update(e); },
[&](const TradeEvent& e) { s.on_trade(e); },
[&](const TimerEvent& e) { s.on_timer(e); },
}, ev);
}
int main() {
const std::vector<Event> events = {
BookUpdate{100, 9998, 10002},
TimerEvent{250, 1},
TradeEvent{310, {7, 3, 10002, 4}},
BookUpdate{310, 9998, 10004},
TimerEvent{500, 1},
};
Strategy strategy;
for (const Event& ev : events) dispatch(strategy, ev);
}Press Run to compile and run this example.
100 book bid 9998 ask 10002 -> mid 10000
250 timer #1: would quote around 10000
310 trade 4 lots @ 10002
310 book bid 9998 ask 10004 -> mid 10001
500 timer #1: would quote around 10001The Overloaded helper is the standard trick for building a visitor from lambdas: it inherits from all of them and pulls in all their operator() overloads, so overload resolution picks the lambda whose parameter matches the alternative. Each event carries its own timestamp at; the strategy never asks what time it is.
dispatch is the only function that knows the full list of event types. That matters when the list changes. std::visit requires the visitor to be callable for every alternative, so a new event type that nobody handles is a compile error, not a silently ignored message:
#include <cstdint>
#include <variant>
using Time = std::int64_t;
struct BookUpdate { Time at; };
struct TradeEvent { Time at; };
struct TimerEvent { Time at; }; // added later; dispatch was not updated
using Event = std::variant<BookUpdate, TradeEvent, TimerEvent>;
template <class... F> struct Overloaded : F... { using F::operator()...; };
int handled = 0;
void dispatch(const Event& ev) {
std::visit(Overloaded{
[](const BookUpdate&) { ++handled; },
[](const TradeEvent&) { ++handled; },
}, ev);
}
int main() { dispatch(TimerEvent{0}); }The compiler rejects the call because no lambda accepts a TimerEvent. Keep it that way: a catch-all [](const auto&) {} in the visitor would make this compile again and quietly drop every new event type. The optional, variant and expected lesson covers std::variant itself in more depth.
Time is an input, not a lookup
A strategy that calls std::chrono::system_clock::now() cannot be replayed: the clock gives a different answer every time. In a deterministic core, time is a field of the event being processed, and the core's notion of "now" is the timestamp of the last event it applied. In a live system the gateway stamps each message on receipt; in a simulation the program decides the timestamps.
A simulation does not need to wait for that time to pass. It keeps a queue of scheduled events ordered by timestamp, pops the earliest, sets its clock to that timestamp and handles it. Simulated time jumps from event to event, so a simulated second with seven events takes seven iterations of a loop.
#include <cstdint>
#include <print>
#include <queue>
#include <string>
#include <tuple>
#include <vector>
using Time = std::int64_t; // simulated microseconds
struct Scheduled {
Time at;
std::uint64_t seq; // insertion order: breaks ties between equal times
std::string what;
};
// Earliest time first; for equal times, whatever was scheduled first.
struct Later {
bool operator()(const Scheduled& a, const Scheduled& b) const {
return std::tie(a.at, a.seq) > std::tie(b.at, b.seq);
}
};
class Simulation {
public:
void schedule(Time at, std::string what) {
queue_.push({at, next_seq_++, std::move(what)});
}
Time now() const { return now_; } // the only clock the core may read
// Process events in time order until none are left before `end`.
template <class Handler>
int run_until(Time end, Handler on) {
int processed = 0;
while (!queue_.empty() && queue_.top().at < end) {
Scheduled ev = queue_.top();
queue_.pop();
now_ = ev.at; // time jumps straight to the next event
on(*this, ev);
++processed;
}
now_ = end;
return processed;
}
private:
std::priority_queue<Scheduled, std::vector<Scheduled>, Later> queue_;
std::uint64_t next_seq_ = 0;
Time now_ = 0;
};
int main() {
Simulation sim;
sim.schedule(0, "quote timer");
sim.schedule(400'000, "market data A");
sim.schedule(400'000, "market data B"); // same microsecond as A
sim.schedule(999'999, "end-of-second check");
int processed = sim.run_until(1'000'000, [](Simulation& s, const Scheduled& ev) {
std::println("t={:>7} {}", s.now(), ev.what);
if (ev.what == "quote timer") s.schedule(s.now() + 300'000, "quote timer");
});
std::println("simulated {} us, processed {} events", sim.now(), processed);
}Press Run to compile and run this example.
t= 0 quote timer
t= 300000 quote timer
t= 400000 market data A
t= 400000 market data B
t= 600000 quote timer
t= 900000 quote timer
t= 999999 end-of-second check
simulated 1000000 us, processed 7 eventsTwo things in this program are easy to get wrong. The quote timer reschedules itself from inside its own handler, using s.now(), the simulation's clock, never a real one. And two events have the same timestamp, 400000. std::priority_queue is a heap and makes no promise about the order of elements that compare equal, so the comparison includes seq, the order in which events were scheduled. With that tie-breaker the order is fully defined: A before B, on every compiler and every run. Without it, the order is whatever the heap's implementation happens to produce.
One writer and one order: the sequencer
The sequencer is the point where concurrency stops. Gateways run in parallel and receive messages at the same time; the sequencer turns their streams into a single list in which every message has a definite position. The rule it uses (arrival timestamp, then gateway number for ties) is arbitrary, but it must be fixed, and its output must be recorded, because the order decides outcomes.
#include <algorithm>
#include <cstdint>
#include <print>
#include <tuple>
#include <vector>
using Price = std::int64_t;
using Qty = std::int64_t;
using Time = std::int64_t; // microseconds, as stamped by the receiving gateway
// A buy order as a gateway received it. Each gateway's stream is already in order.
struct Inbound { Time received; int gateway; int trader; Price price; Qty qty; };
struct Sequenced { std::uint64_t seq; Inbound in; };
// The sequencer: merge all gateways into one totally ordered log. Ties on the
// timestamp are broken by gateway number, a rule that is arbitrary but fixed.
std::vector<Sequenced> sequence(std::vector<std::vector<Inbound>> gateways) {
std::vector<Inbound> all;
for (auto& g : gateways) all.insert(all.end(), g.begin(), g.end());
std::ranges::stable_sort(all, {}, [](const Inbound& i) {
return std::tuple(i.received, i.gateway);
});
std::vector<Sequenced> log;
for (const Inbound& i : all) log.push_back({log.size() + 1, i});
return log;
}
int main() {
std::vector<Inbound> gateway1 = {{120, 1, 7, 10002, 5}, {300, 1, 7, 10001, 2}};
std::vector<Inbound> gateway2 = {{120, 2, 9, 10002, 5}, {150, 2, 9, 10003, 1}};
auto log = sequence({gateway1, gateway2});
// The core sees only the log. One sell order of 5 lots rests at 10002.
Qty available = 5;
for (const Sequenced& s : log) {
Qty filled = s.in.price >= 10002 ? std::min(available, s.in.qty) : 0;
available -= filled;
std::println("#{} t={} gw{} trader {}: buy {} @ {} -> filled {}", s.seq,
s.in.received, s.in.gateway, s.in.trader, s.in.qty, s.in.price,
filled);
}
}Press Run to compile and run this example.
#1 t=120 gw1 trader 7: buy 5 @ 10002 -> filled 5
#2 t=120 gw2 trader 9: buy 5 @ 10002 -> filled 0
#3 t=150 gw2 trader 9: buy 1 @ 10003 -> filled 0
#4 t=300 gw1 trader 7: buy 2 @ 10001 -> filled 0Traders 7 and 9 both try to buy the same 5 lots at the same microsecond. The sequencer's tie-break puts gateway 1 first, so trader 7 gets the fill and trader 9 gets nothing. Whether that rule is the fairest one is a business question. The engineering point is that after the sequencer, the question "who was first" has exactly one answer, written in the log, and every later step (the core, a replay, an audit) uses that answer.
Behind the sequencer there is one thread that changes the state of the book and the accounts. This is the single writer principle, which Martin Thompson states as: for any item of data, or resource, that item should be owned by a single execution context for all mutations. Readers can be many; the writer is one. A single writer needs no locks around the book, and its behavior does not depend on thread scheduling, which is exactly what replay requires. The cost is that one core can do only as much as one thread can; systems that need more usually split the work by instrument, with a single writer for each.
A complete simulation, replayed bit for bit
The next program puts the pieces together. It is long because it contains a whole small exchange; read it in this order:
MatchingEngine: lesson 2's rules (price-time priority, fills at the resting order's price, market orders never rest), in compact form.Core: the single writer.applytakes one log entry, runs it through the engine, and settles each trade into the buyer's and seller'sAccount: position (lots held; negative means sold more than bought) and cash (in ticks times lots; paying for a purchase reduces it). It also folds every trade into an FNV-1a hash, the same byte-by-byte hash lesson 2 used.MarketMaker: a market maker keeps both a bid and an ask in the book. This one quotes 5 lots two ticks either side of the mid price (the average of best bid and best ask), shifts both quotes down when it holds a long position and up when it holds a short one, and stops bidding at 20 lots long and offering at 20 lots short. Every 1000 microseconds of simulated time its timer fires: it cancels its old quotes and posts new ones. Its state changes only inside its three handlers.NoiseTraders: four synthetic participants who send random limit and market orders near the last trade price, from a seededLcg. They exist to give the market maker something to trade with.simulate: the event loop. Every 100 microseconds of simulated time, the noise traders and (on its timer) the market maker may produce commands. Each command goes throughsequence, the single writer's entry point: it gets a sequence number, is appended to the log, is hashed into the log hash, and is applied. Trades and the new best prices are then delivered to the strategies.
check states what must hold after every run: running the whole simulation again with the same seed produces the same log (same log hash); replaying just the log into a brand-new Core, with no strategies and no random numbers, produces the same trades (same trade hash); positions and cash over all accounts sum to zero; and the book was never crossed.
#include <algorithm>
#include <array>
#include <cstdint>
#include <deque>
#include <functional>
#include <map>
#include <optional>
#include <print>
#include <unordered_map>
#include <variant>
#include <vector>
using Price = std::int64_t; // integer ticks (a multiple of the tick size);
// never a double
using Qty = std::int64_t; // whole lots
using OrderId = std::uint64_t;
enum class Side : std::uint8_t { Buy, Sell };
struct Order { OrderId id; Side side; Price price; Qty qty; };
struct Trade { OrderId taker; OrderId maker; Price price; Qty qty; };
struct Lcg {
std::uint64_t s;
std::uint32_t next() {
s = s * 6364136223846793005ULL + 1442695040888963407ULL;
return std::uint32_t(s >> 33);
}
int range(int lo, int hi) { // inclusive
return lo + int(next() % std::uint32_t(hi - lo + 1));
}
};
void fnv1a(std::uint64_t& h, std::uint64_t value) { // byte by byte: same on every CPU
for (int i = 0; i < 8; ++i) {
h ^= (value >> (8 * i)) & 0xff;
h *= 1099511628211ULL;
}
}
using Time = std::int64_t; // simulated microseconds
using TraderId = std::uint32_t;
// What gateways send in. The sequencer stamps each one and appends it to the log.
struct NewOrder { TraderId trader; Order order; bool market; };
struct CancelOrder { TraderId trader; OrderId id; };
using Command = std::variant<NewOrder, CancelOrder>;
struct LogEntry { std::uint64_t seq; Time at; Command cmd; };
class MatchingEngine { // price-time priority; fills at the resting order's price
public:
std::vector<Trade> submit(Order o, bool market) {
std::vector<Trade> out;
if (o.side == Side::Buy) match(o, asks_, market, out);
else match(o, bids_, market, out);
if (o.qty > 0 && !market) { // a market order's remainder is dropped
if (o.side == Side::Buy) bids_[o.price].push_back(o);
else asks_[o.price].push_back(o);
}
return out;
}
void cancel(OrderId id, Side side, Price price) {
if (side == Side::Buy) remove(bids_, id, price);
else remove(asks_, id, price);
}
std::optional<Price> best_bid() const {
return bids_.empty() ? std::nullopt : std::optional(bids_.begin()->first);
}
std::optional<Price> best_ask() const {
return asks_.empty() ? std::nullopt : std::optional(asks_.begin()->first);
}
private:
template <class Book>
static void match(Order& o, Book& book, bool market, std::vector<Trade>& out) {
auto crosses = [&](Price p) {
return market || (o.side == Side::Buy ? p <= o.price : p >= o.price);
};
while (o.qty > 0 && !book.empty() && crosses(book.begin()->first)) {
auto& queue = book.begin()->second;
Order& maker = queue.front();
Qty q = std::min(o.qty, maker.qty);
out.push_back({o.id, maker.id, maker.price, q});
o.qty -= q;
if ((maker.qty -= q) == 0) queue.pop_front();
if (queue.empty()) book.erase(book.begin());
}
}
template <class Book> static void remove(Book& book, OrderId id, Price price) {
auto level = book.find(price);
if (level == book.end()) return;
std::erase_if(level->second, [&](const Order& o) { return o.id == id; });
if (level->second.empty()) book.erase(level);
}
std::map<Price, std::deque<Order>, std::greater<>> bids_;
std::map<Price, std::deque<Order>> asks_;
};
struct Account { Qty position = 0; std::int64_t cash = 0; }; // cash in ticks x lots
// The single writer: all engine and account state changes go through apply().
class Core {
public:
std::vector<Trade> apply(const LogEntry& e) {
std::vector<Trade> trades;
if (auto* c = std::get_if<NewOrder>(&e.cmd)) {
info_[c->order.id] = {c->trader, c->order.side, c->order.price};
trades = engine.submit(c->order, c->market);
} else {
auto& cancel = std::get<CancelOrder>(e.cmd);
auto it = info_.find(cancel.id);
if (it != info_.end() && it->second.trader == cancel.trader)
engine.cancel(cancel.id, it->second.side, it->second.price);
}
for (const Trade& t : trades) settle(t);
return trades;
}
MatchingEngine engine;
std::array<Account, 5> accounts{};
std::uint64_t hash = 14695981039346656037ULL; // FNV-1a over every trade
private:
void settle(const Trade& t) {
const Info& taker = info_.at(t.taker);
const Info& maker = info_.at(t.maker);
Account& buyer = accounts[taker.side == Side::Buy ? taker.trader : maker.trader];
Account& seller = accounts[taker.side == Side::Buy ? maker.trader : taker.trader];
buyer.position += t.qty;
buyer.cash -= t.price * t.qty;
seller.position -= t.qty;
seller.cash += t.price * t.qty;
for (auto v : {t.taker, t.maker, std::uint64_t(t.price), std::uint64_t(t.qty)})
fnv1a(hash, v);
}
struct Info { TraderId trader; Side side; Price price; };
std::unordered_map<OrderId, Info> info_; // looked up, never iterated
};
// Educational only: one bid and one ask around the mid, leaning against inventory.
// Its state changes only inside its handlers.
class MarketMaker {
public:
static constexpr TraderId kId = 0;
void on_book_update(std::optional<Price> bid, std::optional<Price> ask) {
if (bid && ask) mid_ = (*bid + *ask) / 2;
}
void on_trade(const Trade& t) {
for (OrderId id : {t.taker, t.maker})
if (auto it = mine_.find(id); it != mine_.end())
position_ += it->second == Side::Buy ? t.qty : -t.qty;
}
std::vector<Command> on_timer() {
std::vector<Command> out;
for (const auto& [id, side] : mine_) out.push_back(CancelOrder{kId, id});
mine_.clear();
Price skew = -position_ / 5; // long: quote lower, so that it sells more
if (position_ < 20) out.push_back(quote(Side::Buy, mid_ - 2 + skew));
if (position_ > -20) out.push_back(quote(Side::Sell, mid_ + 2 + skew));
return out;
}
private:
Command quote(Side side, Price price) {
OrderId id = (OrderId(kId) << 32) | ++count_;
mine_[id] = side;
return NewOrder{kId, {id, side, price, 5}, false};
}
std::map<OrderId, Side> mine_; // ordered: cancels come out in a fixed order
Price mid_ = 10000;
Qty position_ = 0;
std::uint32_t count_ = 0;
};
// Traders 1 to 4: random limit and market orders near the last trade price.
class NoiseTraders {
public:
explicit NoiseTraders(std::uint64_t seed) : rng_{seed} {}
void on_trade(const Trade& t) { last_ = t.price; }
std::vector<Command> on_tick() {
if (rng_.range(0, 1) == 0) return {}; // nothing this tick
auto trader = TraderId(rng_.range(1, 4));
Side side = rng_.range(0, 1) ? Side::Buy : Side::Sell;
OrderId id = (OrderId(trader) << 32) | ++count_;
if (rng_.range(0, 9) < 3) // market order
return {NewOrder{trader, {id, side, 0, rng_.range(1, 5)}, true}};
Price away = rng_.range(0, 4);
Price price = side == Side::Buy ? last_ - away : last_ + away;
Qty qty = rng_.range(1, 10);
std::vector<Command> out{NewOrder{trader, {id, side, price, qty}, false}};
resting_.push_back(CancelOrder{trader, id});
if (resting_.size() > 30) { // cancel the oldest, so the book stays small
out.push_back(resting_.front());
resting_.pop_front();
}
return out;
}
private:
Lcg rng_;
Price last_ = 10000;
std::uint32_t count_ = 0;
std::deque<CancelOrder> resting_;
};
struct Result {
std::vector<LogEntry> log;
Core core;
std::uint64_t log_hash;
int crossed; // times the book was crossed after a command: must stay 0
};
Result simulate(std::uint64_t seed, Time duration) {
Result r{{}, {}, 14695981039346656037ULL, 0};
MarketMaker maker;
NoiseTraders noise(seed);
auto sequence = [&](Time now, const Command& cmd) { // the one writer
const LogEntry& e = r.log.emplace_back(r.log.size() + 1, now, cmd);
auto* n = std::get_if<NewOrder>(&cmd);
Order o = n ? n->order : Order{std::get<CancelOrder>(cmd).id, Side::Buy, 0, 0};
TraderId trader = n ? n->trader : std::get<CancelOrder>(cmd).trader;
for (auto v : {e.seq, std::uint64_t(now), std::uint64_t(cmd.index()),
std::uint64_t(trader), o.id, std::uint64_t(o.side),
std::uint64_t(o.price), std::uint64_t(o.qty),
std::uint64_t(n && n->market)})
fnv1a(r.log_hash, v); // every field of the entry
for (const Trade& t : r.core.apply(e)) {
maker.on_trade(t);
noise.on_trade(t);
}
auto bid = r.core.engine.best_bid(), ask = r.core.engine.best_ask();
r.crossed += bid && ask && *bid >= *ask;
maker.on_book_update(bid, ask);
};
for (Time now = 0; now < duration; now += 100) { // a tick every 100 us
for (const Command& c : noise.on_tick()) sequence(now, c);
if (now % 1000 == 0)
for (const Command& c : maker.on_timer()) sequence(now, c);
}
return r;
}
bool check(const Result& r, const Result& again) {
Core replay; // fed only the log: no strategies, no random numbers
for (const LogEntry& e : r.log) replay.apply(e);
Qty positions = 0;
std::int64_t cash = 0;
for (const Account& a : r.core.accounts) {
positions += a.position;
cash += a.cash;
}
return again.log_hash == r.log_hash && replay.hash == r.core.hash && positions == 0 &&
cash == 0 && r.crossed == 0;
}
int main() {
Result r = simulate(42, 1'000'000); // one simulated second
const Account& m = r.core.accounts[MarketMaker::kId];
std::println("log entries {}, maker position {}, maker cash {} (ticks x lots)",
r.log.size(), m.position, m.cash);
std::println("log hash {:016x}, trade hash {:016x}", r.log_hash, r.core.hash);
std::println("seed 42 replays and balances: {}", check(r, simulate(42, 1'000'000)));
int passed = 0;
for (std::uint64_t seed = 1; seed <= 30; ++seed)
passed += check(simulate(seed, 100'000), simulate(seed, 100'000));
std::println("seeds 1 to 30: {} of 30 pass", passed);
}Press Run to compile and run this example.
log entries 12314, maker position -5, maker cash 50078 (ticks x lots)
log hash be8c3a4674005ad2, trade hash ef2a540351fd6d0c
seed 42 replays and balances: true
seeds 1 to 30: 30 of 30 passOne simulated second produced 12,314 log entries. The market maker ended with a short position of 5 lots and positive cash. Do not read the cash as a result: it is what the maker received for selling more than it bought, and what that short position is worth depends on the price at which it would be bought back. On synthetic data from a random generator, the number says nothing about any real strategy.
The hashes are the point. The whole simulation, rerun, wrote the same log. The log alone, replayed through a fresh core, produced the same trades. Thirty more seeds pass the same checks. The two sums are a useful invariant for any exchange simulation: every trade moves the same quantity and the same cash from one account to another, so totals over all accounts are always zero. A settlement bug, such as charging the buyer the taker's limit price instead of the trade price, breaks it at once.
Replay from the log needs only the core. The strategies, the random generator and the timers are not involved, because their influence on the core is entirely contained in the commands they produced. That is what "the log is the source of truth" means in practice: you can ship a log from a production incident to a developer's machine and step through the core's behavior exactly as it happened.