use super::record::{ContractQuote, ContractSide, SnapshotRecord};
use crate::utils::ChainError;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use positive::Positive;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq)]
pub struct ContractSeriesQuery {
pub simulation: Uuid,
pub generation: u64,
pub expires_at: DateTime<Utc>,
pub strike: Positive,
pub side: ContractSide,
pub from_step: usize,
pub to_step: usize,
}
impl ContractSeriesQuery {
#[must_use]
pub fn new(
simulation: Uuid,
generation: u64,
expires_at: DateTime<Utc>,
strike: Positive,
side: ContractSide,
from_step: usize,
to_step: usize,
) -> Self {
Self {
simulation,
generation,
expires_at,
strike,
side,
from_step,
to_step,
}
}
}
#[async_trait]
pub trait SimulationSnapshotRepository: Send + Sync {
async fn persist(&self, record: SnapshotRecord) -> Result<(), ChainError>;
async fn get(
&self,
simulation: Uuid,
generation: u64,
step: usize,
) -> Result<Option<SnapshotRecord>, ChainError>;
async fn read_range(
&self,
simulation: Uuid,
generation: u64,
from_step: usize,
to_step: usize,
) -> Result<Vec<SnapshotRecord>, ChainError>;
async fn contract_series(
&self,
query: ContractSeriesQuery,
) -> Result<Vec<ContractQuote>, ChainError>;
}
#[cfg(test)]
mod tests {
use super::super::record::{CURRENT_SNAPSHOT_GENERATION, ExpirationRecord, QuoteRow};
use super::*;
use chrono::TimeZone;
use positive::pos_or_panic;
use rust_decimal_macros::dec;
use std::collections::BTreeMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Default)]
struct InMemorySnapshotRepository {
stored: Mutex<BTreeMap<(Uuid, u64, usize), SnapshotRecord>>,
}
#[async_trait]
impl SimulationSnapshotRepository for InMemorySnapshotRepository {
async fn persist(&self, record: SnapshotRecord) -> Result<(), ChainError> {
record.validate()?;
let key = (record.simulation, record.generation, record.step);
self.stored.lock().await.insert(key, record);
Ok(())
}
async fn get(
&self,
simulation: Uuid,
generation: u64,
step: usize,
) -> Result<Option<SnapshotRecord>, ChainError> {
Ok(self
.stored
.lock()
.await
.get(&(simulation, generation, step))
.cloned())
}
async fn read_range(
&self,
simulation: Uuid,
generation: u64,
from_step: usize,
to_step: usize,
) -> Result<Vec<SnapshotRecord>, ChainError> {
Ok(self
.stored
.lock()
.await
.range((simulation, generation, from_step)..=(simulation, generation, to_step))
.map(|(_, record)| record.clone())
.collect())
}
async fn contract_series(
&self,
query: ContractSeriesQuery,
) -> Result<Vec<ContractQuote>, ChainError> {
let stored = self.stored.lock().await;
let mut series = Vec::new();
for record in stored
.range(
(query.simulation, query.generation, query.from_step)
..=(query.simulation, query.generation, query.to_step),
)
.map(|(_, record)| record)
{
for expiration in &record.expirations {
if expiration.expires_at != query.expires_at {
continue;
}
for quote in &expiration.quotes {
if quote.strike != query.strike {
continue;
}
let (bid, ask, mid, delta) = match query.side {
ContractSide::Call => (
quote.call_bid,
quote.call_ask,
quote.call_mid,
quote.delta_call,
),
ContractSide::Put => {
(quote.put_bid, quote.put_ask, quote.put_mid, quote.delta_put)
}
};
series.push(ContractQuote {
step: record.step,
simulated_at: record.simulated_at,
expires_at: expiration.expires_at,
days_to_expiration: expiration.days_to_expiration,
strike: quote.strike,
side: query.side,
implied_volatility: quote.implied_volatility,
bid,
ask,
mid,
delta,
gamma: quote.gamma,
});
}
}
}
Ok(series)
}
}
fn instant(day: u32) -> DateTime<Utc> {
match Utc.with_ymd_and_hms(2026, 1, day, 14, 30, 0).single() {
Some(instant) => instant,
None => panic!("the test instant must be valid"),
}
}
fn quote() -> QuoteRow {
QuoteRow::new(pos_or_panic!(5000.0), pos_or_panic!(0.18))
.with_call(
Some(pos_or_panic!(1.0)),
Some(pos_or_panic!(1.2)),
Some(pos_or_panic!(1.1)),
Some(dec!(0.51)),
)
.with_put(
Some(pos_or_panic!(0.8)),
Some(pos_or_panic!(1.0)),
None,
Some(dec!(-0.49)),
)
.with_gamma(Some(dec!(0.003)))
}
fn record(simulation: Uuid, step: usize) -> SnapshotRecord {
SnapshotRecord::new(
simulation,
CURRENT_SNAPSHOT_GENERATION,
step,
instant(5),
"SPX".to_string(),
pos_or_panic!(5000.0),
pos_or_panic!(0.18),
vec![ExpirationRecord::new(
instant(9),
pos_or_panic!(4.0),
vec!["weeklies".to_string()],
vec![quote()],
)],
)
}
fn series_query(simulation: Uuid, side: ContractSide) -> ContractSeriesQuery {
ContractSeriesQuery::new(
simulation,
CURRENT_SNAPSHOT_GENERATION,
instant(9),
pos_or_panic!(5000.0),
side,
0,
2,
)
}
#[tokio::test]
async fn test_the_repository_is_object_safe() {
let simulation = Uuid::from_u128(5);
let repository: Arc<dyn SimulationSnapshotRepository> =
Arc::new(InMemorySnapshotRepository::default());
match repository.persist(record(simulation, 0)).await {
Ok(()) => {}
Err(error) => panic!("the snapshot must persist: {error}"),
}
match repository
.get(simulation, CURRENT_SNAPSHOT_GENERATION, 0)
.await
{
Ok(Some(found)) => assert_eq!(found.step, 0),
other => panic!("the snapshot must be readable, got {other:?}"),
}
}
#[tokio::test]
async fn test_a_missing_snapshot_reads_as_absent() {
let repository = InMemorySnapshotRepository::default();
match repository
.get(Uuid::from_u128(5), CURRENT_SNAPSHOT_GENERATION, 3)
.await
{
Ok(None) => {}
other => panic!("expected an absent snapshot, got {other:?}"),
}
}
#[tokio::test]
async fn test_a_range_comes_back_ascending() {
let simulation = Uuid::from_u128(5);
let repository = InMemorySnapshotRepository::default();
for step in [2, 0, 1] {
match repository.persist(record(simulation, step)).await {
Ok(()) => {}
Err(error) => panic!("the snapshot must persist: {error}"),
}
}
match repository
.read_range(simulation, CURRENT_SNAPSHOT_GENERATION, 0, 2)
.await
{
Ok(range) => {
let steps: Vec<usize> = range.iter().map(|record| record.step).collect();
assert_eq!(steps, vec![0, 1, 2]);
}
Err(error) => panic!("the range must read: {error}"),
}
}
#[tokio::test]
async fn test_a_contract_history_selects_the_call_side() {
let simulation = Uuid::from_u128(5);
let repository = InMemorySnapshotRepository::default();
for step in 0..3 {
match repository.persist(record(simulation, step)).await {
Ok(()) => {}
Err(error) => panic!("the snapshot must persist: {error}"),
}
}
match repository
.contract_series(series_query(simulation, ContractSide::Call))
.await
{
Ok(series) => {
assert_eq!(series.len(), 3);
for quote in &series {
assert_eq!(quote.side, ContractSide::Call);
assert_eq!(quote.strike, pos_or_panic!(5000.0));
assert_eq!(quote.bid, Some(pos_or_panic!(1.0)));
assert_eq!(quote.mid, Some(pos_or_panic!(1.1)));
assert_eq!(quote.delta, Some(dec!(0.51)));
assert_eq!(quote.gamma, Some(dec!(0.003)));
}
}
Err(error) => panic!("the series must read: {error}"),
}
}
#[tokio::test]
async fn test_a_contract_history_selects_the_put_side() {
let simulation = Uuid::from_u128(5);
let repository = InMemorySnapshotRepository::default();
match repository.persist(record(simulation, 0)).await {
Ok(()) => {}
Err(error) => panic!("the snapshot must persist: {error}"),
}
match repository
.contract_series(series_query(simulation, ContractSide::Put))
.await
{
Ok(series) => {
assert_eq!(series.len(), 1);
match series.first() {
Some(quote) => {
assert_eq!(quote.side, ContractSide::Put);
assert_eq!(quote.bid, Some(pos_or_panic!(0.8)));
assert_eq!(quote.ask, Some(pos_or_panic!(1.0)));
assert_eq!(quote.mid, None, "a missing quote stays missing");
assert_eq!(quote.delta, Some(dec!(-0.49)));
assert_eq!(quote.gamma, Some(dec!(0.003)));
}
None => panic!("the series must carry a point"),
}
}
Err(error) => panic!("the series must read: {error}"),
}
}
#[tokio::test]
async fn test_persisting_twice_is_idempotent() {
let simulation = Uuid::from_u128(5);
let repository = InMemorySnapshotRepository::default();
for _ in 0..2 {
match repository.persist(record(simulation, 0)).await {
Ok(()) => {}
Err(error) => panic!("the snapshot must persist: {error}"),
}
}
match repository
.read_range(simulation, CURRENT_SNAPSHOT_GENERATION, 0, 0)
.await
{
Ok(range) => assert_eq!(range.len(), 1),
Err(error) => panic!("the range must read: {error}"),
}
}
}