use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::analytics::metrics::Metrics;
use crate::config::{BacktestConfig, FeeSchedule, LiquidityProfile, ResourceLimits, SlippageModel};
use crate::data::DataSourceSpec;
use crate::domain::{
EquityPoint, ExecutionMode, FillRow, GreeksAttributionRow, PositionRow, StrategySpec,
};
use crate::error::BacktestError;
pub const BUNDLE_SCHEMA: &str = "ironcondor.bundle.v1";
pub const FILLS_SORT_COLUMNS: &[&str] = &["step", "order_id", "fill_seq"];
pub const POSITIONS_SORT_COLUMNS: &[&str] = &["step", "position_id"];
pub const EQUITY_CURVE_SORT_COLUMNS: &[&str] = &["step"];
pub const GREEKS_ATTRIBUTION_SORT_COLUMNS: &[&str] = &["step"];
#[must_use]
pub const fn fill_sort_key(row: &FillRow) -> (u32, u64, u32) {
(row.step, row.order_id, row.fill_seq)
}
#[must_use]
pub const fn position_sort_key(row: &PositionRow) -> (u32, u64) {
(row.step, row.position_id)
}
#[must_use]
pub const fn equity_sort_key(row: &EquityPoint) -> u32 {
row.step
}
#[must_use]
pub const fn greeks_sort_key(row: &GreeksAttributionRow) -> u32 {
row.step
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(into = "String", try_from = "String")]
pub struct RunId(String);
const RUN_ID_HEX_LEN: usize = 64;
fn validate_run_id_hex(hex: &str) -> Result<(), BacktestError> {
if hex.len() != RUN_ID_HEX_LEN {
return Err(BacktestError::Bundle(format!(
"run_id must be {RUN_ID_HEX_LEN} lowercase hex characters, got {} characters",
hex.len()
)));
}
if !hex
.bytes()
.all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
{
return Err(BacktestError::Bundle(format!(
"run_id must be {RUN_ID_HEX_LEN} lowercase hex characters, got {hex:?}"
)));
}
Ok(())
}
impl TryFrom<String> for RunId {
type Error = BacktestError;
fn try_from(hex: String) -> Result<Self, Self::Error> {
Self::from_hex(hex)
}
}
impl From<RunId> for String {
fn from(id: RunId) -> Self {
id.0
}
}
impl RunId {
#[must_use = "the derived run id must be used"]
pub fn derive(
seed: u64,
config: &BacktestConfig,
strategy: &StrategySpec,
tape_identity: &str,
code_version: &str,
lockfile_sha256: &str,
) -> Result<Self, BacktestError> {
let preimage = RunIdPreimage {
seed,
semantic_config: SemanticConfig {
data_source: &config.data_source,
mode: config.mode,
seed: config.seed,
initial_capital: config.initial_capital,
fees: &config.fees,
slippage: &config.slippage,
marketable_cap_ticks: config.marketable_cap_ticks,
liquidity_profile: &config.liquidity_profile,
limits: &config.limits,
},
strategy,
tape_identity,
build_identity: BuildIdentity {
code_version,
lockfile_sha256,
},
};
let bytes = serde_json::to_vec(&preimage).map_err(|error| {
BacktestError::Bundle(format!("run_id preimage serialisation failed: {error}"))
})?;
Ok(Self(to_hex(&Sha256::digest(&bytes))))
}
#[must_use = "the validated run id must be used"]
pub fn from_hex(hex: String) -> Result<Self, BacktestError> {
validate_run_id_hex(&hex)?;
Ok(Self(hex))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Serialize)]
struct SemanticConfig<'a> {
data_source: &'a DataSourceSpec,
mode: ExecutionMode,
seed: u64,
initial_capital: u64,
fees: &'a FeeSchedule,
slippage: &'a SlippageModel,
marketable_cap_ticks: u32,
liquidity_profile: &'a LiquidityProfile,
limits: &'a ResourceLimits,
}
#[derive(Serialize)]
struct BuildIdentity<'a> {
code_version: &'a str,
lockfile_sha256: &'a str,
}
#[derive(Serialize)]
struct RunIdPreimage<'a> {
seed: u64,
semantic_config: SemanticConfig<'a>,
strategy: &'a StrategySpec,
tape_identity: &'a str,
build_identity: BuildIdentity<'a>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct RowCounts {
pub fills: u64,
pub equity_curve: u64,
pub positions: u64,
pub greeks_attribution: u64,
}
impl RowCounts {
#[must_use]
pub const fn new(
fills: u64,
equity_curve: u64,
positions: u64,
greeks_attribution: u64,
) -> Self {
Self {
fills,
equity_curve,
positions,
greeks_attribution,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub schema: String,
pub run_id: RunId,
pub created_utc: String,
pub code_version: String,
pub lockfile_sha256: String,
pub seed: u64,
pub config: BacktestConfig,
pub strategy: StrategySpec,
pub data_source: DataSourceSpec,
pub metrics: Metrics,
pub row_counts: RowCounts,
}
fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len().saturating_mul(2));
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use rust_decimal::Decimal;
use super::{
BUNDLE_SCHEMA, EQUITY_CURVE_SORT_COLUMNS, FILLS_SORT_COLUMNS,
GREEKS_ATTRIBUTION_SORT_COLUMNS, Manifest, POSITIONS_SORT_COLUMNS, RowCounts, RunId,
};
use crate::analytics::metrics::Metrics;
use crate::config::{BacktestConfig, FeeSchedule, SlippageModel};
use crate::data::DataSourceSpec;
use crate::domain::{
ExecutionMode, IronCondorSpec, PriceCents, Quantity, StrategySpec, Underlying,
};
use optionstratlib::ExpirationDate;
use optionstratlib::backtesting::BacktestResult;
const CODE_VERSION: &str = "0.3.0";
const LOCKFILE_SHA: &str = "0000000000000000000000000000000000000000000000000000000000000000";
const TAPE_SHA: &str = "1111111111111111111111111111111111111111111111111111111111111111";
fn base_config() -> BacktestConfig {
BacktestConfig {
data_source: DataSourceSpec::Parquet {
path: "chains/spx.parquet".to_string(),
sha256: TAPE_SHA.to_string(),
},
mode: ExecutionMode::Naive,
seed: 42,
initial_capital: 10_000_000,
fees: FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
},
slippage: SlippageModel::None,
marketable_cap_ticks: 10,
liquidity_profile: crate::config::LiquidityProfile::default(),
limits: crate::config::ResourceLimits::default(),
output_dir: "runs/out".into(),
overwrite: false,
}
}
fn strategy() -> StrategySpec {
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is valid");
};
let Ok(quantity) = Quantity::new(1) else {
panic!("1 is valid");
};
StrategySpec::IronCondor(IronCondorSpec {
underlying,
underlying_price: PriceCents::new(500_000),
short_call_strike: PriceCents::new(510_000),
short_put_strike: PriceCents::new(490_000),
long_call_strike: PriceCents::new(520_000),
long_put_strike: PriceCents::new(480_000),
expiration: ExpirationDate::DateTime(chrono::DateTime::from_timestamp_nanos(
1_750_291_200_000_000_000,
)),
implied_volatility: Decimal::new(20, 2),
risk_free_rate: Decimal::new(5, 2),
dividend_yield: Decimal::ZERO,
quantity,
premium_short_call: PriceCents::new(2_000),
premium_short_put: PriceCents::new(1_800),
premium_long_call: PriceCents::new(800),
premium_long_put: PriceCents::new(700),
open_fee: PriceCents::new(65),
close_fee: PriceCents::new(65),
})
}
fn derive(config: &BacktestConfig) -> RunId {
match RunId::derive(
config.seed,
config,
&strategy(),
TAPE_SHA,
CODE_VERSION,
LOCKFILE_SHA,
) {
Ok(id) => id,
Err(error) => panic!("run_id derives: {error}"),
}
}
#[test]
fn test_bundle_schema_tag_is_frozen_v1() {
assert_eq!(BUNDLE_SCHEMA, "ironcondor.bundle.v1");
}
#[test]
fn test_sort_columns_match_the_pinned_keys() {
assert_eq!(FILLS_SORT_COLUMNS, ["step", "order_id", "fill_seq"]);
assert_eq!(POSITIONS_SORT_COLUMNS, ["step", "position_id"]);
assert_eq!(EQUITY_CURVE_SORT_COLUMNS, ["step"]);
assert_eq!(GREEKS_ATTRIBUTION_SORT_COLUMNS, ["step"]);
}
#[test]
fn test_run_id_serialises_transparently_as_a_bare_string() {
let hex = "a".repeat(64);
let Ok(id) = RunId::from_hex(hex.clone()) else {
panic!("a 64-char hex id must build");
};
let Ok(json) = serde_json::to_string(&id) else {
panic!("run id serialises");
};
assert_eq!(json, format!("\"{hex}\""));
let back: Result<RunId, _> = serde_json::from_str(&format!("\"{hex}\""));
assert!(matches!(back, Ok(ref r) if r.as_str() == hex));
}
#[test]
fn test_run_id_from_hex_rejects_malformed_ids() {
assert!(RunId::from_hex("abc123".to_string()).is_err(), "too short");
assert!(
RunId::from_hex("A".repeat(64)).is_err(),
"uppercase hex rejected"
);
assert!(
RunId::from_hex("g".repeat(64)).is_err(),
"non-hex character rejected"
);
assert!(
RunId::from_hex(format!("../{}", "a".repeat(61))).is_err(),
"a path fragment is rejected"
);
assert!(
RunId::from_hex("a".repeat(64)).is_ok(),
"valid hex accepted"
);
}
#[test]
fn test_run_id_deserialize_rejects_malformed_ids() {
let bad: Result<RunId, _> = serde_json::from_str("\"not-a-sha256\"");
assert!(bad.is_err(), "a non-hex run_id must fail to deserialize");
let ok: Result<RunId, _> = serde_json::from_str(&format!("\"{}\"", "0".repeat(64)));
assert!(ok.is_ok(), "a valid 64-char hex run_id deserializes");
}
#[test]
fn test_run_id_is_byte_stable_for_the_same_tuple() {
let config = base_config();
assert_eq!(derive(&config), derive(&config));
}
#[test]
fn test_run_id_excludes_overwrite_and_output_path() {
let config = base_config();
let mut only_operational = base_config();
only_operational.overwrite = true;
only_operational.output_dir = "a/totally/different/dir".into();
assert_eq!(
derive(&config),
derive(&only_operational),
"overwrite / output_dir must not change the run_id"
);
}
#[test]
fn test_run_id_changes_when_any_hashed_input_changes() {
let base = derive(&base_config());
let mut seeded = base_config();
seeded.seed = 43;
assert_ne!(
base,
match RunId::derive(
seeded.seed,
&seeded,
&strategy(),
TAPE_SHA,
CODE_VERSION,
LOCKFILE_SHA
) {
Ok(id) => id,
Err(error) => panic!("derive: {error}"),
},
"a different seed must change the run_id"
);
let mut moded = base_config();
moded.mode = ExecutionMode::Realistic;
assert_ne!(
base,
derive(&moded),
"a different mode must change the run_id"
);
let config = base_config();
let mut other_strategy = strategy();
if let StrategySpec::IronCondor(ref mut spec) = other_strategy {
spec.short_call_strike = PriceCents::new(515_000);
}
let strategy_changed = match RunId::derive(
config.seed,
&config,
&other_strategy,
TAPE_SHA,
CODE_VERSION,
LOCKFILE_SHA,
) {
Ok(id) => id,
Err(error) => panic!("derive: {error}"),
};
assert_ne!(
base, strategy_changed,
"a different strategy must change the run_id"
);
let tape_changed = match RunId::derive(
config.seed,
&config,
&strategy(),
"2222222222222222222222222222222222222222222222222222222222222222",
CODE_VERSION,
LOCKFILE_SHA,
) {
Ok(id) => id,
Err(error) => panic!("derive: {error}"),
};
assert_ne!(
base, tape_changed,
"a different tape identity must change the run_id"
);
let build_changed = match RunId::derive(
config.seed,
&config,
&strategy(),
TAPE_SHA,
"0.3.1",
LOCKFILE_SHA,
) {
Ok(id) => id,
Err(error) => panic!("derive: {error}"),
};
assert_ne!(
base, build_changed,
"a different build identity must change the run_id"
);
}
#[test]
fn test_run_id_is_a_lower_hex_sha256() {
let id = derive(&base_config());
assert_eq!(id.as_str().len(), 64, "sha256 is 32 bytes = 64 hex chars");
assert!(
id.as_str()
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"run_id is lower-hex"
);
}
#[test]
fn test_row_counts_serialises_as_the_four_keyed_object() {
let counts = RowCounts::new(12, 5, 7, 5);
let Ok(json) = serde_json::to_value(counts) else {
panic!("row counts serialise");
};
let Some(obj) = json.as_object() else {
panic!("object");
};
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
["equity_curve", "fills", "greeks_attribution", "positions"]
);
assert!(matches!(
obj.get("fills").and_then(serde_json::Value::as_u64),
Some(12)
));
}
fn sample_manifest() -> Manifest {
let config = base_config();
let run_id = derive(&config);
Manifest {
schema: BUNDLE_SCHEMA.to_string(),
run_id,
created_utc: "2026-07-16T00:00:00Z".to_string(),
code_version: CODE_VERSION.to_string(),
lockfile_sha256: LOCKFILE_SHA.to_string(),
seed: config.seed,
config: config.clone(),
strategy: strategy(),
data_source: config.data_source.clone(),
metrics: Metrics::from_result(&BacktestResult::default()),
row_counts: RowCounts::new(4, 3, 4, 3),
}
}
#[test]
fn test_manifest_carries_exactly_the_docs_06_fields() {
let Ok(json) = serde_json::to_value(sample_manifest()) else {
panic!("manifest serialises");
};
let Some(obj) = json.as_object() else {
panic!("manifest is a JSON object");
};
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
let mut expected = [
"schema",
"run_id",
"created_utc",
"code_version",
"lockfile_sha256",
"seed",
"config",
"strategy",
"data_source",
"metrics",
"row_counts",
];
expected.sort_unstable();
assert_eq!(
keys, expected,
"manifest must carry exactly the docs/05 §6 fields"
);
assert!(!obj.contains_key("currency"), "v1 has no currency field");
assert!(obj.contains_key("metrics"));
assert!(obj.contains_key("row_counts"));
}
#[test]
fn test_manifest_round_trips_through_json() {
let Ok(json) = serde_json::to_string(&sample_manifest()) else {
panic!("manifest serialises");
};
let back: Result<Manifest, _> = serde_json::from_str(&json);
let Ok(back) = back else {
panic!("manifest deserialises");
};
assert_eq!(back.schema, BUNDLE_SCHEMA);
assert_eq!(back.run_id, sample_manifest().run_id);
assert_eq!(back.seed, 42);
assert_eq!(back.config, base_config());
assert_eq!(back.row_counts, RowCounts::new(4, 3, 4, 3));
}
}