chore: prepare v1.1.0 release

Update version numbers across Rust, Python, and documentation files to 1.1.0. Enhance the .gitignore to include macOS dSYM files and plans directory. Introduce new dependencies in the Rust core library and update the README to reflect recent performance benchmarks and backtesting engine capabilities. Add new artifacts to the benchmarks manifest and improve documentation for the backtesting engine API.
This commit is contained in:
Pratik Bhadane
2026-03-30 12:45:52 +05:30
parent 2d776b6f90
commit 436954138f
174 changed files with 29297 additions and 10773 deletions
+714 -149
View File
@@ -1,34 +1,126 @@
//! Rust-backed strategy signal generation and backtest core.
//!
//! These functions move the hot loops from Python into Rust while preserving
//! the public Python behavior.
//! Thin PyO3 wrappers delegating to `ferro_ta_core::backtest`.
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
pub mod commission;
pub mod currency;
use commission::PyCommissionModel;
use currency::PyCurrency;
use ferro_ta_core::backtest as core_bt;
use ndarray::Array2;
use numpy::{IntoPyArray, PyArray1, PyArray2, PyReadonlyArray1, PyReadonlyArray2};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use rayon::prelude::*;
fn nan_to_num_with_numpy_defaults(v: f64) -> f64 {
if v.is_nan() {
0.0
} else if v.is_infinite() {
if v.is_sign_positive() {
f64::MAX
} else {
-f64::MAX
use crate::validation;
// ---------------------------------------------------------------------------
// BacktestConfig pyclass wrapping core struct
// ---------------------------------------------------------------------------
#[pyclass(name = "BacktestConfig")]
#[derive(Clone)]
pub struct BacktestConfig {
#[pyo3(get, set)]
pub fill_mode: String,
#[pyo3(get, set)]
pub stop_loss_pct: f64,
#[pyo3(get, set)]
pub take_profit_pct: f64,
#[pyo3(get, set)]
pub trailing_stop_pct: f64,
#[pyo3(get, set)]
pub slippage_bps: f64,
#[pyo3(get, set)]
pub initial_capital: f64,
#[pyo3(get, set)]
pub commission_per_trade: f64,
#[pyo3(get, set)]
pub max_hold_bars: usize,
#[pyo3(get, set)]
pub slippage_pct_range: f64,
#[pyo3(get, set)]
pub breakeven_pct: f64,
#[pyo3(get, set)]
pub periods_per_year: f64,
#[pyo3(get, set)]
pub margin_ratio: f64,
#[pyo3(get, set)]
pub margin_call_pct: f64,
#[pyo3(get, set)]
pub daily_loss_limit: f64,
#[pyo3(get, set)]
pub total_loss_limit: f64,
#[pyo3(get, set)]
pub commission: Option<PyCommissionModel>,
}
#[pymethods]
impl BacktestConfig {
#[new]
#[pyo3(signature = (
fill_mode = "market_open",
stop_loss_pct = 0.0,
take_profit_pct = 0.0,
trailing_stop_pct = 0.0,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
max_hold_bars = 0,
slippage_pct_range = 0.0,
breakeven_pct = 0.0,
periods_per_year = 252.0,
margin_ratio = 0.0,
margin_call_pct = 0.5,
daily_loss_limit = 0.0,
total_loss_limit = 0.0,
commission = None,
))]
#[allow(clippy::too_many_arguments)]
pub fn new(
fill_mode: &str,
stop_loss_pct: f64,
take_profit_pct: f64,
trailing_stop_pct: f64,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
max_hold_bars: usize,
slippage_pct_range: f64,
breakeven_pct: f64,
periods_per_year: f64,
margin_ratio: f64,
margin_call_pct: f64,
daily_loss_limit: f64,
total_loss_limit: f64,
commission: Option<PyCommissionModel>,
) -> Self {
BacktestConfig {
fill_mode: fill_mode.to_string(),
stop_loss_pct,
take_profit_pct,
trailing_stop_pct,
slippage_bps,
initial_capital,
commission_per_trade,
max_hold_bars,
slippage_pct_range,
breakeven_pct,
periods_per_year,
margin_ratio,
margin_call_pct,
daily_loss_limit,
total_loss_limit,
commission,
}
} else {
v
}
}
// ---------------------------------------------------------------------------
// Strategy signal helpers
// Signal generators
// ---------------------------------------------------------------------------
/// RSI threshold strategy:
/// +1 when RSI <= oversold, -1 when RSI >= overbought, 0 otherwise.
/// Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, timeperiod = 14, oversold = 30.0, overbought = 70.0))]
pub fn rsi_threshold_signals<'py>(
@@ -40,26 +132,10 @@ pub fn rsi_threshold_signals<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(timeperiod, "timeperiod", 1)?;
let prices = close.as_slice()?;
let rsi = ferro_ta_core::momentum::rsi(prices, timeperiod);
let out: Vec<f64> = rsi
.iter()
.map(|&v| {
if v.is_nan() {
f64::NAN
} else if v <= oversold {
1.0
} else if v >= overbought {
-1.0
} else {
0.0
}
})
.collect();
let out = core_bt::rsi_threshold_signals(prices, timeperiod, oversold, overbought);
Ok(out.into_pyarray(py))
}
/// SMA crossover strategy:
/// +1 when fast SMA > slow SMA, -1 otherwise. Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, fast = 10, slow = 30))]
pub fn sma_crossover_signals<'py>(
@@ -70,32 +146,12 @@ pub fn sma_crossover_signals<'py>(
) -> PyResult<Bound<'py, PyArray1<f64>>> {
validation::validate_timeperiod(fast, "fast", 1)?;
validation::validate_timeperiod(slow, "slow", 1)?;
if fast >= slow {
return Err(PyValueError::new_err(format!(
"fast ({fast}) must be less than slow ({slow})"
)));
}
let prices = close.as_slice()?;
let sma_fast = ferro_ta_core::overlap::sma(prices, fast);
let sma_slow = ferro_ta_core::overlap::sma(prices, slow);
let out: Vec<f64> = sma_fast
.iter()
.zip(sma_slow.iter())
.map(|(&f, &s)| {
if f.is_nan() || s.is_nan() {
f64::NAN
} else if f > s {
1.0
} else {
-1.0
}
})
.collect();
let out = core_bt::sma_crossover_signals(prices, fast, slow)
.map_err(|e| PyValueError::new_err(e))?;
Ok(out.into_pyarray(py))
}
/// MACD crossover strategy:
/// +1 when MACD line > signal line, -1 otherwise. Warm-up bars are NaN.
#[pyfunction]
#[pyo3(signature = (close, fastperiod = 12, slowperiod = 26, signalperiod = 9))]
pub fn macd_crossover_signals<'py>(
@@ -108,137 +164,646 @@ pub fn macd_crossover_signals<'py>(
validation::validate_timeperiod(fastperiod, "fastperiod", 1)?;
validation::validate_timeperiod(slowperiod, "slowperiod", 1)?;
validation::validate_timeperiod(signalperiod, "signalperiod", 1)?;
if fastperiod >= slowperiod {
return Err(PyValueError::new_err(format!(
"fastperiod ({fastperiod}) must be less than slowperiod ({slowperiod})"
)));
}
let prices = close.as_slice()?;
let (macd_line, signal_line, _) =
ferro_ta_core::overlap::macd(prices, fastperiod, slowperiod, signalperiod);
let out: Vec<f64> = macd_line
.iter()
.zip(signal_line.iter())
.map(|(&m, &s)| {
if m.is_nan() || s.is_nan() {
f64::NAN
} else if m > s {
1.0
} else {
-1.0
}
})
.collect();
let out = core_bt::macd_crossover_signals(prices, fastperiod, slowperiod, signalperiod)
.map_err(|e| PyValueError::new_err(e))?;
Ok(out.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Backtest core
// Backtest core (close-only)
// ---------------------------------------------------------------------------
/// Backtest core loop over close prices and strategy signals.
///
/// Returns `(positions, bar_returns, strategy_returns, equity)`.
#[pyfunction]
#[pyo3(signature = (close, signals, commission_per_trade = 0.0, slippage_bps = 0.0))]
#[pyo3(signature = (
close, signals,
commission = None,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
))]
#[allow(clippy::type_complexity)]
pub fn backtest_core<'py>(
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
signals: PyReadonlyArray1<'py, f64>,
commission_per_trade: f64,
commission: Option<PyRef<'py, PyCommissionModel>>,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
) -> PyResult<(
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let c = close.as_slice()?;
let s = signals.as_slice()?;
validation::validate_equal_length(&[(c.len(), "close"), (s.len(), "signals")])?;
let cm = commission.as_ref().map(|c| &c.inner);
let result = core_bt::backtest_core(c, s, cm, slippage_bps, initial_capital, commission_per_trade)
.map_err(|e| PyValueError::new_err(e))?;
Ok((
result.positions.into_pyarray(py),
result.bar_returns.into_pyarray(py),
result.strategy_returns.into_pyarray(py),
result.equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// OHLCV backtest
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (
open, high, low, close, signals,
fill_mode = "market_open",
stop_loss_pct = 0.0,
take_profit_pct = 0.0,
trailing_stop_pct = 0.0,
commission = None,
slippage_bps = 0.0,
initial_capital = 100_000.0,
commission_per_trade = 0.0,
limit_prices = None,
max_hold_bars = 0,
slippage_pct_range = 0.0,
breakeven_pct = 0.0,
periods_per_year = 252.0,
margin_ratio = 0.0,
margin_call_pct = 0.5,
daily_loss_limit = 0.0,
total_loss_limit = 0.0,
))]
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn backtest_ohlcv_core<'py>(
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
signals: PyReadonlyArray1<'py, f64>,
fill_mode: &str,
stop_loss_pct: f64,
take_profit_pct: f64,
trailing_stop_pct: f64,
commission: Option<PyRef<'py, PyCommissionModel>>,
slippage_bps: f64,
initial_capital: f64,
commission_per_trade: f64,
limit_prices: Option<PyReadonlyArray1<'py, f64>>,
max_hold_bars: usize,
slippage_pct_range: f64,
breakeven_pct: f64,
periods_per_year: f64,
margin_ratio: f64,
margin_call_pct: f64,
daily_loss_limit: f64,
total_loss_limit: f64,
) -> PyResult<(
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let o = open.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
let c = close.as_slice()?;
let s = signals.as_slice()?;
let n = c.len();
validation::validate_equal_length(&[(n, "close"), (s.len(), "signals")])?;
let mut positions = vec![0.0_f64; n];
if n > 1 {
for i in 1..n {
positions[i] = nan_to_num_with_numpy_defaults(s[i - 1]);
}
validation::validate_equal_length(&[
(n, "close"),
(o.len(), "open"),
(h.len(), "high"),
(l.len(), "low"),
(s.len(), "signals"),
])?;
let config = core_bt::BacktestConfig {
fill_mode: fill_mode.to_string(),
stop_loss_pct,
take_profit_pct,
trailing_stop_pct,
slippage_bps,
initial_capital,
commission_per_trade,
max_hold_bars,
slippage_pct_range,
breakeven_pct,
periods_per_year,
margin_ratio,
margin_call_pct,
daily_loss_limit,
total_loss_limit,
commission: commission.as_ref().map(|c| c.inner.clone()),
};
let lp_opt: Option<&[f64]> = limit_prices.as_ref().and_then(|lp| lp.as_slice().ok());
let result = core_bt::backtest_ohlcv_core(o, h, l, c, s, &config, lp_opt)
.map_err(|e| PyValueError::new_err(e))?;
Ok((
result.positions.into_pyarray(py),
result.fill_prices.into_pyarray(py),
result.bar_returns.into_pyarray(py),
result.strategy_returns.into_pyarray(py),
result.equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Performance metrics
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (strategy_returns, equity, periods_per_year = 252.0, risk_free_rate = 0.0, benchmark_returns = None))]
pub fn compute_performance_metrics<'py>(
py: Python<'py>,
strategy_returns: PyReadonlyArray1<'py, f64>,
equity: PyReadonlyArray1<'py, f64>,
periods_per_year: f64,
risk_free_rate: f64,
benchmark_returns: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<Bound<'py, PyDict>> {
let r = strategy_returns.as_slice()?;
let eq = equity.as_slice()?;
let br = benchmark_returns.as_ref().and_then(|b| b.as_slice().ok());
let metrics = core_bt::compute_performance_metrics(r, eq, periods_per_year, risk_free_rate, br)
.map_err(|e| PyValueError::new_err(e))?;
let dict = PyDict::new(py);
dict.set_item("total_return", metrics.total_return)?;
dict.set_item("cagr", metrics.cagr)?;
dict.set_item("annualized_vol", metrics.annualized_vol)?;
dict.set_item("sharpe", metrics.sharpe)?;
dict.set_item("sortino", metrics.sortino)?;
dict.set_item("calmar", metrics.calmar)?;
dict.set_item("max_drawdown", metrics.max_drawdown)?;
dict.set_item("avg_drawdown", metrics.avg_drawdown)?;
dict.set_item("max_drawdown_duration_bars", metrics.max_drawdown_duration_bars as i64)?;
dict.set_item("avg_drawdown_duration_bars", metrics.avg_drawdown_duration_bars)?;
dict.set_item("ulcer_index", metrics.ulcer_index)?;
dict.set_item("omega_ratio", metrics.omega_ratio)?;
dict.set_item("win_rate", metrics.win_rate)?;
dict.set_item("profit_factor", metrics.profit_factor)?;
dict.set_item("r_expectancy", metrics.r_expectancy)?;
dict.set_item("avg_win", metrics.avg_win)?;
dict.set_item("avg_loss", metrics.avg_loss)?;
dict.set_item("tail_ratio", metrics.tail_ratio)?;
dict.set_item("skewness", metrics.skewness)?;
dict.set_item("kurtosis", metrics.kurtosis)?;
dict.set_item("best_bar", metrics.best_bar)?;
dict.set_item("worst_bar", metrics.worst_bar)?;
dict.set_item("n_trades", metrics.n_trades as i64)?;
dict.set_item("n_position_changes", metrics.n_position_changes as i64)?;
if let Some(v) = metrics.benchmark_total_return {
dict.set_item("benchmark_total_return", v)?;
}
if let Some(v) = metrics.benchmark_cagr {
dict.set_item("benchmark_cagr", v)?;
}
if let Some(v) = metrics.benchmark_annualized_vol {
dict.set_item("benchmark_annualized_vol", v)?;
}
if let Some(v) = metrics.benchmark_sharpe {
dict.set_item("benchmark_sharpe", v)?;
}
if let Some(v) = metrics.alpha {
dict.set_item("alpha", v)?;
}
if let Some(v) = metrics.beta {
dict.set_item("beta", v)?;
}
if let Some(v) = metrics.tracking_error {
dict.set_item("tracking_error", v)?;
}
if let Some(v) = metrics.information_ratio {
dict.set_item("information_ratio", v)?;
}
let mut bar_returns = vec![0.0_f64; n];
for i in 1..n {
bar_returns[i] = (c[i] - c[i - 1]) / c[i - 1];
}
Ok(dict)
}
let mut strategy_returns = vec![0.0_f64; n];
for i in 0..n {
strategy_returns[i] = positions[i] * bar_returns[i];
}
// ---------------------------------------------------------------------------
// Trade extraction
// ---------------------------------------------------------------------------
let mut position_changed = vec![false; n];
for i in 1..n {
position_changed[i] = positions[i] != positions[i - 1];
}
#[pyfunction]
#[allow(clippy::type_complexity)]
pub fn extract_trades_ohlcv<'py>(
py: Python<'py>,
positions: PyReadonlyArray1<'py, f64>,
fill_prices: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
) -> PyResult<(
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<i64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let pos = positions.as_slice()?;
let fp = fill_prices.as_slice()?;
let h = high.as_slice()?;
let l = low.as_slice()?;
if slippage_bps > 0.0 {
let slip = slippage_bps / 10_000.0;
for i in 0..n {
if position_changed[i] {
strategy_returns[i] -= slip;
}
}
}
validation::validate_equal_length(&[
(pos.len(), "positions"),
(fp.len(), "fill_prices"),
(h.len(), "high"),
(l.len(), "low"),
])?;
let mut equity = vec![1.0_f64; n];
if n > 0 {
if commission_per_trade <= 0.0 {
let mut gross = 1.0_f64;
for i in 0..n {
gross *= 1.0 + strategy_returns[i];
equity[i] = gross;
}
} else {
let mut gross_equity = vec![1.0_f64; n];
let mut gross = 1.0_f64;
for i in 0..n {
gross *= 1.0 + strategy_returns[i];
gross_equity[i] = gross;
}
let trades = core_bt::extract_trades_ohlcv(pos, fp, h, l)
.map_err(|e| PyValueError::new_err(e))?;
if gross_equity.contains(&0.0) {
equity[0] = 1.0;
for i in 1..n {
equity[i] = equity[i - 1] * (1.0 + strategy_returns[i]);
if position_changed[i] {
equity[i] -= commission_per_trade;
}
}
} else {
let mut discounted_commissions = 0.0_f64;
for i in 0..n {
if position_changed[i] {
discounted_commissions += commission_per_trade / gross_equity[i];
}
equity[i] = gross_equity[i] * (1.0 - discounted_commissions);
}
}
}
let mut entry_bars: Vec<i64> = Vec::with_capacity(trades.len());
let mut exit_bars: Vec<i64> = Vec::with_capacity(trades.len());
let mut directions: Vec<f64> = Vec::with_capacity(trades.len());
let mut entry_prices: Vec<f64> = Vec::with_capacity(trades.len());
let mut exit_prices: Vec<f64> = Vec::with_capacity(trades.len());
let mut pnl_pcts: Vec<f64> = Vec::with_capacity(trades.len());
let mut duration_bars_vec: Vec<i64> = Vec::with_capacity(trades.len());
let mut maes: Vec<f64> = Vec::with_capacity(trades.len());
let mut mfes: Vec<f64> = Vec::with_capacity(trades.len());
for t in &trades {
entry_bars.push(t.entry_bar);
exit_bars.push(t.exit_bar);
directions.push(t.direction);
entry_prices.push(t.entry_price);
exit_prices.push(t.exit_price);
pnl_pcts.push(t.pnl_pct);
duration_bars_vec.push(t.duration_bars);
maes.push(t.mae);
mfes.push(t.mfe);
}
Ok((
positions.into_pyarray(py),
bar_returns.into_pyarray(py),
strategy_returns.into_pyarray(py),
equity.into_pyarray(py),
entry_bars.into_pyarray(py),
exit_bars.into_pyarray(py),
directions.into_pyarray(py),
entry_prices.into_pyarray(py),
exit_prices.into_pyarray(py),
pnl_pcts.into_pyarray(py),
duration_bars_vec.into_pyarray(py),
maes.into_pyarray(py),
mfes.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Multi-asset backtest
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (
close_2d, weights_2d,
commission_per_trade = 0.0,
slippage_bps = 0.0,
parallel = true,
max_asset_weight = 1.0,
max_gross_exposure = 0.0,
max_net_exposure = 0.0,
))]
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
pub fn backtest_multi_asset_core<'py>(
py: Python<'py>,
close_2d: PyReadonlyArray2<'py, f64>,
weights_2d: PyReadonlyArray2<'py, f64>,
commission_per_trade: f64,
slippage_bps: f64,
parallel: bool,
max_asset_weight: f64,
max_gross_exposure: f64,
max_net_exposure: f64,
) -> PyResult<(
Bound<'py, PyArray2<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
)> {
let c_arr = close_2d.as_array();
let w_arr = weights_2d.as_array();
let (n_bars, n_assets) = c_arr.dim();
if w_arr.dim() != (n_bars, n_assets) {
return Err(PyValueError::new_err(format!(
"weights_2d shape {:?} must match close_2d shape {:?}",
w_arr.dim(),
c_arr.dim()
)));
}
// Transpose to (n_assets, n_bars) for the core function
let mut close_cm: Vec<Vec<f64>> = vec![vec![0.0; n_bars]; n_assets];
let mut weights_cm: Vec<Vec<f64>> = vec![vec![0.0; n_bars]; n_assets];
for j in 0..n_assets {
for i in 0..n_bars {
close_cm[j][i] = c_arr[[i, j]];
weights_cm[j][i] = w_arr[[i, j]];
}
}
// For parallel execution, use rayon directly on the core's single_asset_backtest.
// Apply portfolio constraints first via the core function's logic.
// Apply constraints
if max_asset_weight != 1.0 || max_gross_exposure > 0.0 || max_net_exposure > 0.0 {
for i in 0..n_bars {
if max_asset_weight < f64::INFINITY && max_asset_weight > 0.0 {
for j in 0..n_assets {
let w = weights_cm[j][i];
if w.abs() > max_asset_weight {
weights_cm[j][i] = w.signum() * max_asset_weight;
}
}
}
if max_gross_exposure > 0.0 {
let gross: f64 = (0..n_assets).map(|j| weights_cm[j][i].abs()).sum();
if gross > max_gross_exposure {
let scale = max_gross_exposure / gross;
for j in 0..n_assets {
weights_cm[j][i] *= scale;
}
}
}
if max_net_exposure > 0.0 {
let net: f64 = (0..n_assets).map(|j| weights_cm[j][i]).sum();
if net.abs() > max_net_exposure {
let excess = net - net.signum() * max_net_exposure;
let adj_per_asset = excess / n_assets as f64;
for j in 0..n_assets {
weights_cm[j][i] -= adj_per_asset;
}
}
}
}
}
// Run per-asset backtests (parallel or serial)
let asset_strategy_returns: Vec<Vec<f64>> = py.allow_threads(|| {
let run_asset = |j: usize| -> Vec<f64> {
let (_, strat_rets, _) = core_bt::single_asset_backtest(
&close_cm[j],
&weights_cm[j],
commission_per_trade,
slippage_bps,
);
strat_rets
};
if parallel {
(0..n_assets).into_par_iter().map(run_asset).collect()
} else {
(0..n_assets).map(run_asset).collect()
}
});
// Assemble asset_returns 2D array (n_bars, n_assets)
let mut asset_ret_arr = Array2::<f64>::zeros((n_bars, n_assets));
for j in 0..n_assets {
for i in 0..n_bars {
asset_ret_arr[[i, j]] = asset_strategy_returns[j][i];
}
}
// Portfolio returns
let mut portfolio_returns = vec![0.0_f64; n_bars];
for i in 0..n_bars {
let mut s = 0.0_f64;
for j in 0..n_assets {
s += asset_ret_arr[[i, j]];
}
portfolio_returns[i] = s;
}
// Portfolio equity
let mut portfolio_equity = vec![1.0_f64; n_bars];
let mut cum = 1.0_f64;
for i in 0..n_bars {
cum *= 1.0 + portfolio_returns[i];
portfolio_equity[i] = cum;
}
Ok((
asset_ret_arr.into_pyarray(py),
portfolio_returns.into_pyarray(py),
portfolio_equity.into_pyarray(py),
))
}
// ---------------------------------------------------------------------------
// Monte Carlo bootstrap
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (strategy_returns, n_sims = 1000, seed = 42, block_size = 1))]
pub fn monte_carlo_bootstrap<'py>(
py: Python<'py>,
strategy_returns: PyReadonlyArray1<'py, f64>,
n_sims: usize,
seed: u64,
block_size: usize,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let r = strategy_returns.as_slice()?;
let n = r.len();
// Use rayon for parallel Monte Carlo (preserving the original parallel behavior)
if n < 2 {
return Err(PyValueError::new_err(
"strategy_returns must have at least 2 elements",
));
}
if n_sims == 0 {
return Err(PyValueError::new_err("n_sims must be >= 1"));
}
let bsize = block_size.max(1).min(n);
let mut result = Array2::<f64>::zeros((n_sims, n));
py.allow_threads(|| {
result
.as_slice_mut()
.unwrap()
.par_chunks_mut(n)
.enumerate()
.for_each(|(sim_idx, row)| {
let mut state = seed
.wrapping_mul(6_364_136_223_846_793_005_u64)
.wrapping_add((sim_idx as u64).wrapping_mul(2_862_933_555_777_941_757_u64));
core_bt::lcg_next(&mut state);
core_bt::lcg_next(&mut state);
if bsize == 1 {
for dst in row.iter_mut() {
*dst = r[core_bt::lcg_index(&mut state, n)];
}
} else {
let mut filled = 0_usize;
while filled < n {
let start = core_bt::lcg_index(&mut state, n);
let take = bsize.min(n - filled);
for k in 0..take {
row[filled + k] = r[(start + k) % n];
}
filled += take;
}
}
let mut cum = 1.0_f64;
for elem in row.iter_mut().take(n) {
cum *= 1.0 + *elem;
*elem = cum;
}
});
});
Ok(result.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Walk-forward indices
// ---------------------------------------------------------------------------
#[pyfunction]
#[pyo3(signature = (n_bars, train_bars, test_bars, anchored = false, step_bars = 0))]
pub fn walk_forward_indices<'py>(
py: Python<'py>,
n_bars: usize,
train_bars: usize,
test_bars: usize,
anchored: bool,
step_bars: usize,
) -> PyResult<Bound<'py, PyArray2<i64>>> {
let folds = core_bt::walk_forward_indices(n_bars, train_bars, test_bars, anchored, step_bars)
.map_err(|e| PyValueError::new_err(e))?;
let n_folds = folds.len();
let mut arr = Array2::<i64>::zeros((n_folds, 4));
for (i, fold) in folds.iter().enumerate() {
for j in 0..4 {
arr[[i, j]] = fold[j];
}
}
Ok(arr.into_pyarray(py))
}
// ---------------------------------------------------------------------------
// Kelly criterion
// ---------------------------------------------------------------------------
#[pyfunction]
pub fn kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult<f64> {
core_bt::kelly_fraction(win_rate, avg_win, avg_loss).map_err(|e| PyValueError::new_err(e))
}
#[pyfunction]
pub fn half_kelly_fraction(win_rate: f64, avg_win: f64, avg_loss: f64) -> PyResult<f64> {
core_bt::half_kelly_fraction(win_rate, avg_win, avg_loss).map_err(|e| PyValueError::new_err(e))
}
// ---------------------------------------------------------------------------
// StreamingBacktest
// ---------------------------------------------------------------------------
#[pyclass(name = "StreamingBacktest")]
pub struct StreamingBacktest {
inner: core_bt::StreamingBacktest,
}
#[pymethods]
impl StreamingBacktest {
#[new]
#[pyo3(signature = (commission_per_trade=0.0, slippage_bps=0.0))]
pub fn new(commission_per_trade: f64, slippage_bps: f64) -> Self {
StreamingBacktest {
inner: core_bt::StreamingBacktest::new(commission_per_trade, slippage_bps),
}
}
pub fn on_bar<'py>(
&mut self,
py: Python<'py>,
close: f64,
signal: f64,
) -> PyResult<Bound<'py, PyDict>> {
let result = self.inner.on_bar(close, signal);
let d = PyDict::new(py);
d.set_item("position", result.position)?;
d.set_item("bar_return", result.bar_return)?;
d.set_item("equity", result.equity)?;
d.set_item("n_trades", result.n_trades)?;
Ok(d)
}
#[getter]
pub fn equity(&self) -> f64 {
self.inner.equity
}
#[getter]
pub fn position(&self) -> f64 {
self.inner.position
}
#[getter]
pub fn n_trades(&self) -> usize {
self.inner.n_trades
}
pub fn summary<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
let s = self.inner.summary();
let d = PyDict::new(py);
d.set_item("equity", s.equity)?;
d.set_item("n_trades", s.n_trades)?;
d.set_item("total_commission", s.total_commission)?;
d.set_item("win_rate", s.win_rate)?;
d.set_item("avg_win", s.avg_win)?;
d.set_item("avg_loss", s.avg_loss)?;
d.set_item("kelly_fraction", s.kelly_fraction)?;
Ok(d)
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(rsi_threshold_signals, m)?)?;
m.add_function(wrap_pyfunction!(sma_crossover_signals, m)?)?;
m.add_function(wrap_pyfunction!(macd_crossover_signals, m)?)?;
m.add_function(wrap_pyfunction!(backtest_core, m)?)?;
m.add_function(wrap_pyfunction!(backtest_ohlcv_core, m)?)?;
m.add_function(wrap_pyfunction!(compute_performance_metrics, m)?)?;
m.add_function(wrap_pyfunction!(extract_trades_ohlcv, m)?)?;
m.add_function(wrap_pyfunction!(backtest_multi_asset_core, m)?)?;
m.add_function(wrap_pyfunction!(monte_carlo_bootstrap, m)?)?;
m.add_function(wrap_pyfunction!(walk_forward_indices, m)?)?;
m.add_function(wrap_pyfunction!(kelly_fraction, m)?)?;
m.add_function(wrap_pyfunction!(half_kelly_fraction, m)?)?;
m.add_class::<BacktestConfig>()?;
m.add_class::<StreamingBacktest>()?;
m.add_class::<PyCommissionModel>()?;
m.add_class::<PyCurrency>()?;
Ok(())
}