use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use optionstratlib::simulation::ExitPolicy;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::bundle::write_bundle;
use crate::config::BacktestConfig;
#[cfg(feature = "simulator")]
use crate::data::SimulatorFeed;
use crate::data::historical::to_hex;
use crate::data::{DataSourceSpec, ParquetFeed, SharedParquetTape};
use crate::domain::{InstrumentSpec, Quantity, StrategySpec};
use crate::engine::{BacktestRun, ScenarioParams, ScenarioType, expand};
use crate::error::BacktestError;
use crate::run::run_with_feed;
pub const BATCH_INDEX_SCHEMA: &str = "ironcondor.batch_index.v0";
#[derive(Debug, Clone, Copy)]
pub struct SimulatorMaterialisation {
pub instrument: InstrumentSpec,
pub quote_size: Quantity,
pub timeout: Duration,
}
impl SimulatorMaterialisation {
#[must_use]
pub const fn new(instrument: InstrumentSpec, quote_size: Quantity, timeout: Duration) -> Self {
Self {
instrument,
quote_size,
timeout,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BatchIndex {
pub schema: String,
pub batch_id: String,
pub kind: ScenarioType,
pub base_seed: u64,
pub count: u32,
pub run_count: u32,
pub runs: Vec<BatchRunEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BatchRunEntry {
pub index: u32,
pub engine_seed: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_seed: Option<u64>,
pub outcome: BatchRunOutcome,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum BatchRunOutcome {
Ok {
run_id: String,
bundle_path: String,
tape_sha256: String,
terminal_equity_cents: i64,
},
Error {
error: String,
},
}
const BATCH_ID_TAG: &[u8] = b"ironcondor.scenario.batch.v1\0";
pub fn run_scenario_batch(
params: &ScenarioParams,
base_config: &BacktestConfig,
strategy: &StrategySpec,
exit: &ExitPolicy,
materialisation: Option<&SimulatorMaterialisation>,
) -> Result<BatchIndex, BacktestError> {
let configs = expand(params, base_config)?;
let mut planned = Vec::with_capacity(configs.len());
for (idx, config) in configs.into_iter().enumerate() {
let index = u32::try_from(idx).map_err(|_| BacktestError::ArithmeticOverflow)?;
planned.push(PlannedRun { index, config });
}
let shared = materialise_shared_tapes(&planned);
let runs = fan_out(&planned, strategy, exit, materialisation, &shared)?;
let batch_id = derive_batch_id(params, base_config, strategy)?;
let run_count = u32::try_from(runs.len()).map_err(|_| BacktestError::ArithmeticOverflow)?;
let index = BatchIndex {
schema: BATCH_INDEX_SCHEMA.to_string(),
batch_id,
kind: params.kind,
base_seed: params.base_seed,
count: params.count,
run_count,
runs,
};
let path = write_batch_index(&index, base_config.output_dir.as_path())?;
let failures = index
.runs
.iter()
.filter(|entry| matches!(entry.outcome, BatchRunOutcome::Error { .. }))
.count();
tracing::info!(
batch_id = index.batch_id.as_str(),
kind = ?index.kind,
run_count = index.run_count,
failures,
index = %path.display(),
"scenario batch finished"
);
Ok(index)
}
struct PlannedRun {
index: u32,
config: BacktestConfig,
}
type SharedTapes = BTreeMap<String, Result<SharedParquetTape, String>>;
fn materialise_shared_tapes(planned: &[PlannedRun]) -> SharedTapes {
let mut shared = SharedTapes::new();
for run in planned {
if let DataSourceSpec::Parquet { path, sha256 } = &run.config.data_source
&& !shared.contains_key(path)
{
let outcome = SharedParquetTape::materialise(path, sha256, &run.config.limits)
.map_err(|error| error.to_string());
shared.insert(path.clone(), outcome);
}
}
shared
}
struct RunSummary {
run_id: String,
bundle_path: String,
tape_sha256: String,
terminal_equity_cents: i64,
}
fn worker_count(run_count: usize) -> usize {
let available = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
run_count.min(available).max(1)
}
fn fan_out(
planned: &[PlannedRun],
strategy: &StrategySpec,
exit: &ExitPolicy,
materialisation: Option<&SimulatorMaterialisation>,
shared: &SharedTapes,
) -> Result<Vec<BatchRunEntry>, BacktestError> {
if planned.is_empty() {
return Ok(Vec::new());
}
let workers = worker_count(planned.len());
let chunk_size = planned.len().div_ceil(workers);
let mut collected: Vec<BatchRunEntry> = Vec::with_capacity(planned.len());
let mut panicked = false;
std::thread::scope(|scope| {
let mut handles = Vec::with_capacity(workers);
for chunk in planned.chunks(chunk_size) {
handles.push(scope.spawn(move || {
let mut local = Vec::with_capacity(chunk.len());
for run in chunk {
local.push(run_one_entry(run, strategy, exit, materialisation, shared));
}
local
}));
}
for handle in handles {
match handle.join() {
Ok(mut local) => collected.append(&mut local),
Err(_) => panicked = true,
}
}
});
if panicked {
return Err(BacktestError::Execution(
"a scenario batch worker thread panicked; run failures are recorded, not panicked \
(this is a bug)"
.to_string(),
));
}
collected.sort_by_key(|entry| entry.index);
Ok(collected)
}
fn run_one_entry(
run: &PlannedRun,
strategy: &StrategySpec,
exit: &ExitPolicy,
materialisation: Option<&SimulatorMaterialisation>,
shared: &SharedTapes,
) -> BatchRunEntry {
let engine_seed = run.config.seed;
let data_seed = data_seed_of(&run.config);
let outcome = match run_one(&run.config, strategy, exit, materialisation, shared) {
Ok(summary) => BatchRunOutcome::Ok {
run_id: summary.run_id,
bundle_path: summary.bundle_path,
tape_sha256: summary.tape_sha256,
terminal_equity_cents: summary.terminal_equity_cents,
},
Err(error) => BatchRunOutcome::Error {
error: error.to_string(),
},
};
BatchRunEntry {
index: run.index,
engine_seed,
data_seed,
outcome,
}
}
fn run_one(
config: &BacktestConfig,
strategy: &StrategySpec,
exit: &ExitPolicy,
materialisation: Option<&SimulatorMaterialisation>,
shared: &SharedTapes,
) -> Result<RunSummary, BacktestError> {
config.validate()?;
let run = open_and_run(config, strategy, exit, materialisation, shared)?;
let bundle_path = write_bundle(&run, config, strategy)?;
let run_id = bundle_path
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.ok_or_else(|| {
BacktestError::Bundle("published bundle path has no run_id component".to_string())
})?;
let terminal_equity_cents = run
.equity_curve
.last()
.map_or(0, |point| point.equity_cents);
Ok(RunSummary {
run_id,
bundle_path: bundle_path.display().to_string(),
tape_sha256: run.data_identity.clone(),
terminal_equity_cents,
})
}
#[cfg_attr(not(feature = "simulator"), allow(unused_variables))]
fn open_and_run(
config: &BacktestConfig,
strategy: &StrategySpec,
exit: &ExitPolicy,
materialisation: Option<&SimulatorMaterialisation>,
shared: &SharedTapes,
) -> Result<BacktestRun, BacktestError> {
match &config.data_source {
DataSourceSpec::Parquet { path, sha256 } => {
let feed = match shared.get(path) {
Some(Ok(tape)) if sha256.is_empty() || tape.data_identity() == sha256 => {
tape.feed()
}
Some(Err(descriptor)) => {
return Err(BacktestError::Data(descriptor.clone()));
}
_ => ParquetFeed::open_verified(path, sha256, &config.limits)?,
};
run_with_feed(config, feed, strategy, exit.clone())
}
#[cfg(feature = "simulator")]
DataSourceSpec::Simulator(spec) => {
let knobs = materialisation.ok_or_else(|| {
BacktestError::Config(
"a simulator scenario source requires SimulatorMaterialisation \
(instrument grid, quote size, timeout)"
.to_string(),
)
})?;
let feed = SimulatorFeed::open(
spec,
knobs.instrument,
knobs.quote_size,
knobs.timeout,
&config.limits,
)?;
run_with_feed(config, feed, strategy, exit.clone())
}
other => Err(BacktestError::Config(format!(
"scenario batch supports a parquet or simulator data source; got {other:?}"
))),
}
}
fn data_seed_of(config: &BacktestConfig) -> Option<u64> {
#[cfg(feature = "simulator")]
if let DataSourceSpec::Simulator(spec) = &config.data_source {
return Some(spec.data_seed);
}
#[cfg(not(feature = "simulator"))]
let _ = config;
None
}
fn derive_batch_id(
params: &ScenarioParams,
base_config: &BacktestConfig,
strategy: &StrategySpec,
) -> Result<String, BacktestError> {
let mut hasher = Sha256::new();
hasher.update(BATCH_ID_TAG);
for part in [
serde_json::to_vec(params),
serde_json::to_vec(base_config),
serde_json::to_vec(strategy),
] {
let bytes = part
.map_err(|e| BacktestError::Bundle(format!("batch id preimage serialisation: {e}")))?;
let len = u64::try_from(bytes.len()).map_err(|_| BacktestError::ArithmeticOverflow)?;
hasher.update(len.to_le_bytes());
hasher.update(&bytes);
}
let full = to_hex(&hasher.finalize());
full.get(..16)
.map(str::to_string)
.ok_or_else(|| BacktestError::Bundle("batch id hash is too short".to_string()))
}
fn write_batch_index(index: &BatchIndex, output_dir: &Path) -> Result<PathBuf, BacktestError> {
let dir = output_dir.join(format!("batch_{}", index.batch_id));
std::fs::create_dir_all(&dir).map_err(|e| batch_err("create batch index directory", &e))?;
let value = serde_json::to_value(index).map_err(|e| batch_err("index to json value", &e))?;
let mut json =
serde_json::to_string_pretty(&value).map_err(|e| batch_err("index to json", &e))?;
json.push('\n');
let temp = dir.join(".index.json.partial");
std::fs::write(&temp, &json).map_err(|e| batch_err("write temp index", &e))?;
let final_path = dir.join("index.json");
if let Err(error) = std::fs::rename(&temp, &final_path) {
let _ = std::fs::remove_file(&temp);
return Err(batch_err("publish index.json", &error));
}
Ok(final_path)
}
fn batch_err(context: &str, error: &dyn std::fmt::Display) -> BacktestError {
BacktestError::Bundle(format!("batch index {context}: {error}"))
}