rustrade_execution/exchange/mock/venue/snapshot.rs
1//! A [`SimulatedVenue`]'s state as plain data, and the venue it restores.
2
3use super::price_window::PriceWindow;
4use super::{NetPosition, SimulatedVenue, VenueRegime, liquidation::LiquidationState};
5use crate::{
6 balance::MarginDetails,
7 client::mock::MockExecutionConfig,
8 exchange::mock::account::{AccountState, AccountStateSnapshot, DuplicateSeededOrder},
9 short_option::Collateral,
10 slippage::Slippage,
11 stored::FormatVersion,
12};
13use chrono::{DateTime, Utc};
14use fnv::FnvHashMap;
15use itertools::Itertools;
16use rust_decimal::Decimal;
17use rustrade_instrument::{
18 asset::name::AssetNameExchange,
19 exchange::ExchangeId,
20 instrument::{
21 Instrument,
22 kind::{
23 InstrumentKind,
24 option::{OptionKind, OptionSettlement},
25 },
26 name::InstrumentNameExchange,
27 },
28};
29use serde::{Deserialize, Serialize};
30use std::{
31 collections::{BTreeSet, HashSet},
32 hash::Hash,
33};
34use thiserror::Error;
35
36/// Everything a [`SimulatedVenue`] has accumulated since it was built, as a value serde can write,
37/// from which [`SimulatedVenue::from_snapshot`] builds a venue that carries on exactly as this one
38/// would have.
39///
40/// Taken by [`SimulatedVenue::snapshot`]. When and where to keep one is the caller's choice.
41///
42/// # What it holds
43/// - The account: every balance with its loan and unpaid interest, every open and held order with
44/// what is held against it and its place in the queue, the orders that ended, and the trades.
45/// - The net position of each cash-settled instrument, with its entry price.
46/// - The order, trade and cash-flow id sequences, so no id the venue mints after a restore repeats
47/// one minted before it.
48/// - The venue's clock: the latest instant it reached and the instant up to which it has charged
49/// interest and holding costs.
50/// - The debt it last reported, whether a liquidation is under way or a margin call outstanding,
51/// the prices over the borrow price window, and how far the slippage stream has drawn.
52///
53/// # What it does not hold
54/// - **The configuration**, and the models set by builder methods (holding costs, margin interest,
55/// a fill model, shortability): code, not data. Restore with the configuration and models the
56/// venue ran with. A changed fee model or interest model applies from the restore on.
57/// - **The market**: what the venue last observed is stale by the time it is restored. A restored
58/// venue holds no market until its driver feeds one, so it values and matches nothing before
59/// then, as a fresh venue does not.
60/// - **Warnings it gave once**, which a restored venue gives again.
61///
62/// # The time between the snapshot and the restore
63/// A restored venue resumes from the instant recorded:
64/// - **Interest and holding costs** are charged for every boundary after it, once its clock first
65/// moves, as for a host that slept through them.
66/// - **A resting order** is matched against the first market observed after the restore, as on
67/// any observation. A price that crossed it and turned back while the venue was not running was
68/// never observed, so the order did not fill.
69///
70/// # Versions
71/// Carries a format [`version`](Self::version). In a self-describing format such as JSON, fields a
72/// later release adds are read with defaults from an older snapshot. A change an older snapshot
73/// cannot be read under raises the version, and [`SimulatedVenue::from_snapshot`] refuses a
74/// version other than [`VERSION`](Self::VERSION).
75///
76/// A snapshot whose shape this release cannot parse fails in the deserialiser, before it reaches
77/// the venue. Read one as a [`Stored`](crate::stored::Stored) snapshot to be told the version it
78/// was written in instead.
79///
80/// `Ord` and `Hash` exist so it can sit in an engine output, which requires them, and are not a
81/// meaningful ranking.
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
83pub struct SimulatedVenueSnapshot {
84 version: u32,
85 exchange: ExchangeId,
86 regime: VenueRegime,
87 /// Sorted.
88 instruments: Vec<InstrumentNameExchange>,
89 account: AccountStateSnapshot,
90 /// Sorted by instrument.
91 positions: Vec<(InstrumentNameExchange, NetPosition)>,
92 order_sequence: u64,
93 /// Written as a string: a number this wide does not survive every reader, such as a
94 /// [`Stored`](crate::stored::Stored) snapshot read from JSON.
95 #[serde(with = "u128_as_string")]
96 order_id_start: u128,
97 trade_sequence: u64,
98 cash_flow_sequence: u64,
99 time_exchange: DateTime<Utc>,
100 accrued_through: Option<DateTime<Utc>>,
101 /// Sorted by asset.
102 reported_debt: Vec<(AssetNameExchange, MarginDetails)>,
103 liquidation_under_way: bool,
104 margin_called: bool,
105 /// Sorted by instrument.
106 borrow_prices: Vec<(InstrumentNameExchange, PriceWindow)>,
107 slippage_draws: u64,
108 checkpoint_sequence: u64,
109}
110
111impl SimulatedVenueSnapshot {
112 /// The format version this release writes, and the only one it restores.
113 pub const VERSION: u32 = 1;
114
115 /// The format version this snapshot was written in.
116 pub fn version(&self) -> u32 {
117 self.version
118 }
119
120 /// The exchange the venue simulated.
121 pub fn exchange(&self) -> ExchangeId {
122 self.exchange
123 }
124
125 /// The venue's price source, which the restored venue keeps.
126 pub fn regime(&self) -> VenueRegime {
127 self.regime
128 }
129
130 /// The latest instant the venue's clock had reached.
131 pub fn time_exchange(&self) -> DateTime<Utc> {
132 self.time_exchange
133 }
134
135 /// The net position of each cash-settled instrument the venue held one in, in contracts:
136 /// positive long, negative short. A flat instrument is not listed, nor is a spot holding, which
137 /// the venue holds as an asset balance.
138 pub fn positions(&self) -> impl Iterator<Item = (&InstrumentNameExchange, Decimal)> + '_ {
139 self.positions
140 .iter()
141 .map(|(instrument, position)| (instrument, position.quantity))
142 }
143
144 /// The instant up to which the venue had charged every interest and holding-cost boundary, or
145 /// `None` if its clock had never moved.
146 pub fn accrued_through(&self) -> Option<DateTime<Utc>> {
147 self.accrued_through
148 }
149}
150
151/// A [`SimulatedVenueSnapshot`] taken at one point in the venue's account stream, and sent on it as
152/// an [`AccountEventKind::VenueCheckpoint`](crate::AccountEventKind::VenueCheckpoint): every event
153/// the venue sent before it is in the snapshot, and none sent after it.
154///
155/// Made by [`SimulatedVenue::checkpoint`].
156#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
157pub struct VenueCheckpoint {
158 /// Numbers the venue's checkpoints from one, counting across restores, so a caller can tell
159 /// the checkpoint it asked for from an earlier one.
160 pub id: u64,
161 /// The venue's state at this point in its stream.
162 pub snapshot: SimulatedVenueSnapshot,
163}
164
165/// Why [`SimulatedVenue::from_snapshot`] refused a [`SimulatedVenueSnapshot`].
166#[derive(Debug, Clone, PartialEq, Eq, Error)]
167#[non_exhaustive]
168pub enum VenueRestoreError {
169 /// The snapshot was written in a format version this release does not read.
170 #[error("venue snapshot is format version {found}; this release reads version {supported}")]
171 UnsupportedVersion { found: u32, supported: u32 },
172
173 /// The snapshot is of another exchange than the configuration.
174 #[error("venue snapshot is of {snapshot}, but the configuration simulates {config}")]
175 ExchangeMismatch {
176 snapshot: ExchangeId,
177 config: ExchangeId,
178 },
179
180 /// The snapshot's venue traded other instruments than the venue being restored.
181 #[error(
182 "venue snapshot traded other instruments than the venue being restored: only in the \
183 snapshot {only_in_snapshot:?}, only in the venue {only_in_venue:?}"
184 )]
185 InstrumentsMismatch {
186 only_in_snapshot: Vec<InstrumentNameExchange>,
187 only_in_venue: Vec<InstrumentNameExchange>,
188 },
189
190 /// The snapshot names an instrument it does not list among those it traded.
191 #[error("venue snapshot names {0}, which it does not list among its instruments")]
192 UnknownInstrument(InstrumentNameExchange),
193
194 /// The snapshot was of a margin account and the configuration lends nothing, or the other way
195 /// round.
196 #[error(
197 "venue snapshot is of a {} account, but the configuration {}",
198 if *snapshot_margin { "margin" } else { "cash" },
199 if *snapshot_margin { "sets no spot_margin" } else { "sets spot_margin" }
200 )]
201 MarginMismatch { snapshot_margin: bool },
202
203 /// The snapshot lists one order id twice.
204 #[error(transparent)]
205 DuplicateOrder(#[from] DuplicateSeededOrder),
206
207 /// The snapshot lists one instrument twice among its positions, or among its borrow price
208 /// windows.
209 #[error("venue snapshot lists {0} twice")]
210 DuplicateInstrument(InstrumentNameExchange),
211
212 /// The snapshot lists one asset twice among its balances, or among the debt it last reported.
213 #[error("venue snapshot lists {0} twice")]
214 DuplicateAsset(AssetNameExchange),
215
216 /// The snapshot holds a short position in a cash-settled call, which nothing in a cash account
217 /// secures, so the venue never holds one.
218 #[error(
219 "venue snapshot holds the cash-settled call {0} short, which SimulatedVenue never holds"
220 )]
221 OptionShortUnsecurable(InstrumentNameExchange),
222
223 /// The snapshot's balances do not hold the collateral its short options secure: what an
224 /// asset's balance holds, net of what its open orders reserve, is less than that collateral.
225 /// A snapshot the venue wrote always holds it.
226 #[error(
227 "venue snapshot's short options secure {required} {asset}, but its balance holds only \
228 {held} beyond what its open orders reserve"
229 )]
230 OptionCollateralNotHeld {
231 asset: AssetNameExchange,
232 required: Decimal,
233 held: Decimal,
234 },
235}
236
237impl FormatVersion for SimulatedVenueSnapshot {
238 // The inherent constant: an inherent item takes priority over the trait's, so this does not
239 // refer to itself.
240 const VERSION: u32 = Self::VERSION;
241
242 fn format_version(&self) -> u32 {
243 self.version
244 }
245}
246
247/// A `u128` written as a decimal string.
248mod u128_as_string {
249 use serde::{Deserialize, Deserializer, Serializer, de::Error};
250
251 pub(super) fn serialize<S: Serializer>(value: &u128, serializer: S) -> Result<S::Ok, S::Error> {
252 serializer.collect_str(value)
253 }
254
255 pub(super) fn deserialize<'de, D: Deserializer<'de>>(
256 deserializer: D,
257 ) -> Result<u128, D::Error> {
258 let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
259 text.parse().map_err(D::Error::custom)
260 }
261}
262
263/// The first item `items` yields that an earlier one equals.
264fn first_repeat<'a, T: Eq + Hash + 'a>(items: impl IntoIterator<Item = &'a T>) -> Option<&'a T> {
265 let mut seen = HashSet::new();
266 items.into_iter().find(|item| !seen.insert(*item))
267}
268
269impl SimulatedVenue {
270 /// This venue's state as plain data: see [`SimulatedVenueSnapshot`].
271 ///
272 /// Taken between inputs, so it reflects every input applied so far and nothing of the next.
273 pub fn snapshot(&self) -> SimulatedVenueSnapshot {
274 SimulatedVenueSnapshot {
275 version: SimulatedVenueSnapshot::VERSION,
276 exchange: self.exchange,
277 regime: self.regime,
278 instruments: self.instruments.keys().cloned().sorted_unstable().collect(),
279 account: self.account.snapshot(),
280 positions: self
281 .positions
282 .iter()
283 .map(|(instrument, position)| (instrument.clone(), *position))
284 .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b))
285 .collect(),
286 order_sequence: self.order_sequence,
287 order_id_start: self.order_id_start,
288 trade_sequence: self.trade_sequence,
289 cash_flow_sequence: self.cash_flow_sequence,
290 time_exchange: self.time_exchange_latest,
291 accrued_through: self.accrued_through,
292 reported_debt: self
293 .reported_debt
294 .iter()
295 .map(|(asset, debt)| (asset.clone(), *debt))
296 .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b))
297 .collect(),
298 liquidation_under_way: self.liquidation.under_way(),
299 margin_called: self.liquidation.margin_called(),
300 borrow_prices: self
301 .borrow_prices
302 .iter()
303 .map(|(instrument, window)| (instrument.clone(), window.clone()))
304 .sorted_unstable_by(|(a, _), (b, _)| a.cmp(b))
305 .collect(),
306 slippage_draws: self.slippage.draws(),
307 checkpoint_sequence: self.checkpoint_sequence,
308 }
309 }
310
311 /// This venue's state, numbered as its next checkpoint: see [`VenueCheckpoint`].
312 ///
313 /// A driver sends it as an
314 /// [`AccountEventKind::VenueCheckpoint`](crate::AccountEventKind::VenueCheckpoint) in order
315 /// with the venue's other output, so whoever applies the stream up to it holds what the
316 /// snapshot holds.
317 pub fn checkpoint(&mut self) -> VenueCheckpoint {
318 self.checkpoint_sequence += 1;
319 VenueCheckpoint {
320 id: self.checkpoint_sequence,
321 snapshot: self.snapshot(),
322 }
323 }
324
325 /// Checks that each asset's balance holds the collateral this venue's short options secure,
326 /// beyond what its open orders reserve. A balance holds `total − free`; the collateral itself
327 /// is derived from the positions, so it is not stored, and a snapshot whose balances do not
328 /// hold it would let an assignment spend balance the account never set aside.
329 ///
330 /// A lower bound: anything else a hand-edited snapshot holds out of `free` counts towards the
331 /// collateral too. A snapshot this venue wrote holds exactly its orders' reservations and
332 /// the collateral.
333 fn check_collateral_held(&self) -> Result<(), VenueRestoreError> {
334 let mut required: FnvHashMap<AssetNameExchange, Decimal> = FnvHashMap::default();
335 for Collateral { asset, amount } in self
336 .positions
337 .keys()
338 .filter_map(|name| self.collateral_held(name))
339 {
340 *required.entry(asset).or_default() += amount;
341 }
342 let mut reserved: FnvHashMap<AssetNameExchange, Decimal> = FnvHashMap::default();
343 for entry in self.account.orders().entries() {
344 if let Some(reservation) = entry.resting.reservation {
345 *reserved.entry(reservation.asset).or_default() += reservation.amount;
346 }
347 }
348 for (asset, required) in required.into_iter().sorted() {
349 let held = self
350 .account
351 .balances()
352 .find(|balance| balance.asset == asset)
353 .map_or(Decimal::ZERO, |balance| {
354 balance.balance.total - balance.balance.free
355 })
356 - reserved.get(&asset).copied().unwrap_or_default();
357 if held < required {
358 return Err(VenueRestoreError::OptionCollateralNotHeld {
359 asset,
360 required,
361 held,
362 });
363 }
364 }
365 Ok(())
366 }
367
368 /// The venue `snapshot` was taken of, configured by `config` and trading `instruments`, which
369 /// carries on exactly as that venue would have: see [`SimulatedVenueSnapshot`] for what it
370 /// restores and what happens to the time in between.
371 ///
372 /// Takes the regime the snapshot's venue had; a driver checks [`regime`](Self::regime) is the
373 /// one it drives. `config.initial_state` is not read: the snapshot's account replaces it. Add
374 /// the models the venue ran with through the same builder methods as a fresh venue, such as
375 /// [`with_margin_interest`](Self::with_margin_interest), which then charge from the recorded
376 /// instant.
377 ///
378 /// # Errors
379 /// - [`VenueRestoreError::UnsupportedVersion`] for a snapshot in another format version.
380 /// - [`VenueRestoreError::ExchangeMismatch`] if `config` simulates another exchange.
381 /// - [`VenueRestoreError::InstrumentsMismatch`] unless `instruments` are exactly the ones the
382 /// snapshot's venue traded.
383 /// - [`VenueRestoreError::UnknownInstrument`] if the snapshot names an instrument it does not
384 /// list.
385 /// - [`VenueRestoreError::MarginMismatch`] if the snapshot is of a margin account and `config`
386 /// sets no `spot_margin`, or the other way round.
387 /// - [`VenueRestoreError::DuplicateOrder`] if the snapshot lists one order id twice among its
388 /// open orders, or among its cancelled, filled or expired ones.
389 /// - [`VenueRestoreError::DuplicateInstrument`] or [`VenueRestoreError::DuplicateAsset`] if it
390 /// lists one instrument or asset twice in a list that holds each once.
391 /// - [`VenueRestoreError::OptionShortUnsecurable`] if it holds a short cash-settled call.
392 /// - [`VenueRestoreError::OptionCollateralNotHeld`] if its balances do not hold the collateral
393 /// its short options secure.
394 ///
395 /// # Cost
396 /// The slippage stream is resumed by drawing again every value the snapshot's venue drew, one
397 /// per fill it slipped, so a restore takes time in proportion to the snapshot's fills. A
398 /// snapshot is trusted input: one edited to claim a vast number of draws restores as slowly.
399 pub fn from_snapshot(
400 config: &MockExecutionConfig,
401 instruments: FnvHashMap<InstrumentNameExchange, Instrument<ExchangeId, AssetNameExchange>>,
402 snapshot: SimulatedVenueSnapshot,
403 ) -> Result<Self, VenueRestoreError> {
404 if snapshot.version != SimulatedVenueSnapshot::VERSION {
405 return Err(VenueRestoreError::UnsupportedVersion {
406 found: snapshot.version,
407 supported: SimulatedVenueSnapshot::VERSION,
408 });
409 }
410 if snapshot.exchange != config.mocked_exchange {
411 return Err(VenueRestoreError::ExchangeMismatch {
412 snapshot: snapshot.exchange,
413 config: config.mocked_exchange,
414 });
415 }
416
417 let listed = snapshot.instruments.iter().collect::<BTreeSet<_>>();
418 let trading = instruments.keys().collect::<BTreeSet<_>>();
419 if listed != trading {
420 return Err(VenueRestoreError::InstrumentsMismatch {
421 only_in_snapshot: listed
422 .difference(&trading)
423 .map(|&name| name.clone())
424 .collect(),
425 only_in_venue: trading
426 .difference(&listed)
427 .map(|&name| name.clone())
428 .collect(),
429 });
430 }
431 if let Some(unknown) = snapshot
432 .account
433 .instruments()
434 .chain(snapshot.positions.iter().map(|(instrument, _)| instrument))
435 .chain(
436 snapshot
437 .borrow_prices
438 .iter()
439 .map(|(instrument, _)| instrument),
440 )
441 .find(|instrument| !instruments.contains_key(*instrument))
442 {
443 return Err(VenueRestoreError::UnknownInstrument(unknown.clone()));
444 }
445 // Each list below becomes a map, which would keep one of two entries and drop the other.
446 if let Some(repeat) = first_repeat(snapshot.positions.iter().map(|(name, _)| name))
447 .or_else(|| first_repeat(snapshot.borrow_prices.iter().map(|(name, _)| name)))
448 {
449 return Err(VenueRestoreError::DuplicateInstrument(repeat.clone()));
450 }
451 if let Some(repeat) = first_repeat(snapshot.account.balance_assets())
452 .or_else(|| first_repeat(snapshot.reported_debt.iter().map(|(asset, _)| asset)))
453 {
454 return Err(VenueRestoreError::DuplicateAsset(repeat.clone()));
455 }
456 if let Some((short, _)) = snapshot.positions.iter().find(|(name, position)| {
457 matches!(
458 &instruments[name].kind,
459 InstrumentKind::Option(contract)
460 if contract.kind == OptionKind::Call
461 && contract.settlement == OptionSettlement::Cash
462 ) && position.quantity < Decimal::ZERO
463 }) {
464 return Err(VenueRestoreError::OptionShortUnsecurable(short.clone()));
465 }
466 if snapshot.account.margin() != config.spot_margin.is_some() {
467 return Err(VenueRestoreError::MarginMismatch {
468 snapshot_margin: snapshot.account.margin(),
469 });
470 }
471
472 let SimulatedVenueSnapshot {
473 version: _,
474 exchange: _,
475 regime,
476 instruments: _,
477 account,
478 positions,
479 order_sequence,
480 order_id_start,
481 trade_sequence,
482 cash_flow_sequence,
483 time_exchange,
484 accrued_through,
485 reported_debt,
486 liquidation_under_way,
487 margin_called,
488 borrow_prices,
489 slippage_draws,
490 checkpoint_sequence,
491 } = snapshot;
492
493 let mut venue = Self::assemble(
494 config,
495 instruments,
496 regime,
497 AccountState::from_snapshot(account)?,
498 positions.into_iter().collect(),
499 order_id_start,
500 reported_debt.into_iter().collect(),
501 );
502 venue.order_sequence = order_sequence;
503 venue.trade_sequence = trade_sequence;
504 venue.cash_flow_sequence = cash_flow_sequence;
505 // A snapshot written before a market tick moved the request clock can hold it behind the
506 // instant reached; restored at that instant, as the next input would set it.
507 venue.time_exchange_latest =
508 accrued_through.map_or(time_exchange, |reached| reached.max(time_exchange));
509 venue.accrued_through = accrued_through;
510 venue.liquidation = LiquidationState::restored(liquidation_under_way, margin_called);
511 // A window the configuration no longer sets would never be read or trimmed.
512 if config
513 .spot_margin
514 .as_ref()
515 .is_some_and(|margin| margin.borrow_price_window().is_some())
516 {
517 venue.borrow_prices = borrow_prices.into_iter().collect();
518 } else if !borrow_prices.is_empty() {
519 tracing::warn!(
520 exchange = %config.mocked_exchange,
521 windows = borrow_prices.len(),
522 "restored venue drops the borrow price windows of its snapshot: the configuration \
523 sets no borrow price window"
524 );
525 }
526 venue.check_collateral_held()?;
527 venue.slippage = Slippage::resumed(config.slippage, slippage_draws);
528 venue.checkpoint_sequence = checkpoint_sequence;
529 venue.refresh_next_charge();
530 venue.refresh_next_interest();
531 venue.refresh_next_option_expiry();
532 // An option the snapshot still holds past its expiry was waiting for a price to settle at,
533 // so it waits again. Rebuilt rather than stored: it is exactly those positions.
534 venue.expiry_pending = venue
535 .option_expiries
536 .iter()
537 .take_while(|(expiry, _)| accrued_through.is_some_and(|reached| *expiry <= reached))
538 .filter(|(_, name)| venue.positions.contains_key(name))
539 .map(|(_, name)| name.clone())
540 .collect();
541 Ok(venue)
542 }
543}