Skip to content

Commit 224c139

Browse files
committed
Add v0.8 analytics: indicators, stats, CV, extended metrics
- Technical indicators (RSI, MACD, Bollinger Bands, ATR) replacing ta-lib - Statistics module (Spearman correlation, quintile spread) replacing scipy - Time-series cross-validation replacing sklearn - Extended portfolio metrics (CVaR, win rate, profit factor, payoff ratio, Kelly) - Rolling Sharpe and rolling volatility - Python bindings for all new functions - Property tests and reference tests against numpy/scipy/sklearn
1 parent 7116066 commit 224c139

File tree

17 files changed

+2225
-7
lines changed

17 files changed

+2225
-7
lines changed

python/src/cv.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
use nanobook::cv;
2+
use pyo3::prelude::*;
3+
4+
/// Expanding-window time series cross-validation splits.
5+
///
6+
/// Drop-in replacement for ``sklearn.model_selection.TimeSeriesSplit``.
7+
///
8+
/// Args:
9+
/// n_samples: Total number of observations.
10+
/// n_splits: Number of folds.
11+
///
12+
/// Returns:
13+
/// List of (train_indices, test_indices) tuples.
14+
///
15+
/// Example::
16+
///
17+
/// for train_idx, test_idx in nanobook.py_time_series_split(100, 5):
18+
/// train_data = data[train_idx]
19+
/// test_data = data[test_idx]
20+
///
21+
#[pyfunction]
22+
#[pyo3(signature = (n_samples, n_splits=5))]
23+
pub fn py_time_series_split(
24+
n_samples: usize,
25+
n_splits: usize,
26+
) -> Vec<(Vec<usize>, Vec<usize>)> {
27+
cv::time_series_split(n_samples, n_splits)
28+
}

python/src/indicators.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
use nanobook::indicators;
2+
use pyo3::prelude::*;
3+
4+
/// Compute RSI (Relative Strength Index) using Wilder's smoothing.
5+
///
6+
/// Drop-in replacement for ``talib.RSI(close, timeperiod)``.
7+
///
8+
/// Args:
9+
/// close: List of closing prices.
10+
/// period: Lookback period (default 14).
11+
///
12+
/// Returns:
13+
/// List of RSI values. NaN for the lookback period.
14+
///
15+
/// Example::
16+
///
17+
/// rsi = nanobook.py_rsi([44.0, 44.25, 44.5, ...], 14)
18+
///
19+
#[pyfunction]
20+
#[pyo3(signature = (close, period=14))]
21+
pub fn py_rsi(close: Vec<f64>, period: usize) -> Vec<f64> {
22+
indicators::rsi(&close, period)
23+
}
24+
25+
/// Compute MACD (Moving Average Convergence Divergence).
26+
///
27+
/// Drop-in replacement for ``talib.MACD(close, fast, slow, signal)``.
28+
///
29+
/// Args:
30+
/// close: List of closing prices.
31+
/// fast_period: Fast EMA period (default 12).
32+
/// slow_period: Slow EMA period (default 26).
33+
/// signal_period: Signal line EMA period (default 9).
34+
///
35+
/// Returns:
36+
/// Tuple of (macd_line, signal_line, histogram).
37+
///
38+
/// Example::
39+
///
40+
/// macd, signal, hist = nanobook.py_macd(closes, 12, 26, 9)
41+
///
42+
#[pyfunction]
43+
#[pyo3(signature = (close, fast_period=12, slow_period=26, signal_period=9))]
44+
pub fn py_macd(
45+
close: Vec<f64>,
46+
fast_period: usize,
47+
slow_period: usize,
48+
signal_period: usize,
49+
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
50+
indicators::macd(&close, fast_period, slow_period, signal_period)
51+
}
52+
53+
/// Compute Bollinger Bands (SMA +/- k * standard deviation).
54+
///
55+
/// Drop-in replacement for ``talib.BBANDS(close, period, nbdevup, nbdevdn)``.
56+
///
57+
/// Args:
58+
/// close: List of closing prices.
59+
/// period: SMA/stddev period (default 20).
60+
/// num_std_up: Standard deviations above SMA (default 2.0).
61+
/// num_std_dn: Standard deviations below SMA (default 2.0).
62+
///
63+
/// Returns:
64+
/// Tuple of (upper_band, middle_band, lower_band).
65+
///
66+
/// Example::
67+
///
68+
/// upper, middle, lower = nanobook.py_bbands(closes, 20, 2.0, 2.0)
69+
///
70+
#[pyfunction]
71+
#[pyo3(signature = (close, period=20, num_std_up=2.0, num_std_dn=2.0))]
72+
pub fn py_bbands(
73+
close: Vec<f64>,
74+
period: usize,
75+
num_std_up: f64,
76+
num_std_dn: f64,
77+
) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
78+
indicators::bbands(&close, period, num_std_up, num_std_dn)
79+
}
80+
81+
/// Compute ATR (Average True Range) using Wilder's smoothing.
82+
///
83+
/// Drop-in replacement for ``talib.ATR(high, low, close, timeperiod)``.
84+
///
85+
/// Args:
86+
/// high: List of high prices.
87+
/// low: List of low prices.
88+
/// close: List of closing prices.
89+
/// period: Lookback period (default 14).
90+
///
91+
/// Returns:
92+
/// List of ATR values. NaN for the lookback period.
93+
///
94+
/// Example::
95+
///
96+
/// atr = nanobook.py_atr(highs, lows, closes, 14)
97+
///
98+
#[pyfunction]
99+
#[pyo3(signature = (high, low, close, period=14))]
100+
pub fn py_atr(high: Vec<f64>, low: Vec<f64>, close: Vec<f64>, period: usize) -> Vec<f64> {
101+
indicators::atr(&high, &low, &close, period)
102+
}

