use crate::domain::Walker;
use crate::infrastructure::{
ClickHouseClient, ClickHouseConfig, ClickHouseHistoricalRepository, HistoricalDataRepository,
calculate_required_duration, select_random_date,
};
use crate::session::{Session, SessionState, SimulationMethod};
use crate::utils::ChainError;
use optionstratlib::utils::{Len, TimeFrame};
use optionstratlib::{
ExpirationDate,
chains::{
OptionChainBuildParams, chain::OptionChain, generator_optionchain,
utils::OptionDataPriceParams,
},
simulation::{
WalkParams,
randomwalk::RandomWalk,
steps::{Step, Xstep, Ystep},
},
};
use positive::{Positive, pos_or_panic};
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use std::time::Instant;
use tokio::sync::Mutex;
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
const DEFAULT_CHAIN_SIZE: usize = 30;
const DEFAULT_SKEW_SLOPE: Decimal = dec!(-0.2);
const DEFAULT_SMILE_CURVE: Decimal = dec!(0.4);
const DEFAULT_MAX_CACHED_WALKS: usize = 1000;
const HISTORICAL_STREAM_SALT: u64 = 0x0048_4953_544F_5259;
static MAX_CACHED_WALKS: LazyLock<usize> =
LazyLock::new(|| match std::env::var("OCS_MAX_CACHED_WALKS").ok() {
None => DEFAULT_MAX_CACHED_WALKS,
Some(value) => match value.trim().parse::<usize>() {
Ok(parsed) if parsed >= 1 => parsed,
_ => {
warn!(
raw = %value,
default = DEFAULT_MAX_CACHED_WALKS,
"invalid OCS_MAX_CACHED_WALKS; falling back to default"
);
DEFAULT_MAX_CACHED_WALKS
}
},
});
struct CacheEntry {
walk: RandomWalk<Positive, OptionChain>,
last_access: Instant,
resolved_method: Option<SimulationMethod>,
}
fn enforce_capacity(cache: &mut HashMap<Uuid, CacheEntry>, max: usize) {
while cache.len() > max {
let victim = cache
.iter()
.min_by_key(|(_, entry)| entry.last_access)
.map(|(id, _)| *id);
match victim {
Some(id) => {
cache.remove(&id);
}
None => break,
}
}
}
pub struct Simulator {
simulation_cache: Arc<Mutex<HashMap<Uuid, CacheEntry>>>,
database_repo: Option<Arc<dyn HistoricalDataRepository>>,
}
impl Simulator {
pub fn new() -> Self {
info!("Creating new simulator instance");
let database_config = ClickHouseConfig::default();
info!("Connecting to ClickHouse at {}", database_config.host);
let database_repo = match ClickHouseClient::new(database_config) {
Ok(client) => {
let client = Arc::new(client);
let repo: Arc<dyn HistoricalDataRepository> =
Arc::new(ClickHouseHistoricalRepository::new(client));
Some(repo)
}
Err(e) => {
error!("Failed to connect to ClickHouse: {}", e);
None
}
};
Self {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo,
}
}
#[instrument(skip(self, session), level = "debug")]
pub async fn simulate_next_step(
&self,
session: &mut Session,
) -> Result<OptionChain, ChainError> {
debug!(
session_id = %session.id,
current_step = session.current_step,
"Simulating next step"
);
let need_new_walk;
{
let mut cache = self.simulation_cache.lock().await;
need_new_walk =
!cache.contains_key(&session.id) || session.state == SessionState::Reinitialized;
if session.state == SessionState::Reinitialized {
cache.remove(&session.id);
}
}
let random_walk_opt = if need_new_walk {
info!(
session_id = %session.id,
"Creating new simulation for session"
);
debug!("Reset Random Walk with Session: {}", session);
Some(self.create_random_walk(session).await?)
} else {
None
};
let step = {
let mut cache = self.simulation_cache.lock().await;
let entry = if let Some((random_walk, resolved_method)) = random_walk_opt {
let max = *MAX_CACHED_WALKS;
debug_assert!(max >= 1, "MAX_CACHED_WALKS is validated >= 1 at parse time");
enforce_capacity(&mut cache, max - 1);
cache.entry(session.id).or_insert(CacheEntry {
walk: random_walk,
last_access: Instant::now(),
resolved_method,
})
} else {
cache.get_mut(&session.id).ok_or_else(|| {
ChainError::Internal(format!(
"Failed to get random walk for session {}",
session.id
))
})?
};
entry.last_access = Instant::now();
if let Some(resolved) = &entry.resolved_method {
session.parameters.method = resolved.clone();
}
if session.current_step >= entry.walk.len() {
warn!("Walker reached end of data.");
return Err(ChainError::SimulatorError(
"Walker reached end of data".to_string(),
));
}
entry.walk[session.current_step].clone()
};
let chain = step.y.value().clone();
debug!(
session_id = %session.id,
current_step = session.current_step,
underlying_price = %chain.underlying_price,
contracts_count = chain.len(),
"Retrieved option chain for step"
);
Ok(chain)
}
#[instrument(skip(self, rng), level = "debug")]
pub async fn get_historical_data(
&self,
symbol: &Option<String>,
timeframe: &TimeFrame,
steps: usize,
rng: &mut StdRng,
) -> Result<(String, Vec<Positive>), ChainError> {
if let Some(repo) = &self.database_repo {
let actual_symbol = if let Some(sym) = symbol {
sym.clone()
} else {
let available_symbols = repo
.list_available_symbols()
.await
.map_err(|e| ChainError::ClickHouseError(e.to_string()))?;
if available_symbols.is_empty() {
return Err(ChainError::NotFound(
"No symbols available in the database".to_string(),
));
}
let random_index = rng.random_range(0..available_symbols.len());
available_symbols
.get(random_index)
.ok_or_else(|| ChainError::Internal("symbol index out of range".to_string()))?
.clone()
};
debug!("Selected symbol: {}", actual_symbol);
let (min_date, max_date) = repo
.get_date_range_for_symbol(&actual_symbol)
.await
.map_err(|e| ChainError::ClickHouseError(e.to_string()))?;
debug!("Available date range: {} - {}", min_date, max_date);
let start_date = select_random_date(rng, min_date, max_date, timeframe, steps)?;
let duration = calculate_required_duration(timeframe, steps);
let end_date = start_date + duration;
debug!(
"Fetching data from {} to {} for symbol {}",
start_date, end_date, actual_symbol
);
let prices = repo
.get_historical_prices(&actual_symbol, timeframe, &start_date, steps)
.await
.map_err(|e| ChainError::ClickHouseError(e.to_string()))?;
if prices.len() < steps {
return Err(ChainError::NotEnoughData(format!(
"Retrieved {} data points but {} required for symbol {}",
prices.len(),
steps,
actual_symbol
)));
}
Ok((actual_symbol, prices.into_iter().take(steps).collect()))
} else {
Err(ChainError::SimulatorError(
"Database not available".to_string(),
))
}
}
#[instrument(skip(self, session), level = "debug")]
async fn create_random_walk(
&self,
session: &Session,
) -> Result<(RandomWalk<Positive, OptionChain>, Option<SimulationMethod>), ChainError> {
let params = &session.parameters;
let (method, resolved_method): (SimulationMethod, Option<SimulationMethod>) = match ¶ms
.method
{
SimulationMethod::Historical {
timeframe,
prices,
symbol,
} => {
if prices.is_empty() || prices.len() < params.steps {
let mut selection_rng = match params.seed {
Some(seed) => StdRng::seed_from_u64(seed ^ HISTORICAL_STREAM_SALT),
None => {
warn!(
session_id = %session.id,
"historical session has no seed; data selection is not reproducible"
);
StdRng::from_rng(&mut rand::rng())
}
};
let (resolved_symbol, loaded_prices) = self
.get_historical_data(symbol, timeframe, params.steps, &mut selection_rng)
.await?;
let resolved = SimulationMethod::Historical {
timeframe: *timeframe,
prices: loaded_prices,
symbol: Some(resolved_symbol),
};
(resolved.clone(), Some(resolved))
} else {
(params.method.clone(), None)
}
}
_ => (params.method.clone(), None),
};
let initial_price = params.initial_price;
let days_to_expiration = params.days_to_expiration;
let volatility = params.volatility;
let risk_free_rate = params.risk_free_rate;
let dividend_yield = params.dividend_yield;
let symbol = params.symbol.clone();
let time_frame = params.time_frame;
let chain_size = params.chain_size.unwrap_or(DEFAULT_CHAIN_SIZE);
let strike_interval = params.strike_interval;
let skew_slope = params.skew_slope.unwrap_or(DEFAULT_SKEW_SLOPE);
let smile_curve = params.smile_curve.unwrap_or(DEFAULT_SMILE_CURVE);
let spread = params.spread.unwrap_or(pos_or_panic!(0.01));
let price_params = OptionDataPriceParams::new(
Some(Box::new(initial_price)),
Some(ExpirationDate::Days(days_to_expiration)),
Some(risk_free_rate),
Some(dividend_yield),
Some(symbol.clone()),
);
let build_params = OptionChainBuildParams::new(
symbol.clone(),
Some(Positive::ONE), chain_size,
strike_interval,
skew_slope,
smile_curve,
spread,
2, price_params,
volatility,
);
let initial_chain = OptionChain::build_chain(&build_params)
.map_err(|e| ChainError::Internal(format!("Failed to build option chain: {}", e)))?;
let walker = Box::new(match params.seed {
Some(seed) => Walker::new_with_seed(seed),
None => Walker::new(),
});
let walk_params = WalkParams {
size: params.steps,
init_step: Step {
x: Xstep::new(
Positive::ONE,
time_frame,
ExpirationDate::Days(days_to_expiration),
),
y: Ystep::new(0, initial_chain),
},
walk_type: method,
walker,
};
let random_walk = RandomWalk::new(
format!("Session_{}", session.id),
&walk_params,
generator_optionchain,
)
.map_err(|e| ChainError::Internal(format!("Failed to create random walk: {}", e)))?;
info!(
session_id = %session.id,
steps = random_walk.len(),
"Created random walk for session"
);
Ok((random_walk, resolved_method))
}
#[instrument(skip(self), level = "debug")]
pub async fn remove_session(&self, id: &Uuid) -> bool {
let mut cache = self.simulation_cache.lock().await;
let removed = cache.remove(id).is_some();
if removed {
debug!(session_id = %id, "Evicted cached random walk");
}
removed
}
pub async fn cache_len(&self) -> usize {
self.simulation_cache.lock().await.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{SimulationMethod, SimulationParameters};
use crate::utils::UuidGenerator;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mockall::predicate::*;
use mockall::*;
use optionstratlib::utils::TimeFrame;
use positive::{Positive, pos_or_panic};
use rust_decimal_macros::dec;
use std::sync::Arc;
use uuid::Uuid;
mock! {
pub HistoricalRepository {}
#[async_trait]
impl HistoricalDataRepository for HistoricalRepository {
async fn get_historical_prices(
&self,
symbol: &str,
timeframe: &TimeFrame,
start_date: &DateTime<Utc>,
limit: usize,
) -> Result<Vec<Positive>, ChainError>;
async fn list_available_symbols(&self) -> Result<Vec<String>, ChainError>;
async fn get_date_range_for_symbol(
&self,
symbol: &str,
) -> Result<(DateTime<Utc>, DateTime<Utc>), ChainError>;
}
}
fn create_test_session(id: Option<Uuid>) -> Session {
let params = SimulationParameters {
symbol: "TEST".to_string(),
steps: 10,
initial_price: pos_or_panic!(100.0),
days_to_expiration: pos_or_panic!(30.0),
volatility: pos_or_panic!(0.2),
risk_free_rate: dec!(0.0),
dividend_yield: pos_or_panic!(0.0),
method: SimulationMethod::GeometricBrownian {
dt: pos_or_panic!(0.004),
drift: dec!(0.0),
volatility: pos_or_panic!(0.2),
},
time_frame: TimeFrame::Day,
chain_size: Some(10),
strike_interval: Some(pos_or_panic!(5.0)),
skew_slope: Some(dec!(-0.2)),
smile_curve: Some(dec!(0.5)),
spread: Some(pos_or_panic!(0.01)),
seed: None,
};
let namespace = Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap();
let uuid_generator = UuidGenerator::new(namespace);
let mut session = Session::new(params, &uuid_generator);
if let Some(id) = id {
session.id = id;
}
session
}
async fn collect_tape(simulator: &Simulator, session: &mut Session) -> Vec<Positive> {
let steps = session.parameters.steps;
let mut tape = Vec::with_capacity(steps);
for step in 0..steps {
session.current_step = step;
if step > 0 {
session.state = SessionState::InProgress;
}
let chain = simulator
.simulate_next_step(session)
.await
.expect("Simulation step failed");
tape.push(chain.underlying_price);
}
tape
}
#[tokio::test]
async fn test_same_seed_produces_identical_tape() {
let mut session_a = create_test_session(Some(Uuid::new_v4()));
let mut session_b = create_test_session(Some(Uuid::new_v4()));
session_a.parameters.seed = Some(20260713);
session_b.parameters.seed = Some(20260713);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let tape_a = collect_tape(&simulator, &mut session_a).await;
let tape_b = collect_tape(&simulator, &mut session_b).await;
assert_eq!(tape_a, tape_b);
}
#[tokio::test]
async fn test_different_seeds_produce_different_tapes() {
let mut session_a = create_test_session(Some(Uuid::new_v4()));
let mut session_b = create_test_session(Some(Uuid::new_v4()));
session_a.parameters.seed = Some(1);
session_b.parameters.seed = Some(2);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let tape_a = collect_tape(&simulator, &mut session_a).await;
let tape_b = collect_tape(&simulator, &mut session_b).await;
assert_ne!(tape_a, tape_b);
}
fn jump_diffusion_method() -> SimulationMethod {
SimulationMethod::JumpDiffusion {
dt: pos_or_panic!(0.004),
drift: dec!(0.0),
volatility: pos_or_panic!(0.2),
intensity: pos_or_panic!(1.0),
jump_mean: dec!(0.0),
jump_volatility: pos_or_panic!(0.1),
}
}
#[tokio::test]
async fn test_jump_diffusion_same_seed_same_tape() {
let mut session_a = create_test_session(Some(Uuid::new_v4()));
let mut session_b = create_test_session(Some(Uuid::new_v4()));
session_a.parameters.method = jump_diffusion_method();
session_b.parameters.method = jump_diffusion_method();
session_a.parameters.seed = Some(20260713);
session_b.parameters.seed = Some(20260713);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let tape_a = collect_tape(&simulator, &mut session_a).await;
let tape_b = collect_tape(&simulator, &mut session_b).await;
assert_eq!(tape_a, tape_b);
}
#[tokio::test]
async fn test_jump_diffusion_different_seeds_different_tapes() {
let mut session_a = create_test_session(Some(Uuid::new_v4()));
let mut session_b = create_test_session(Some(Uuid::new_v4()));
session_a.parameters.method = jump_diffusion_method();
session_b.parameters.method = jump_diffusion_method();
session_a.parameters.seed = Some(1);
session_b.parameters.seed = Some(2);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let tape_a = collect_tape(&simulator, &mut session_a).await;
let tape_b = collect_tape(&simulator, &mut session_b).await;
assert_ne!(tape_a, tape_b);
}
#[tokio::test]
async fn test_remove_session_evicts_cached_walk() {
let mut session = create_test_session(Some(Uuid::new_v4()));
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
simulator
.simulate_next_step(&mut session)
.await
.expect("initial simulate failed");
assert_eq!(simulator.cache_len().await, 1);
assert!(simulator.remove_session(&session.id).await);
assert_eq!(simulator.cache_len().await, 0);
assert!(!simulator.remove_session(&session.id).await);
}
#[tokio::test]
async fn test_eviction_preserves_seeded_tape() {
let mut session = create_test_session(Some(Uuid::new_v4()));
session.parameters.seed = Some(20260713);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let before = simulator
.simulate_next_step(&mut session)
.await
.expect("first simulate failed");
assert_eq!(simulator.cache_len().await, 1);
assert!(simulator.remove_session(&session.id).await);
assert_eq!(simulator.cache_len().await, 0);
let after = simulator
.simulate_next_step(&mut session)
.await
.expect("rebuild simulate failed");
assert_eq!(simulator.cache_len().await, 1);
assert_eq!(before.underlying_price, after.underlying_price);
}
#[tokio::test]
async fn test_enforce_capacity_evicts_least_recently_accessed() {
use std::time::Duration;
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let mut small = create_test_session(None);
small.parameters.steps = 2;
let id_old = Uuid::new_v4();
let id_mid = Uuid::new_v4();
let id_new = Uuid::new_v4();
let now = Instant::now();
let mut cache: HashMap<Uuid, CacheEntry> = HashMap::new();
cache.insert(
id_old,
CacheEntry {
walk: simulator.create_random_walk(&small).await.unwrap().0,
last_access: now - Duration::from_secs(3),
resolved_method: None,
},
);
cache.insert(
id_mid,
CacheEntry {
walk: simulator.create_random_walk(&small).await.unwrap().0,
last_access: now - Duration::from_secs(2),
resolved_method: None,
},
);
cache.insert(
id_new,
CacheEntry {
walk: simulator.create_random_walk(&small).await.unwrap().0,
last_access: now - Duration::from_secs(1),
resolved_method: None,
},
);
enforce_capacity(&mut cache, 2);
assert_eq!(cache.len(), 2);
assert!(
!cache.contains_key(&id_old),
"least-recently-accessed entry must be evicted"
);
assert!(cache.contains_key(&id_mid));
assert!(cache.contains_key(&id_new));
}
#[tokio::test]
async fn test_enforce_capacity_noop_when_within_bound() {
use std::time::Duration;
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let mut small = create_test_session(None);
small.parameters.steps = 2;
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
let now = Instant::now();
let mut cache: HashMap<Uuid, CacheEntry> = HashMap::new();
cache.insert(
id_a,
CacheEntry {
walk: simulator.create_random_walk(&small).await.unwrap().0,
last_access: now - Duration::from_secs(2),
resolved_method: None,
},
);
cache.insert(
id_b,
CacheEntry {
walk: simulator.create_random_walk(&small).await.unwrap().0,
last_access: now - Duration::from_secs(1),
resolved_method: None,
},
);
enforce_capacity(&mut cache, 5);
assert_eq!(cache.len(), 2);
}
#[tokio::test]
async fn test_enforce_capacity_evicts_historical_entries_like_any_other() {
use std::time::Duration;
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let mut small = create_test_session(None);
small.parameters.steps = 2;
let id_hist_old = Uuid::new_v4();
let id_mid = Uuid::new_v4();
let id_new = Uuid::new_v4();
let now = Instant::now();
let mut cache: HashMap<Uuid, CacheEntry> = HashMap::new();
cache.insert(
id_hist_old,
CacheEntry {
walk: simulator.create_random_walk(&small).await.unwrap().0,
last_access: now - Duration::from_secs(3),
resolved_method: Some(SimulationMethod::Historical {
timeframe: TimeFrame::Day,
prices: vec![pos_or_panic!(100.0), pos_or_panic!(101.0)],
symbol: Some("SYM0".to_string()),
}),
},
);
cache.insert(
id_mid,
CacheEntry {
walk: simulator.create_random_walk(&small).await.unwrap().0,
last_access: now - Duration::from_secs(2),
resolved_method: None,
},
);
cache.insert(
id_new,
CacheEntry {
walk: simulator.create_random_walk(&small).await.unwrap().0,
last_access: now - Duration::from_secs(1),
resolved_method: None,
},
);
enforce_capacity(&mut cache, 2);
assert_eq!(cache.len(), 2);
assert!(
!cache.contains_key(&id_hist_old),
"a least-recently-accessed historical entry is evicted like any other"
);
assert!(cache.contains_key(&id_mid));
assert!(cache.contains_key(&id_new));
}
fn create_test_historical_data(count: usize) -> Vec<Positive> {
let mut data = Vec::with_capacity(count);
for i in 0..count {
data.push(pos_or_panic!(100.0 + i as f64));
}
data
}
#[tokio::test]
async fn test_new_simulator_without_db() {
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
assert!(simulator.database_repo.is_none());
assert_eq!(simulator.simulation_cache.lock().await.len(), 0);
}
#[tokio::test]
async fn test_new_simulator_with_mock_db() {
let mut mock_repo = MockHistoricalRepository::new();
mock_repo
.expect_list_available_symbols()
.returning(|| Ok(vec!["TEST".to_string()]));
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(mock_repo)),
};
assert!(simulator.database_repo.is_some());
let symbols = simulator
.database_repo
.as_ref()
.unwrap()
.list_available_symbols()
.await
.unwrap();
assert_eq!(symbols, vec!["TEST".to_string()]);
}
#[tokio::test]
async fn test_simulate_next_step_new_session() {
let mut session = create_test_session(None);
let session_id = session.id;
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let result = simulator.simulate_next_step(&mut session).await;
assert!(result.is_ok());
let cache = simulator.simulation_cache.lock().await;
assert!(cache.contains_key(&session_id));
}
#[tokio::test]
async fn test_simulate_next_step_existing_session() {
let mut session = create_test_session(None);
let session_id = session.id;
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let _ = simulator.simulate_next_step(&mut session).await.unwrap();
session.current_step = 1;
session.state = SessionState::InProgress;
let result = simulator.simulate_next_step(&mut session).await;
assert!(result.is_ok());
let cache = simulator.simulation_cache.lock().await;
assert_eq!(cache.len(), 1);
assert!(cache.contains_key(&session_id));
}
#[tokio::test]
async fn test_simulate_next_step_reinitialized_session() {
let mut session = create_test_session(None);
let session_id = session.id;
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let _ = simulator.simulate_next_step(&mut session).await.unwrap();
session.state = SessionState::Reinitialized;
let result = simulator.simulate_next_step(&mut session).await;
assert!(result.is_ok());
let cache = simulator.simulation_cache.lock().await;
assert_eq!(cache.len(), 1);
assert!(cache.contains_key(&session_id));
}
#[tokio::test]
async fn test_simulate_next_step_out_of_range() {
let mut session = create_test_session(None);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let _ = simulator.simulate_next_step(&mut session).await.unwrap();
session.current_step = session.parameters.steps + 1;
let result = simulator.simulate_next_step(&mut session).await;
assert!(result.is_err());
match result {
Err(ChainError::SimulatorError(msg)) => {
assert_eq!(msg, "Walker reached end of data");
}
_ => panic!("Expected SimulatorError"),
}
}
#[tokio::test]
async fn test_get_historical_data_with_symbol() {
let symbol = Some("TEST".to_string());
let timeframe = TimeFrame::Day;
let steps = 5;
let expected_data = create_test_historical_data(steps);
let mut mock_repo = MockHistoricalRepository::new();
mock_repo
.expect_get_date_range_for_symbol()
.with(eq("TEST"))
.returning(|_| Ok((Utc::now() - chrono::Duration::days(30), Utc::now())));
mock_repo
.expect_get_historical_prices()
.returning(move |_, _, _, _| Ok(expected_data.clone()));
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(mock_repo)),
};
let mut rng = StdRng::seed_from_u64(1);
let result = simulator
.get_historical_data(&symbol, &timeframe, steps, &mut rng)
.await;
assert!(result.is_ok());
let (resolved_symbol, data) = result.unwrap();
assert_eq!(resolved_symbol, "TEST");
assert_eq!(data.len(), steps);
}
#[tokio::test]
async fn test_get_historical_data_without_symbol() {
let symbol = None;
let timeframe = TimeFrame::Day;
let steps = 5;
let expected_data = create_test_historical_data(steps);
let mut mock_repo = MockHistoricalRepository::new();
mock_repo
.expect_list_available_symbols()
.returning(|| Ok(vec!["RANDOM1".to_string(), "RANDOM2".to_string()]));
mock_repo
.expect_get_date_range_for_symbol()
.returning(|_| Ok((Utc::now() - chrono::Duration::days(30), Utc::now())));
mock_repo
.expect_get_historical_prices()
.returning(move |_, _, _, _| Ok(expected_data.clone()));
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(mock_repo)),
};
let mut rng = StdRng::seed_from_u64(1);
let result = simulator
.get_historical_data(&symbol, &timeframe, steps, &mut rng)
.await;
assert!(result.is_ok());
let (resolved_symbol, data) = result.unwrap();
assert!(resolved_symbol == "RANDOM1" || resolved_symbol == "RANDOM2");
assert_eq!(data.len(), steps);
}
#[tokio::test]
async fn test_get_historical_data_no_db() {
let symbol = Some("TEST".to_string());
let timeframe = TimeFrame::Day;
let steps = 5;
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let mut rng = StdRng::seed_from_u64(1);
let result = simulator
.get_historical_data(&symbol, &timeframe, steps, &mut rng)
.await;
assert!(result.is_err());
match result {
Err(ChainError::SimulatorError(msg)) => {
assert_eq!(msg, "Database not available");
}
_ => panic!("Expected SimulatorError"),
}
}
#[tokio::test]
async fn test_get_historical_data_not_enough_data() {
let symbol = Some("TEST".to_string());
let timeframe = TimeFrame::Day;
let steps = 10;
let expected_data = create_test_historical_data(5);
let mut mock_repo = MockHistoricalRepository::new();
mock_repo
.expect_get_date_range_for_symbol()
.returning(|_| Ok((Utc::now() - chrono::Duration::days(30), Utc::now())));
mock_repo
.expect_get_historical_prices()
.returning(move |_, _, _, _| Ok(expected_data.clone()));
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(mock_repo)),
};
let mut rng = StdRng::seed_from_u64(1);
let result = simulator
.get_historical_data(&symbol, &timeframe, steps, &mut rng)
.await;
assert!(result.is_err());
match result {
Err(ChainError::NotEnoughData(_)) => {
}
_ => panic!("Expected NotEnoughData error"),
}
}
#[tokio::test]
async fn test_create_random_walk() {
let session = create_test_session(None);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: None,
};
let result = simulator.create_random_walk(&session).await;
assert!(result.is_ok());
let (random_walk, _resolved) = result.unwrap();
assert_eq!(random_walk.len(), session.parameters.steps);
}
#[tokio::test]
async fn test_create_random_walk_historical() {
let mut session = create_test_session(None);
let steps = 5;
session.parameters.steps = steps;
session.parameters.method = SimulationMethod::Historical {
timeframe: TimeFrame::Day,
prices: vec![], symbol: Some("TEST".to_string()),
};
let expected_data = create_test_historical_data(steps);
let mut mock_repo = MockHistoricalRepository::new();
mock_repo
.expect_get_date_range_for_symbol()
.returning(|_| Ok((Utc::now() - chrono::Duration::days(30), Utc::now())));
mock_repo
.expect_get_historical_prices()
.returning(move |_, _, _, _| Ok(expected_data.clone()));
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(mock_repo)),
};
let result = simulator.create_random_walk(&session).await;
assert!(result.is_ok());
let (random_walk, resolved) = result.unwrap();
assert_eq!(random_walk.len(), steps);
match resolved {
Some(SimulationMethod::Historical { prices, symbol, .. }) => {
assert_eq!(symbol, Some("TEST".to_string()));
assert_eq!(prices.len(), steps);
}
other => panic!("expected a resolved Historical method, got {other:?}"),
}
}
fn historical_session(seed: u64) -> Session {
let mut session = create_test_session(Some(Uuid::new_v4()));
session.parameters.steps = 5;
session.parameters.seed = Some(seed);
session.parameters.method = SimulationMethod::Historical {
timeframe: TimeFrame::Day,
prices: vec![],
symbol: None,
};
session
}
fn historical_mock() -> MockHistoricalRepository {
let mut mock = MockHistoricalRepository::new();
mock.expect_list_available_symbols()
.returning(|| Ok((0..8).map(|i| format!("SYM{i}")).collect::<Vec<String>>()));
mock.expect_get_date_range_for_symbol().returning(|_| {
let min = DateTime::from_timestamp(1_600_000_000, 0).expect("valid min timestamp");
let max = DateTime::from_timestamp(1_700_000_000, 0).expect("valid max timestamp");
Ok((min, max))
});
mock.expect_get_historical_prices()
.returning(|symbol, _tf, start_date, limit| {
let base: u64 = symbol.bytes().map(u64::from).sum();
let offset = start_date.timestamp().unsigned_abs();
let seed_val = base.wrapping_add(offset);
let prices = (0..limit)
.map(|i| {
let step = seed_val.wrapping_add(i as u64) % 1000;
pos_or_panic!(100.0 + step as f64 * 0.1)
})
.collect();
Ok(prices)
});
mock
}
#[tokio::test]
async fn test_historical_same_seed_identical_selection_and_tape() {
let seed = 20260713;
let mut session_a = historical_session(seed);
let mut session_b = historical_session(seed);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(historical_mock())),
};
let tape_a = collect_tape(&simulator, &mut session_a).await;
let tape_b = collect_tape(&simulator, &mut session_b).await;
assert_eq!(tape_a, tape_b);
assert_eq!(session_a.parameters.method, session_b.parameters.method);
}
#[tokio::test]
async fn test_historical_different_seeds_diverge() {
let mut session_a = historical_session(1);
let mut session_b = historical_session(2);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(historical_mock())),
};
let tape_a = collect_tape(&simulator, &mut session_a).await;
let tape_b = collect_tape(&simulator, &mut session_b).await;
assert_ne!(tape_a, tape_b);
}
#[tokio::test]
async fn test_historical_resolution_persisted_in_session() {
let mut session = historical_session(42);
assert!(matches!(
session.parameters.method,
SimulationMethod::Historical { ref prices, symbol: None, .. } if prices.is_empty()
));
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(historical_mock())),
};
simulator
.simulate_next_step(&mut session)
.await
.expect("historical simulate failed");
match &session.parameters.method {
SimulationMethod::Historical { prices, symbol, .. } => {
assert!(!prices.is_empty(), "resolved prices must be embedded");
assert!(symbol.is_some(), "resolved symbol must be recorded");
}
other => panic!("expected a resolved Historical method, got {other:?}"),
}
}
#[tokio::test]
async fn test_peek_then_advance_applies_resolution_on_cache_hit() {
let session = historical_session(42);
let simulator = Simulator {
simulation_cache: Arc::new(Mutex::new(HashMap::new())),
database_repo: Some(Arc::new(historical_mock())),
};
let mut peek_copy = session.clone();
let peeked = simulator
.simulate_next_step(&mut peek_copy)
.await
.expect("peek simulate failed");
assert_eq!(simulator.cache_len().await, 1);
drop(peek_copy);
let mut advance_copy = session.clone();
assert!(matches!(
advance_copy.parameters.method,
SimulationMethod::Historical { ref prices, symbol: None, .. } if prices.is_empty()
));
let advanced = simulator
.simulate_next_step(&mut advance_copy)
.await
.expect("advance simulate failed");
match &advance_copy.parameters.method {
SimulationMethod::Historical { prices, symbol, .. } => {
assert!(
!prices.is_empty(),
"cache hit must apply the resolved prices to the served session"
);
assert!(
symbol.is_some(),
"cache hit must apply the resolved symbol to the served session"
);
}
other => panic!("expected a resolved Historical method, got {other:?}"),
}
assert_eq!(peeked.underlying_price, advanced.underlying_price);
}
}