use std::any::Any;
use std::panic::{AssertUnwindSafe, catch_unwind};
use pyo3::PyTypeInfo;
use pyo3::exceptions::{PyException, PyOSError, PyValueError};
use pyo3::prelude::*;
use pyo3::sync::PyOnceLock;
use pyo3::types::{PyDict, PyTuple, PyType};
use crate::error::BacktestError;
pyo3::create_exception!(
ironcondor,
IronCondorError,
PyException,
"Base class for every exception the ironcondor engine raises; `except \
ironcondor.IronCondorError` catches them all."
);
pyo3::create_exception!(
ironcondor,
ExecutionError,
IronCondorError,
"A fill-model or order-book execution failure (BacktestError::Execution / \
OrderBook)."
);
pyo3::create_exception!(
ironcondor,
StrategyError,
IronCondorError,
"A strategy-adapter failure surfaced from optionstratlib \
(BacktestError::Strategy)."
);
pyo3::create_exception!(
ironcondor,
EngineError,
IronCondorError,
"An engine-internal failure: checked-arithmetic overflow \
(BacktestError::ArithmeticOverflow) or an unexpected Rust panic caught at \
the FFI boundary."
);
const CONFIG_ERROR_DOC: &str = "An invalid configuration or input value \
(BacktestError::Config / InvalidQuantity / CrossedQuote / \
PriceNotTickAligned). Also a ValueError.";
const DATA_ERROR_DOC: &str = "A data-source, conversion, tape, or session \
failure (BacktestError::DataIo / Data / Conversion / DataOutOfOrder / \
TapeTooLarge / Session). Also an OSError (IOError).";
const BUNDLE_ERROR_DOC: &str = "A result-bundle write or read-back failure \
(BacktestError::Bundle). Also an OSError (IOError).";
static CONFIG_ERROR: PyOnceLock<Py<PyType>> = PyOnceLock::new();
static DATA_ERROR: PyOnceLock<Py<PyType>> = PyOnceLock::new();
static BUNDLE_ERROR: PyOnceLock<Py<PyType>> = PyOnceLock::new();
fn build_multi_base<'py>(
py: Python<'py>,
name: &str,
builtin_base: &Bound<'py, PyType>,
doc: &str,
) -> PyResult<Py<PyType>> {
let ic_base = py.get_type::<IronCondorError>();
let bases = PyTuple::new(py, [ic_base, builtin_base.clone()])?;
let namespace = PyDict::new(py);
namespace.set_item("__module__", "ironcondor")?;
namespace.set_item("__doc__", doc)?;
let type_ctor = py.import("builtins")?.getattr("type")?;
let class = type_ctor.call1((name, bases, namespace))?;
Ok(class.cast_into::<PyType>()?.unbind())
}
pub(crate) fn config_error_type(py: Python<'_>) -> PyResult<Bound<'_, PyType>> {
let cached = CONFIG_ERROR.get_or_try_init(py, || {
build_multi_base(
py,
"ConfigError",
&PyValueError::type_object(py),
CONFIG_ERROR_DOC,
)
})?;
Ok(cached.bind(py).clone())
}
pub(crate) fn data_error_type(py: Python<'_>) -> PyResult<Bound<'_, PyType>> {
let cached = DATA_ERROR.get_or_try_init(py, || {
build_multi_base(py, "DataError", &PyOSError::type_object(py), DATA_ERROR_DOC)
})?;
Ok(cached.bind(py).clone())
}
pub(crate) fn bundle_error_type(py: Python<'_>) -> PyResult<Bound<'_, PyType>> {
let cached = BUNDLE_ERROR.get_or_try_init(py, || {
build_multi_base(
py,
"BundleError",
&PyOSError::type_object(py),
BUNDLE_ERROR_DOC,
)
})?;
Ok(cached.bind(py).clone())
}
#[must_use]
pub(crate) fn to_pyerr(py: Python<'_>, err: BacktestError) -> PyErr {
let message = err.to_string();
match err {
BacktestError::InvalidQuantity(_)
| BacktestError::CrossedQuote { .. }
| BacktestError::PriceNotTickAligned { .. }
| BacktestError::Config(_) => raise(config_error_type(py), message, PyValueError::new_err),
BacktestError::DataIo(_)
| BacktestError::Data(_)
| BacktestError::Conversion(_)
| BacktestError::DataOutOfOrder { .. }
| BacktestError::TapeTooLarge { .. }
| BacktestError::Session(_) => raise(data_error_type(py), message, PyOSError::new_err),
BacktestError::Execution(_) | BacktestError::OrderBook(_) => {
ExecutionError::new_err(message)
}
BacktestError::Strategy(_) => StrategyError::new_err(message),
BacktestError::Bundle(_) => raise(bundle_error_type(py), message, PyOSError::new_err),
BacktestError::ArithmeticOverflow => EngineError::new_err(message),
}
}
fn raise(ty: PyResult<Bound<'_, PyType>>, message: String, fallback: fn(String) -> PyErr) -> PyErr {
match ty {
Ok(ty) => PyErr::from_type(ty, message),
Err(_) => fallback(message),
}
}
pub(crate) fn guard_boundary<F, R>(f: F) -> PyResult<R>
where
F: FnOnce() -> PyResult<R>,
{
match catch_unwind(AssertUnwindSafe(f)) {
Ok(result) => result,
Err(payload) => Err(panic_to_engine_error(payload)),
}
}
#[cold]
fn panic_to_engine_error(payload: Box<dyn Any + Send>) -> PyErr {
let detail = if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_owned()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic payload".to_owned()
};
EngineError::new_err(format!("internal engine panic: {detail}"))
}
pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
let py = module.py();
module.add("IronCondorError", py.get_type::<IronCondorError>())?;
module.add("ExecutionError", py.get_type::<ExecutionError>())?;
module.add("StrategyError", py.get_type::<StrategyError>())?;
module.add("EngineError", py.get_type::<EngineError>())?;
module.add("ConfigError", config_error_type(py)?)?;
module.add("DataError", data_error_type(py)?)?;
module.add("BundleError", bundle_error_type(py)?)?;
module.add_function(wrap_pyfunction!(_panic_for_test, module)?)?;
Ok(())
}
#[doc(hidden)]
#[pyfunction]
pub(crate) fn _panic_for_test(message: String) -> PyResult<()> {
guard_boundary(move || -> PyResult<()> {
panic!("{message}");
})
}
#[cfg(all(test, feature = "python"))]
mod tests {
use super::{
EngineError, ExecutionError, IronCondorError, StrategyError, bundle_error_type,
config_error_type, data_error_type, guard_boundary, to_pyerr,
};
use crate::error::BacktestError;
use pyo3::exceptions::{PyException, PyOSError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyType;
fn with_py<F: for<'py> FnOnce(Python<'py>)>(f: F) {
Python::initialize();
Python::attach(f);
}
fn every_variant() -> Vec<(BacktestError, &'static str)> {
vec![
(BacktestError::InvalidQuantity(0), "ConfigError"),
(
BacktestError::CrossedQuote { bid: 105, ask: 100 },
"ConfigError",
),
(
BacktestError::PriceNotTickAligned {
price: 101,
tick: 5,
},
"ConfigError",
),
(BacktestError::Config("bad capital".into()), "ConfigError"),
(
BacktestError::DataIo(std::io::Error::new(
std::io::ErrorKind::NotFound,
"missing.parquet",
)),
"DataError",
),
(BacktestError::Data("truncated footer".into()), "DataError"),
(BacktestError::Conversion("bad ticker".into()), "DataError"),
(
BacktestError::DataOutOfOrder {
step: 3,
ts: 900,
prev: 1_000,
},
"DataError",
),
(
BacktestError::TapeTooLarge {
limit: "max_steps",
value: 200,
cap: 100,
},
"DataError",
),
(BacktestError::Session("http 500".into()), "DataError"),
(
BacktestError::Execution("oversized close".into()),
"ExecutionError",
),
(
BacktestError::OrderBook("no liquidity".into()),
"ExecutionError",
),
(
BacktestError::Strategy("upstream reject".into()),
"StrategyError",
),
(BacktestError::Bundle("write failed".into()), "BundleError"),
(BacktestError::ArithmeticOverflow, "EngineError"),
]
}
fn type_name(py: Python<'_>, err: &PyErr) -> String {
err.get_type(py)
.name()
.map(|n| n.to_string())
.unwrap_or_default()
}
#[test]
fn test_every_variant_maps_to_expected_exception_class() {
with_py(|py| {
for (err, expected) in every_variant() {
let py_err = to_pyerr(py, err);
assert_eq!(
type_name(py, &py_err),
*expected,
"unexpected exception class for a BacktestError variant"
);
}
});
}
#[test]
fn test_every_variant_is_an_ironcondor_error() {
with_py(|py| {
for (err, _) in every_variant() {
let py_err = to_pyerr(py, err);
assert!(
py_err.is_instance_of::<IronCondorError>(py),
"every engine exception must be an ic.IronCondorError"
);
}
});
}
#[test]
fn test_config_kinds_are_value_errors() {
with_py(|py| {
let cfg_kinds = [
BacktestError::InvalidQuantity(0),
BacktestError::CrossedQuote { bid: 2, ask: 1 },
BacktestError::PriceNotTickAligned { price: 3, tick: 2 },
BacktestError::Config("x".into()),
];
for err in cfg_kinds {
let py_err = to_pyerr(py, err);
assert!(py_err.is_instance_of::<PyValueError>(py));
assert!(py_err.is_instance_of::<IronCondorError>(py));
}
});
}
#[test]
fn test_data_and_bundle_kinds_are_os_errors() {
with_py(|py| {
let os_kinds = [
BacktestError::Data("x".into()),
BacktestError::Conversion("x".into()),
BacktestError::DataOutOfOrder {
step: 1,
ts: 1,
prev: 2,
},
BacktestError::TapeTooLarge {
limit: "max_steps",
value: 2,
cap: 1,
},
BacktestError::Session("x".into()),
BacktestError::Bundle("x".into()),
];
for err in os_kinds {
let py_err = to_pyerr(py, err);
assert!(py_err.is_instance_of::<PyOSError>(py));
assert!(py_err.is_instance_of::<IronCondorError>(py));
}
});
}
#[test]
fn test_single_base_kinds_are_not_value_or_os_errors() {
with_py(|py| {
let kinds = [
BacktestError::Execution("x".into()),
BacktestError::Strategy("x".into()),
BacktestError::ArithmeticOverflow,
];
for err in kinds {
let py_err = to_pyerr(py, err);
assert!(py_err.is_instance_of::<IronCondorError>(py));
assert!(!py_err.is_instance_of::<PyValueError>(py));
assert!(!py_err.is_instance_of::<PyOSError>(py));
}
});
}
#[test]
fn test_message_is_preserved() {
with_py(|py| {
let py_err = to_pyerr(py, BacktestError::CrossedQuote { bid: 105, ask: 100 });
let message = py_err.value(py).to_string();
assert!(
message.contains("crossed quote: bid 105 > ask 100"),
"the offending value must survive the mapping, got {message:?}"
);
});
}
#[test]
fn test_config_error_subclass_relationships() {
with_py(|py| {
let ty: Bound<'_, PyType> = config_error_type(py).expect("config error type builds");
assert!(ty.is_subclass_of::<IronCondorError>().unwrap());
assert!(ty.is_subclass_of::<PyValueError>().unwrap());
assert!(ty.is_subclass_of::<PyException>().unwrap());
});
}
#[test]
fn test_data_error_subclass_relationships() {
with_py(|py| {
let ty: Bound<'_, PyType> = data_error_type(py).expect("data error type builds");
assert!(ty.is_subclass_of::<IronCondorError>().unwrap());
assert!(ty.is_subclass_of::<PyOSError>().unwrap());
});
}
#[test]
fn test_bundle_error_subclass_relationships() {
with_py(|py| {
let ty: Bound<'_, PyType> = bundle_error_type(py).expect("bundle error type builds");
assert!(ty.is_subclass_of::<IronCondorError>().unwrap());
assert!(ty.is_subclass_of::<PyOSError>().unwrap());
});
}
#[test]
fn test_single_base_types_subclass_the_base() {
with_py(|py| {
assert!(
py.get_type::<ExecutionError>()
.is_subclass_of::<IronCondorError>()
.unwrap()
);
assert!(
py.get_type::<StrategyError>()
.is_subclass_of::<IronCondorError>()
.unwrap()
);
assert!(
py.get_type::<EngineError>()
.is_subclass_of::<IronCondorError>()
.unwrap()
);
});
}
#[test]
fn test_guard_boundary_converts_panic_to_engine_error() {
with_py(|py| {
let result: PyResult<()> = guard_boundary(|| -> PyResult<()> {
panic!("boom from rust");
});
let py_err = result.expect_err("a panic must surface as an error");
assert!(
py_err.is_instance_of::<EngineError>(py),
"an induced panic must become ic.EngineError"
);
assert!(py_err.is_instance_of::<IronCondorError>(py));
let message = py_err.value(py).to_string();
assert!(
message.contains("boom from rust"),
"the panic message must be preserved, got {message:?}"
);
});
}
#[test]
fn test_guard_boundary_passes_ok_through() {
let result: PyResult<u8> = guard_boundary(|| Ok(42));
assert!(matches!(result, Ok(42)));
}
}