python/src/lib.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
mod backtest_bridge;
22
mod broker;
3+
mod cv;
34
mod event;
45
mod exchange;
6+
mod indicators;
57
#[cfg(feature = "itch")]
68
mod itch;
79
mod metrics;
@@ -11,6 +13,7 @@ mod portfolio;
1113
mod position;
1214
mod results;
1315
mod risk;
16+
mod stats;
1417
mod strategy;
1518
mod sweep;
1619
mod types;
@@ -21,7 +24,7 @@ use pyo3::prelude::*;
2124
/// and matching engine for testing trading algorithms.
2225
#[pymodule]
2326
fn nanobook(m: &Bound<'_, PyModule>) -> PyResult<()> {
24-
m.add("__version__", "0.7.0")?;
27+
m.add("__version__", "0.8.0")?;
2528

2629
// Broker types
2730
m.add_class::<broker::PyIbkrBroker>()?;
@@ -52,13 +55,30 @@ fn nanobook(m: &Bound<'_, PyModule>) -> PyResult<()> {
5255
m.add_class::<position::PyPosition>()?;
5356
m.add_class::<metrics::PyMetrics>()?;
5457

55-
// Functions
58+
// v0.7 functions
5659
m.add_function(wrap_pyfunction!(metrics::py_compute_metrics, m)?)?;
5760
m.add_function(wrap_pyfunction!(sweep::py_sweep_equal_weight, m)?)?;
5861
m.add_function(wrap_pyfunction!(strategy::py_run_backtest, m)?)?;
5962
m.add_function(wrap_pyfunction!(backtest_bridge::py_backtest_weights, m)?)?;
6063
#[cfg(feature = "itch")]
6164
m.add_function(wrap_pyfunction!(itch::parse_itch, m)?)?;
6265

66+
// v0.8 — Technical indicators (ta-lib replacements)
67+
m.add_function(wrap_pyfunction!(indicators::py_rsi, m)?)?;
68+
m.add_function(wrap_pyfunction!(indicators::py_macd, m)?)?;
69+
m.add_function(wrap_pyfunction!(indicators::py_bbands, m)?)?;
70+
m.add_function(wrap_pyfunction!(indicators::py_atr, m)?)?;
71+
72+
// v0.8 — Statistics (scipy replacements)
73+
m.add_function(wrap_pyfunction!(stats::py_spearman, m)?)?;
74+
m.add_function(wrap_pyfunction!(stats::py_quintile_spread, m)?)?;
75+
76+
// v0.8 — Cross-validation (sklearn replacement)
77+
m.add_function(wrap_pyfunction!(cv::py_time_series_split, m)?)?;
78+
79+
// v0.8 — Rolling metrics (quantstats replacements)
80+
m.add_function(wrap_pyfunction!(metrics::py_rolling_sharpe, m)?)?;
81+
m.add_function(wrap_pyfunction!(metrics::py_rolling_volatility, m)?)?;
82+
6383
Ok(())
6484
}

python/src/metrics.rs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use nanobook::portfolio::{Metrics, compute_metrics};
1+
use nanobook::portfolio::metrics::{compute_metrics, rolling_sharpe, rolling_volatility, Metrics};
22
use pyo3::prelude::*;
33

44
/// Performance metrics for a return series.
@@ -25,16 +25,29 @@ pub struct PyMetrics {
2525
pub winning_periods: usize,
2626
#[pyo3(get)]
2727
pub losing_periods: usize,
28+
29+
// v0.8 extended metrics
30+
#[pyo3(get)]
31+
pub cvar_95: f64,
32+
#[pyo3(get)]
33+
pub win_rate: f64,
34+
#[pyo3(get)]
35+
pub profit_factor: f64,
36+
#[pyo3(get)]
37+
pub payoff_ratio: f64,
38+
#[pyo3(get)]
39+
pub kelly: f64,
2840
}
2941

3042
#[pymethods]
3143
impl PyMetrics {
3244
fn __repr__(&self) -> String {
3345
format!(
34-
"Metrics(total_return={:.2}%, sharpe={:.2}, max_drawdown={:.2}%)",
46+
"Metrics(total_return={:.2}%, sharpe={:.2}, max_drawdown={:.2}%, win_rate={:.1}%)",
3547
self.total_return * 100.0,
3648
self.sharpe,
3749
self.max_drawdown * 100.0,
50+
self.win_rate * 100.0,
3851
)
3952
}
4053
}
@@ -52,6 +65,11 @@ impl From<Metrics> for PyMetrics {
5265
num_periods: m.num_periods,
5366
winning_periods: m.winning_periods,
5467
losing_periods: m.losing_periods,
68+
cvar_95: m.cvar_95,
69+
win_rate: m.win_rate,
70+
profit_factor: m.profit_factor,
71+
payoff_ratio: m.payoff_ratio,
72+
kelly: m.kelly,
5573
}
5674
}
5775
}
@@ -68,8 +86,8 @@ impl From<Metrics> for PyMetrics {
6886
///
6987
/// Example::
7088
///
71-
/// m = compute_metrics([0.01, -0.005, 0.02], 252.0, 0.0)
72-
/// print(m.sharpe)
89+
/// m = nanobook.py_compute_metrics([0.01, -0.005, 0.02], 252.0, 0.0)
90+
/// print(m.sharpe, m.cvar_95, m.kelly)
7391
///
7492
#[pyfunction]
7593
#[pyo3(signature = (returns, periods_per_year=252.0, risk_free=0.0))]
@@ -80,3 +98,47 @@ pub fn py_compute_metrics(
8098
) -> Option<PyMetrics> {
8199
compute_metrics(&returns, periods_per_year, risk_free).map(PyMetrics::from)
82100
}
101+
102+
/// Compute rolling Sharpe ratio over a sliding window.
103+
///
104+
/// Args:
105+
/// returns: List of periodic returns.
106+
/// window: Window size (e.g., 63 for quarterly).
107+
/// periods_per_year: Annualization factor (default 252).
108+
///
109+
/// Returns:
110+
/// List of rolling Sharpe values. NaN for incomplete windows.
111+
///
112+
/// Example::
113+
///
114+
/// rolling = nanobook.py_rolling_sharpe(daily_returns, 63, 252)
115+
///
116+
#[pyfunction]
117+
#[pyo3(signature = (returns, window, periods_per_year=252))]
118+
pub fn py_rolling_sharpe(returns: Vec<f64>, window: usize, periods_per_year: usize) -> Vec<f64> {
119+
rolling_sharpe(&returns, window, periods_per_year)
120+
}
121+
122+
/// Compute rolling annualized volatility over a sliding window.
123+
///
124+
/// Args:
125+
/// returns: List of periodic returns.
126+
/// window: Window size (e.g., 63 for quarterly).
127+
/// periods_per_year: Annualization factor (default 252).
128+
///
129+
/// Returns:
130+
/// List of rolling volatility values. NaN for incomplete windows.
131+
///
132+
/// Example::
133+
///
134+
/// rolling = nanobook.py_rolling_volatility(daily_returns, 63, 252)
135+
///
136+
#[pyfunction]
137+
#[pyo3(signature = (returns, window, periods_per_year=252))]
138+
pub fn py_rolling_volatility(
139+
returns: Vec<f64>,
140+
window: usize,
141+
periods_per_year: usize,
142+
) -> Vec<f64> {
143+
rolling_volatility(&returns, window, periods_per_year)
144+
}

python/src/stats.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
use nanobook::stats;
2+
use pyo3::prelude::*;
3+
4+
/// Compute Spearman rank correlation with two-tailed p-value.
5+
///
6+
/// Drop-in replacement for ``scipy.stats.spearmanr(x, y)``.
7+
/// Uses average-rank tie-breaking, matching scipy's default.
8+
///
9+
/// Args:
10+
/// x: First variable (list of floats).
11+
/// y: Second variable (list of floats, same length as x).
12+
///
13+
/// Returns:
14+
/// Tuple of (correlation, p_value). Returns (NaN, NaN) if len < 3.
15+
///
16+
/// Example::
17+
///
18+
/// corr, p = nanobook.py_spearman(scores, returns)
19+
///
20+
#[pyfunction]
21+
pub fn py_spearman(x: Vec<f64>, y: Vec<f64>) -> (f64, f64) {
22+
stats::spearman(&x, &y)
23+
}
24+
25+
/// Compute quintile spread (top quintile mean - bottom quintile mean).
26+
///
27+
/// Sorts by ``scores``, splits into ``n_quantiles`` groups, returns the
28+
/// difference between the top group's mean return and the bottom group's.
29+
///
30+
/// Args:
31+
/// scores: Factor scores (list of floats).
32+
/// returns: Realized returns (list of floats, same length as scores).
33+
/// n_quantiles: Number of groups (default 5).
34+
///
35+
/// Returns:
36+
/// Float: top_mean - bottom_mean. NaN if inputs are invalid.
37+
///
38+
/// Example::
39+
///
40+
/// spread = nanobook.py_quintile_spread(scores, returns, 5)
41+
///
42+
#[pyfunction]
43+
#[pyo3(signature = (scores, returns, n_quantiles=5))]
44+
pub fn py_quintile_spread(scores: Vec<f64>, returns: Vec<f64>, n_quantiles: usize) -> f64 {
45+
stats::quintile_spread(&scores, &returns, n_quantiles)
46+
}

0 commit comments

Comments
 (0)