Reactive Application Model¶
TL;DR
An Aurum strategy is an event-driven program: you declare what data you consume and what effects you produce. The SDK orders events by logical time and delivers them on a single engine thread. You never spawn threads, poll for data, or manage event queues. Your job is to write handlers that react to typed events and submit typed effects.
Why this matters¶
Imagine writing a trading strategy without a framework. You have:
A WebSocket connection receiving market data at unpredictable rates.
An exchange API that accepts orders and returns fills asynchronously.
A timer that fires every 30 seconds for a heartbeat.
A need to record every decision for later replay and audit.
Without a framework, you are responsible for: managing threads, ordering events from different sources, handling reconnection, buffering during backpressure, and ensuring that a crash during an order submission does not leave the system in an unknown state. Each of these is a source of subtle, production-only bugs.
Aurum’s reactive model eliminates these concerns by giving you a single abstraction: you declare sources and handlers; the engine orders and delivers.
Note
If you have used reactive frameworks before (RxJava, ReactiveX, Combine), many of these concepts will be familiar. The Aurum model is closer to a deterministic actor system than a general-purpose reactive stream processor.
Events and current values¶
Aurum distinguishes two contracts for consuming data:
on(TOPIC<...>, handler)Reacts to each value delivered on a typed topic. Every bar, every trade, every order event invokes the handler once. Use this when you need to process every event (e.g., updating a position on each fill).
ref(TOPIC<...>)Reads the latest cached value of a resource. Returns an optional — empty if no value has arrived yet. Use this when you need the current state but don’t need to react to every update (e.g., reading the latest factor value on a timer).
// React to every confirmed bar — process each event.
on(TOPIC<"bar://okx/BTCUSDT@1m">,
[](const Tick<schema::BarRow>& tick) {
if (tick.payload.close_timestamp == 0) return;
log::info("close={}", tick.payload.close.to_string());
});
// Read the latest spread factor when you need it.
auto spread = ref(TOPIC<"factor://spread/btcusdt.okx@bar:1m">);
if (spread) {
log::info("current spread={}", *spread);
}
Warning
Event delivery and latest-value access are different contracts. An
append-only order event stream is not equivalent to a cached market value.
Do not use ref on an append-only topic expecting to see every event —
you will miss data between reads.
Logical time¶
This is the most important concept in the Aurum execution model, and the one most likely to cause confusion if misunderstood.
The scheduler orders admitted source events, timers, deadlines, and controls on the engine thread. Handlers observe engine time rather than assuming that wall-clock arrival order is the business order.
Here is why this matters. Imagine three events arrive at your process:
Wall clock: 12:00:00.100 — Bar(close=50000) arrives from OKX WebSocket
Wall clock: 12:00:00.150 — Your timer fires (scheduled for 12:00:00.000)
Wall clock: 12:00:00.200 — Bar(close=50001) arrives from OKX WebSocket
If you process events in wall-clock arrival order, your timer handler sees
close=50000. But if the timer was scheduled for 12:00:00.000, it should see
the state before any 12:00:00.100 event.
Aurum’s engine assigns each event a logical timestamp based on the source that produced it. The scheduler orders by logical time, not by network arrival time. Your timer at 12:00:00.000 fires before the bar at 12:00:00.100, regardless of when the network delivered each event.
Note
In LIVE mode, logical time follows live inputs under the configured
late-event policy. In REPLAY mode, eligible historical events drive
logical time; ReplayConfig::speed controls wall-clock pacing, not event
timestamps.
The practical implication: do not use ``std::chrono::system_clock::now()`` in
strategy logic. Use context.engine_time_ns for decisions that depend on
time ordering. Wall-clock time is for logging and performance measurement, not
for trading decisions.
Effects and receipts¶
Reading data is passive; making things happen requires effects. Submitting an order, querying account state, or cancelling an order are all effects.
An effect returns a typed receipt with named outcome branches:
auto receipt = executor.submit(Order{
.instrument_id = "BTCUSDT",
.type = OrderType::Market,
.side = OrderSide::Buy,
.tif = TimeInForce::Ioc,
.quantity = Quantity{0.01},
});
on(receipt.accepted) = [](const OrderAccepted&) {
log::info("order accepted by venue");
};
on(receipt.rejected) = [](const OrderDenied& denied) {
log::warn("order rejected: {}", denied.reason);
};
on(receipt.filled) = [](const OrderFilled& filled) {
log::info("order filled: qty={}", filled.order.quantity);
};
on(receipt.err) = [](const core::QueryErrorInfo& error) {
log::error("submit failed: {}", error.message);
};
Warning
The submit call confirms local admission to the effect pipeline — it
does not mean the exchange accepted the order. An order can be rejected by
the venue seconds after local submission. Always handle every terminal
outcome relevant to your risk model. A strategy that only handles
accepted and ignores rejected, timeout, and err will
silently lose state.
A receipt exposes these branches (not all apply to every effect):
submitted— local admission to the pipeline.accepted— venue confirmed the order.rejected— venue or risk check denied the order.partial_filled— partial execution.filled— complete execution.canceled— cancellation confirmed.timeout— no response within the configured window.err— local infrastructure error (network, serialization, etc.).
Policies¶
Effect policies express the operational semantics of your effect:
Timeouts: how long to wait for a venue response.
Retry: whether and how to retry on timeout or transient error.
Idempotency: whether replaying the effect is safe.
Serialization: whether effects to the same instrument must be ordered.
Latest-request-wins: whether a new submission cancels the previous one.
Backlog limits: how many unacknowledged effects to allow.
Tip
Retrying an idempotent state query (e.g., “what is my BTC balance?”) is safe — the worst case is wasted work. Retrying an order submission without idempotency is dangerous — you may submit the same order twice. Apply policies according to the semantics of the operation, not a blanket rule.
Runtime modes¶
Aurum supports two runtime modes, chosen at construction:
// LIVE: follow real-time market data, submit to real or paper venues.
sdk::Application({.mode = sdk::RuntimeMode::LIVE})
// REPLAY: replay recorded events through the same strategy code.
sdk::Application({
.mode = sdk::RuntimeMode::REPLAY,
.replay = ReplayConfig{.speed = 0.0},
})
Dimension |
LIVE |
REPLAY |
|---|---|---|
Data source |
Live venue WebSocket |
Previously recorded events |
Logical time |
Follows live inputs + late-event policy |
Follows event timestamps |
Order submission |
Real venue or paper account |
Paper account (typically) |
Wall-clock pacing |
Real-time |
Controlled by |
Use case |
Production trading, live paper testing |
Backtesting, debugging, validation |
Note
speed = 0 in REPLAY mode processes events as fast as the CPU allows.
speed = 1.0 replays at real-time pace. Values between 0 and 1 are valid
for time-lapse debugging.
Determinism boundary¶
Replay can be deterministic when the application uses the same ordered input, configuration, and effect results. But determinism has a boundary:
- Inside the boundary (deterministic when inputs match):
Event ordering by logical time.
Handler execution order.
Factor computation from source data.
Paper fill simulation from bar data.
- Outside the boundary (always non-deterministic):
External network calls (REST APIs, WebSocket connections).
Wall-clock reads (
system_clock::now()).Unseeded randomness (
std::random_device).Mutable out-of-process state (files, databases).
Warning
If your strategy makes an HTTP request to an external service inside a handler, that handler will produce different results on replay — the external service won’t return the same data it returned during the live run. Isolate external calls and record their responses if you need deterministic replay.
The determinism contract is: Aurum guarantees replay fidelity for the engine path. You control the rest. If you need a network call to be replayable, record the response during the live run and replay it during REPLAY mode.
Next steps¶
To understand how resources and the catalog connect to this model, read Resources, Live Data, and History.
To see the C++ API for handlers, effects, and schedules, read C++ SDK Reference.
To apply this to a paper trading strategy, read Simulation and Replay.