Skip to main content

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}