use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use pyo3::exceptions::PyImportError;
use pyo3::prelude::*;
use pyo3::types::PyModule;
use super::errors::{guard_boundary, to_pyerr};
use crate::ResourceLimits;
use crate::bundle::read_bundle;
use crate::error::BacktestError;
fn bundle_err(py: Python<'_>, message: String) -> PyErr {
to_pyerr(py, BacktestError::Bundle(message))
}
#[pyclass(name = "Bundle", module = "ironcondor")]
pub struct Bundle {
dir: PathBuf,
}
impl Bundle {
#[must_use]
pub(crate) fn from_dir(dir: PathBuf) -> Self {
Self { dir }
}
}
#[pymethods]
impl Bundle {
#[getter]
fn path(&self) -> String {
self.dir.display().to_string()
}
fn fills<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
self.read_table(py, "fills.parquet")
}
fn equity_curve<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
self.read_table(py, "equity_curve.parquet")
}
fn positions<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
self.read_table(py, "positions.parquet")
}
fn greeks_attribution<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
self.read_table(py, "greeks_attribution.parquet")
}
fn metrics<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
guard_boundary(|| {
let limits = ResourceLimits::default();
let buf = self.read_manifest_bounded(py, &limits)?;
let value: serde_json::Value = serde_json::from_slice(&buf)
.map_err(|e| bundle_err(py, format!("manifest.json is not valid JSON: {e}")))?;
let metrics = value
.get("metrics")
.ok_or_else(|| bundle_err(py, "manifest.json has no metrics object".to_string()))?;
let metrics_json = serde_json::to_string(metrics)
.map_err(|e| bundle_err(py, format!("cannot re-encode manifest metrics: {e}")))?;
let json = py.import("json")?;
json.call_method1("loads", (metrics_json,))
})
}
#[pyo3(signature = (dir, overwrite = false))]
fn write(&self, py: Python<'_>, dir: String, overwrite: bool) -> PyResult<Self> {
guard_boundary(|| {
emit_deprecation(
py,
"Bundle.write() is a deprecated copy alias: run() already wrote the bundle at \
bundle.path; write(dir) only copies it and never re-runs the engine.",
)?;
let dest = PathBuf::from(&dir);
let source = self.dir.canonicalize().map_err(|e| {
bundle_err(
py,
format!("cannot resolve bundle {}: {e}", self.dir.display()),
)
})?;
if let Some(dest_canon) = canonicalize_dest(&dest)
&& source.starts_with(&dest_canon)
{
return Err(bundle_err(
py,
format!(
"refusing to write bundle onto itself: destination {dir} resolves to the \
source bundle {} (or a directory containing it)",
self.dir.display()
),
));
}
let dest_exists = dest
.try_exists()
.map_err(|e| bundle_err(py, format!("cannot stat {dir}: {e}")))?;
if dest_exists && !overwrite {
return Err(bundle_err(
py,
format!("destination {dir} already exists (pass overwrite=True to replace it)"),
));
}
let parent = dest
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
std::fs::create_dir_all(&parent)
.map_err(|e| bundle_err(py, format!("cannot create {}: {e}", parent.display())))?;
let staging = unique_sibling(&parent, &dest, "stage");
std::fs::create_dir(&staging).map_err(|e| {
bundle_err(
py,
format!("cannot create staging dir {}: {e}", staging.display()),
)
})?;
if let Err(err) = self.copy_files_into(py, &staging) {
let _ = std::fs::remove_dir_all(&staging);
return Err(err);
}
if dest_exists {
let backup = unique_sibling(&parent, &dest, "backup");
if let Err(e) = std::fs::rename(&dest, &backup) {
let _ = std::fs::remove_dir_all(&staging);
return Err(bundle_err(py, format!("cannot replace {dir}: {e}")));
}
if let Err(e) = std::fs::rename(&staging, &dest) {
let _ = std::fs::rename(&backup, &dest);
let _ = std::fs::remove_dir_all(&staging);
return Err(bundle_err(py, format!("cannot publish copy to {dir}: {e}")));
}
let _ = std::fs::remove_dir_all(&backup);
} else if let Err(e) = std::fs::rename(&staging, &dest) {
let _ = std::fs::remove_dir_all(&staging);
return Err(bundle_err(py, format!("cannot publish copy to {dir}: {e}")));
}
Ok(Self::from_dir(dest))
})
}
}
impl Bundle {
fn read_manifest_bounded(&self, py: Python<'_>, limits: &ResourceLimits) -> PyResult<Vec<u8>> {
use std::io::Read as _;
let path = self.dir.join("manifest.json");
let meta = std::fs::metadata(&path)
.map_err(|e| bundle_err(py, format!("cannot stat {}: {e}", path.display())))?;
if !meta.is_file() {
return Err(bundle_err(
py,
format!("{} is not a regular file", path.display()),
));
}
let len = meta.len();
if len > limits.max_manifest_bytes {
return Err(to_pyerr(
py,
BacktestError::TapeTooLarge {
limit: "max_manifest_bytes",
value: len,
cap: limits.max_manifest_bytes,
},
));
}
let file = std::fs::File::open(&path)
.map_err(|e| bundle_err(py, format!("cannot open {}: {e}", path.display())))?;
let cap = usize::try_from(len).unwrap_or(0);
let mut buf = Vec::with_capacity(cap);
file.take(limits.max_manifest_bytes)
.read_to_end(&mut buf)
.map_err(|e| bundle_err(py, format!("cannot read {}: {e}", path.display())))?;
Ok(buf)
}
fn copy_files_into(&self, py: Python<'_>, dest_dir: &Path) -> PyResult<()> {
let entries = std::fs::read_dir(&self.dir).map_err(|e| {
bundle_err(
py,
format!("cannot read bundle {}: {e}", self.dir.display()),
)
})?;
for entry in entries {
let entry =
entry.map_err(|e| bundle_err(py, format!("cannot read bundle entry: {e}")))?;
let file_type = entry
.file_type()
.map_err(|e| bundle_err(py, format!("cannot stat bundle entry: {e}")))?;
if file_type.is_file() {
let target = dest_dir.join(entry.file_name());
std::fs::copy(entry.path(), &target).map_err(|e| {
bundle_err(py, format!("cannot copy {:?}: {e}", entry.file_name()))
})?;
}
}
Ok(())
}
fn read_table<'py>(&self, py: Python<'py>, file: &str) -> PyResult<Bound<'py, PyAny>> {
guard_boundary(|| {
let path = self.dir.join(file);
if !path.is_file() {
return Err(bundle_err(
py,
format!("bundle table {} is missing", path.display()),
));
}
let path_str = path.to_str().ok_or_else(|| {
bundle_err(
py,
format!("bundle path {} is not valid UTF-8", path.display()),
)
})?;
let pandas = import_pandas(py)?;
pandas.call_method1("read_parquet", (path_str,))
})
}
}
#[pyfunction]
pub fn load_bundle(py: Python<'_>, dir: String) -> PyResult<Bundle> {
guard_boundary(|| {
let path = PathBuf::from(&dir);
let limits = ResourceLimits::default();
py.detach(|| read_bundle(&path, &limits))
.map_err(|e| to_pyerr(py, e))?;
Ok(Bundle::from_dir(path))
})
}
fn import_pandas(py: Python<'_>) -> PyResult<Bound<'_, PyModule>> {
py.import("pandas").map_err(|_| {
PyImportError::new_err(
"pandas is required for Bundle DataFrame accessors; install the optional extra with \
`pip install ironcondor[pandas]` (pulls pandas + pyarrow). The bundle is plain \
Parquet + JSON at bundle.path, so polars / pyarrow can read it directly instead.",
)
})
}
fn canonicalize_dest(dest: &Path) -> Option<PathBuf> {
if let Ok(canon) = dest.canonicalize() {
return Some(canon);
}
let parent = dest.parent()?;
let name = dest.file_name()?;
let parent = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
Some(parent.canonicalize().ok()?.join(name))
}
fn unique_sibling(parent: &Path, dest: &Path, tag: &str) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let base = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("bundle");
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
parent.join(format!(".{base}.{tag}.{pid}.{n}.tmp"))
}
fn emit_deprecation(py: Python<'_>, message: &str) -> PyResult<()> {
let warnings = py.import("warnings")?;
let category = py.get_type::<pyo3::exceptions::PyDeprecationWarning>();
warnings.call_method1("warn", (message, category))?;
Ok(())
}