use std::path::PathBuf;
use chrono::DateTime;
use optionstratlib::ExpirationDate;
use optionstratlib::simulation::ExitPolicy;
use pyo3::prelude::*;
use rust_decimal::Decimal;
use super::errors::{guard_boundary, to_pyerr};
use crate::BacktestConfig as RustBacktestConfig;
use crate::config::{FeeSchedule, LiquidityProfile, ResourceLimits, SlippageModel};
use crate::data::DataSourceSpec;
use crate::domain::{
ExecutionMode, IronCondorSpec, PriceCents, Quantity, StrategySpec, Underlying,
};
use crate::error::BacktestError;
const DEFAULT_OUTPUT_DIR: &str = "ironcondor_runs";
const DEFAULT_MARKETABLE_CAP_TICKS: u32 = 10;
#[pyclass(name = "BacktestConfig", module = "ironcondor")]
pub struct PyBacktestConfig {
seed: u64,
capital_cents: u64,
data_parquet_path: Option<String>,
strategy: Option<StrategySpec>,
mode: ExecutionMode,
slippage: SlippageModel,
fees: FeeSchedule,
exit: ExitPolicy,
marketable_cap_ticks: u32,
liquidity_profile: LiquidityProfile,
limits: ResourceLimits,
output_dir: Option<PathBuf>,
overwrite: bool,
}
#[pymethods]
impl PyBacktestConfig {
#[new]
#[pyo3(signature = (seed = 0, capital_cents = 1_000_000))]
#[must_use]
fn new(seed: u64, capital_cents: u64) -> Self {
Self {
seed,
capital_cents,
data_parquet_path: None,
strategy: None,
mode: ExecutionMode::Naive,
slippage: SlippageModel::None,
fees: FeeSchedule {
per_contract_cents: 0,
per_order_cents: 0,
},
exit: ExitPolicy::Expiration,
marketable_cap_ticks: DEFAULT_MARKETABLE_CAP_TICKS,
liquidity_profile: LiquidityProfile::default(),
limits: ResourceLimits::default(),
output_dir: None,
overwrite: false,
}
}
fn seed<'py>(mut slf: PyRefMut<'py, Self>, seed: u64) -> PyRefMut<'py, Self> {
slf.seed = seed;
slf
}
fn capital_cents<'py>(mut slf: PyRefMut<'py, Self>, capital_cents: u64) -> PyRefMut<'py, Self> {
slf.capital_cents = capital_cents;
slf
}
fn data_parquet<'py>(mut slf: PyRefMut<'py, Self>, path: String) -> PyRefMut<'py, Self> {
slf.data_parquet_path = Some(path);
slf
}
#[pyo3(signature = (
underlying,
underlying_price_cents,
short_call_strike_cents,
short_put_strike_cents,
long_call_strike_cents,
long_put_strike_cents,
expiration_ns,
quantity,
premium_short_call_cents,
premium_short_put_cents,
premium_long_call_cents,
premium_long_put_cents,
implied_volatility = 0.20,
risk_free_rate = 0.05,
dividend_yield = 0.0,
open_fee_cents = 0,
close_fee_cents = 0,
))]
#[allow(
clippy::too_many_arguments,
reason = "one argument per IronCondorSpec construction field (the full v0.1 parameter set); the builder centralises the marshalling in one place"
)]
fn strategy_iron_condor<'py>(
mut slf: PyRefMut<'py, Self>,
underlying: String,
underlying_price_cents: u64,
short_call_strike_cents: u64,
short_put_strike_cents: u64,
long_call_strike_cents: u64,
long_put_strike_cents: u64,
expiration_ns: i64,
quantity: u32,
premium_short_call_cents: u64,
premium_short_put_cents: u64,
premium_long_call_cents: u64,
premium_long_put_cents: u64,
implied_volatility: f64,
risk_free_rate: f64,
dividend_yield: f64,
open_fee_cents: u64,
close_fee_cents: u64,
) -> PyResult<PyRefMut<'py, Self>> {
let py = slf.py();
guard_boundary(move || {
let underlying = Underlying::new(underlying).map_err(|e| to_pyerr(py, e))?;
let quantity = Quantity::new(quantity).map_err(|e| to_pyerr(py, e))?;
let spec = IronCondorSpec {
underlying,
underlying_price: PriceCents::new(underlying_price_cents),
short_call_strike: PriceCents::new(short_call_strike_cents),
short_put_strike: PriceCents::new(short_put_strike_cents),
long_call_strike: PriceCents::new(long_call_strike_cents),
long_put_strike: PriceCents::new(long_put_strike_cents),
expiration: ExpirationDate::DateTime(DateTime::from_timestamp_nanos(expiration_ns)),
implied_volatility: decimal_from_f64(implied_volatility, "implied_volatility")
.map_err(|e| to_pyerr(py, e))?,
risk_free_rate: decimal_from_f64(risk_free_rate, "risk_free_rate")
.map_err(|e| to_pyerr(py, e))?,
dividend_yield: decimal_from_f64(dividend_yield, "dividend_yield")
.map_err(|e| to_pyerr(py, e))?,
quantity,
premium_short_call: PriceCents::new(premium_short_call_cents),
premium_short_put: PriceCents::new(premium_short_put_cents),
premium_long_call: PriceCents::new(premium_long_call_cents),
premium_long_put: PriceCents::new(premium_long_put_cents),
open_fee: PriceCents::new(open_fee_cents),
close_fee: PriceCents::new(close_fee_cents),
};
slf.strategy = Some(StrategySpec::IronCondor(spec));
Ok(slf)
})
}
#[pyo3(signature = (slippage_cents = None))]
fn execution_naive<'py>(
mut slf: PyRefMut<'py, Self>,
slippage_cents: Option<u64>,
) -> PyRefMut<'py, Self> {
slf.mode = ExecutionMode::Naive;
slf.slippage = match slippage_cents {
Some(cents) => SlippageModel::FixedCents { cents },
None => SlippageModel::None,
};
slf
}
fn execution_realistic<'py>(mut slf: PyRefMut<'py, Self>) -> PyRefMut<'py, Self> {
slf.mode = ExecutionMode::Realistic;
slf
}
#[pyo3(signature = (per_contract_cents = 0, per_order_cents = 0))]
fn fees<'py>(
mut slf: PyRefMut<'py, Self>,
per_contract_cents: u64,
per_order_cents: u64,
) -> PyRefMut<'py, Self> {
slf.fees = FeeSchedule {
per_contract_cents,
per_order_cents,
};
slf
}
fn exit_profit_percent<'py>(
mut slf: PyRefMut<'py, Self>,
percent: f64,
) -> PyResult<PyRefMut<'py, Self>> {
let py = slf.py();
guard_boundary(move || {
let value = decimal_from_f64(percent, "profit percent").map_err(|e| to_pyerr(py, e))?;
slf.exit = ExitPolicy::ProfitPercent(value);
Ok(slf)
})
}
fn exit_loss_percent<'py>(
mut slf: PyRefMut<'py, Self>,
percent: f64,
) -> PyResult<PyRefMut<'py, Self>> {
let py = slf.py();
guard_boundary(move || {
let value = decimal_from_f64(percent, "loss percent").map_err(|e| to_pyerr(py, e))?;
slf.exit = ExitPolicy::LossPercent(value);
Ok(slf)
})
}
fn exit_time_steps<'py>(mut slf: PyRefMut<'py, Self>, steps: usize) -> PyRefMut<'py, Self> {
slf.exit = ExitPolicy::TimeSteps(steps);
slf
}
fn exit_expiration<'py>(mut slf: PyRefMut<'py, Self>) -> PyRefMut<'py, Self> {
slf.exit = ExitPolicy::Expiration;
slf
}
fn output_dir<'py>(mut slf: PyRefMut<'py, Self>, path: String) -> PyRefMut<'py, Self> {
slf.output_dir = Some(PathBuf::from(path));
slf
}
#[pyo3(signature = (overwrite = true))]
fn overwrite<'py>(mut slf: PyRefMut<'py, Self>, overwrite: bool) -> PyRefMut<'py, Self> {
slf.overwrite = overwrite;
slf
}
}
impl PyBacktestConfig {
pub(crate) fn to_rust(
&self,
) -> Result<(RustBacktestConfig, StrategySpec, ExitPolicy), BacktestError> {
let data_source = match &self.data_parquet_path {
Some(path) => DataSourceSpec::Parquet {
path: path.clone(),
sha256: String::new(),
},
None => {
return Err(BacktestError::Config(
"no data source configured: call .data_parquet(path)".to_string(),
));
}
};
let strategy = self.strategy.clone().ok_or_else(|| {
BacktestError::Config(
"no strategy configured: call .strategy_iron_condor(...)".to_string(),
)
})?;
let output_dir = self
.output_dir
.clone()
.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT_DIR));
let config = RustBacktestConfig {
data_source,
mode: self.mode,
seed: self.seed,
initial_capital: self.capital_cents,
fees: self.fees,
slippage: self.slippage.clone(),
marketable_cap_ticks: self.marketable_cap_ticks,
liquidity_profile: self.liquidity_profile,
limits: self.limits,
output_dir,
overwrite: self.overwrite,
};
config.validate()?;
Ok((config, strategy, self.exit.clone()))
}
}
fn decimal_from_f64(value: f64, field: &str) -> Result<Decimal, BacktestError> {
Decimal::try_from(value).map_err(|_| {
BacktestError::Config(format!("{field} {value} is not a representable decimal"))
})
}
#[cfg(all(test, feature = "python"))]
mod tests {
use optionstratlib::simulation::ExitPolicy;
use rust_decimal::Decimal;
use super::{DEFAULT_OUTPUT_DIR, PyBacktestConfig};
use crate::config::{
BacktestConfig, FeeSchedule, LiquidityProfile, ResourceLimits, SlippageModel,
};
use crate::data::DataSourceSpec;
use crate::domain::{ExecutionMode, StrategySpec};
const TS0_NS: i64 = 1_750_291_200_000_000_000;
const EXPIRY_NS: i64 = TS0_NS + 30 * 86_400_000_000_000;
fn base() -> PyBacktestConfig {
PyBacktestConfig {
seed: 7,
capital_cents: 10_000_000,
data_parquet_path: Some("chains/spx.parquet".to_string()),
strategy: Some(strategy()),
mode: ExecutionMode::Naive,
slippage: SlippageModel::None,
fees: FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
},
exit: ExitPolicy::TimeSteps(1_000_000),
marketable_cap_ticks: 10,
liquidity_profile: LiquidityProfile::default(),
limits: ResourceLimits::default(),
output_dir: None,
overwrite: false,
}
}
fn strategy() -> StrategySpec {
use chrono::DateTime;
use optionstratlib::ExpirationDate;
use crate::domain::{IronCondorSpec, PriceCents, Quantity, Underlying};
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is valid");
};
let Ok(quantity) = Quantity::new(1) else {
panic!("1 is a valid quantity");
};
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(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),
})
}
#[test]
fn test_to_rust_marshals_every_field_and_preserves_cents() {
let cfg = base();
let Ok((rust, strat, exit)) = cfg.to_rust() else {
panic!("a complete config must marshal");
};
assert_eq!(rust.initial_capital, 10_000_000);
assert_eq!(rust.seed, 7);
assert_eq!(rust.fees.per_contract_cents, 65);
assert_eq!(rust.fees.per_order_cents, 100);
assert_eq!(rust.mode, ExecutionMode::Naive);
assert!(matches!(
rust.data_source,
DataSourceSpec::Parquet { ref path, ref sha256 }
if path == "chains/spx.parquet" && sha256.is_empty()
));
assert_eq!(strat, strategy());
assert_eq!(exit, ExitPolicy::TimeSteps(1_000_000));
assert_eq!(
rust.output_dir,
std::path::PathBuf::from(DEFAULT_OUTPUT_DIR)
);
assert!(rust.validate().is_ok());
}
#[test]
fn test_to_rust_errors_when_data_source_missing() {
let mut cfg = base();
cfg.data_parquet_path = None;
assert!(matches!(
cfg.to_rust(),
Err(crate::error::BacktestError::Config(msg)) if msg.contains("data source")
));
}
#[test]
fn test_to_rust_errors_when_strategy_missing() {
let mut cfg = base();
cfg.strategy = None;
assert!(matches!(
cfg.to_rust(),
Err(crate::error::BacktestError::Config(msg)) if msg.contains("strategy")
));
}
#[test]
fn test_to_rust_propagates_config_validation_error() {
let mut cfg = base();
cfg.capital_cents = 0;
assert!(matches!(
cfg.to_rust(),
Err(crate::error::BacktestError::Config(msg)) if msg.contains("initial capital")
));
}
#[test]
fn test_to_rust_carries_realistic_mode() {
let mut cfg = base();
cfg.mode = ExecutionMode::Realistic;
let Ok((rust, _, _)) = cfg.to_rust() else {
panic!("realistic-mode config marshals (the feature gate is checked in run_backtest)");
};
assert_eq!(rust.mode, ExecutionMode::Realistic);
}
fn parity_strategy() -> StrategySpec {
use chrono::DateTime;
use optionstratlib::ExpirationDate;
use crate::domain::{IronCondorSpec, PriceCents, Quantity, Underlying};
let Ok(underlying) = Underlying::new("SPX") else {
panic!("SPX is valid");
};
let Ok(quantity) = Quantity::new(1) else {
panic!("1 is a valid quantity");
};
let Ok(iv) = Decimal::try_from(0.20_f64) else {
panic!("0.20 is a representable decimal");
};
let Ok(rate) = Decimal::try_from(0.05_f64) else {
panic!("0.05 is a representable decimal");
};
let Ok(dividend) = Decimal::try_from(0.0_f64) else {
panic!("0.0 is a representable decimal");
};
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(DateTime::from_timestamp_nanos(EXPIRY_NS)),
implied_volatility: iv,
risk_free_rate: rate,
dividend_yield: dividend,
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),
})
}
#[test]
fn test_to_rust_matches_the_shared_parity_scenario() {
let cfg = PyBacktestConfig {
seed: 42,
capital_cents: 10_000_000,
data_parquet_path: Some("iron_condor.parquet".to_string()),
strategy: Some(parity_strategy()),
mode: ExecutionMode::Naive,
slippage: SlippageModel::None,
fees: FeeSchedule {
per_contract_cents: 65,
per_order_cents: 100,
},
exit: ExitPolicy::TimeSteps(1_000_000),
marketable_cap_ticks: 10,
liquidity_profile: LiquidityProfile::default(),
limits: ResourceLimits::default(),
output_dir: Some(std::path::PathBuf::from("runs/out")),
overwrite: false,
};
let expected = BacktestConfig {
data_source: DataSourceSpec::Parquet {
path: "iron_condor.parquet".to_string(),
sha256: String::new(),
},
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: LiquidityProfile::default(),
limits: ResourceLimits::default(),
output_dir: std::path::PathBuf::from("runs/out"),
overwrite: false,
};
let Ok((rust, strat, exit)) = cfg.to_rust() else {
panic!("the shared parity config must marshal");
};
assert_eq!(
rust, expected,
"the marshalled BacktestConfig must equal the Rust parity config field-for-field"
);
assert_eq!(
strat,
parity_strategy(),
"the marshalled strategy must equal the Rust parity spec (identical integer cents + decimals)"
);
assert_eq!(
exit,
ExitPolicy::TimeSteps(1_000_000),
"the exit policy must carry through unchanged"
);
}
}