feat: expand rust parity, wasm exports, and api conformance
Move several hot Python analysis paths to Rust-backed helpers. This adds Rust implementations for backtest strategy signal generation and the core portfolio loop, options and futures payoff aggregation, Greeks aggregation, ratio calculation, trade extraction, chunked close-only indicator runs, and forward-fill helpers. Wire the Python analysis and data modules to prefer these paths, and add coverage for the new batch fast path. Expand the WASM package to export WMA, ADX, and MFI from ferro_ta_core, refresh the Node examples, benchmarks, and README, and add a Node-vs-Python conformance test so the browser and node surface stays aligned with the main Python package. Introduce a generated cross-surface API manifest in docs/, along with scripts to rebuild and verify it from source exports. Enforce manifest freshness in the Python and WASM CI workflows so release candidates catch surface drift before push.
This commit is contained in:
@@ -191,6 +191,54 @@ pub fn signal_attribution<'py>(
|
||||
Ok((labels.into_pyarray(py), contributions.into_pyarray(py)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extract_trades
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extract trade-level pnl and hold durations from positions and strategy returns.
|
||||
///
|
||||
/// A trade is a maximal contiguous run of non-zero position values.
|
||||
#[pyfunction]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn extract_trades<'py>(
|
||||
py: Python<'py>,
|
||||
positions: PyReadonlyArray1<'py, f64>,
|
||||
strategy_returns: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<(Bound<'py, PyArray1<f64>>, Bound<'py, PyArray1<f64>>)> {
|
||||
let pos = positions.as_slice()?;
|
||||
let ret = strategy_returns.as_slice()?;
|
||||
let n = pos.len();
|
||||
if n != ret.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"positions and strategy_returns must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut pnl = Vec::<f64>::new();
|
||||
let mut hold = Vec::<f64>::new();
|
||||
|
||||
let mut i = 0usize;
|
||||
while i < n {
|
||||
if pos[i] == 0.0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < n && pos[j] == pos[i] {
|
||||
j += 1;
|
||||
}
|
||||
let mut trade_pnl = 0.0_f64;
|
||||
for v in ret.iter().take(j).skip(i) {
|
||||
trade_pnl += *v;
|
||||
}
|
||||
pnl.push(trade_pnl);
|
||||
hold.push((j - i) as f64);
|
||||
i = j;
|
||||
}
|
||||
|
||||
Ok((pnl.into_pyarray(py), hold.into_pyarray(py)))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,5 +247,6 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(trade_stats, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(monthly_contribution, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(signal_attribution, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(extract_trades, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Rust-backed strategy signal generation and backtest core.
|
||||
//!
|
||||
//! These functions move the hot loops from Python into Rust while preserving
|
||||
//! the public Python behavior.
|
||||
|
||||
use crate::validation;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::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
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Strategy signal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
timeperiod: usize,
|
||||
oversold: f64,
|
||||
overbought: f64,
|
||||
) -> 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();
|
||||
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>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
fast: usize,
|
||||
slow: usize,
|
||||
) -> 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();
|
||||
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>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
fastperiod: usize,
|
||||
slowperiod: usize,
|
||||
signalperiod: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
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();
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backtest core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 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))]
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn backtest_core<'py>(
|
||||
py: Python<'py>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
signals: PyReadonlyArray1<'py, f64>,
|
||||
commission_per_trade: f64,
|
||||
slippage_bps: 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()?;
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
let mut strategy_returns = vec![0.0_f64; n];
|
||||
for i in 0..n {
|
||||
strategy_returns[i] = positions[i] * bar_returns[i];
|
||||
}
|
||||
|
||||
let mut position_changed = vec![false; n];
|
||||
for i in 1..n {
|
||||
position_changed[i] = positions[i] != positions[i - 1];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((
|
||||
positions.into_pyarray(py),
|
||||
bar_returns.into_pyarray(py),
|
||||
strategy_returns.into_pyarray(py),
|
||||
equity.into_pyarray(py),
|
||||
))
|
||||
}
|
||||
|
||||
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)?)?;
|
||||
Ok(())
|
||||
}
|
||||
+130
-5
@@ -8,11 +8,15 @@
|
||||
//!
|
||||
//! Functions
|
||||
//! ---------
|
||||
//! - `trim_overlap` — remove the first *overlap* elements from an array
|
||||
//! (to strip the warm-up from a chunk's indicator output).
|
||||
//! - `stitch_chunks` — concatenate trimmed chunk results into one array.
|
||||
//! - `make_chunk_ranges` — compute start/end indices for a series given chunk
|
||||
//! size and overlap, for use by the Python caller.
|
||||
//! - `trim_overlap` — remove the first *overlap* elements from
|
||||
//! an array (to strip the warm-up from a chunk's indicator output).
|
||||
//! - `stitch_chunks` — concatenate trimmed chunk results into one
|
||||
//! array.
|
||||
//! - `make_chunk_ranges` — compute start/end indices for a series
|
||||
//! given chunk size and overlap, for use by the Python caller.
|
||||
//! - `chunk_apply_close_indicator`— run chunked close-only indicators fully in
|
||||
//! Rust (SMA/EMA/RSI).
|
||||
//! - `forward_fill_nan` — forward-fill NaN values in a 1-D array.
|
||||
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
@@ -132,6 +136,125 @@ pub fn make_chunk_ranges<'py>(
|
||||
Ok(ranges.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chunk_apply_close_indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn compute_close_indicator(
|
||||
indicator: &str,
|
||||
series: &[f64],
|
||||
timeperiod: usize,
|
||||
) -> PyResult<Vec<f64>> {
|
||||
match indicator {
|
||||
"SMA" => Ok(ferro_ta_core::overlap::sma(series, timeperiod)),
|
||||
"EMA" => Ok(ferro_ta_core::overlap::ema(series, timeperiod)),
|
||||
"RSI" => Ok(ferro_ta_core::momentum::rsi(series, timeperiod)),
|
||||
_ => Err(PyValueError::new_err(format!(
|
||||
"chunk_apply_close_indicator does not support indicator '{indicator}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run chunked execution for close-only indicators in Rust.
|
||||
///
|
||||
/// Parameters
|
||||
/// ----------
|
||||
/// series : 1-D float64 array
|
||||
/// indicator : one of {"SMA", "EMA", "RSI"}
|
||||
/// timeperiod : indicator period (>= 1)
|
||||
/// chunk_size : output bars per chunk (>= 1)
|
||||
/// overlap : warm-up bars prepended to each chunk
|
||||
///
|
||||
/// Returns
|
||||
/// -------
|
||||
/// 1-D float64 array with the same length as `series`.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (series, indicator, timeperiod, chunk_size = 10_000, overlap = 100))]
|
||||
pub fn chunk_apply_close_indicator<'py>(
|
||||
py: Python<'py>,
|
||||
series: PyReadonlyArray1<'py, f64>,
|
||||
indicator: &str,
|
||||
timeperiod: usize,
|
||||
chunk_size: usize,
|
||||
overlap: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if timeperiod == 0 {
|
||||
return Err(PyValueError::new_err("timeperiod must be >= 1"));
|
||||
}
|
||||
if chunk_size == 0 {
|
||||
return Err(PyValueError::new_err("chunk_size must be >= 1"));
|
||||
}
|
||||
|
||||
let values = series.as_slice()?;
|
||||
if values.is_empty() {
|
||||
return Ok(Vec::<f64>::new().into_pyarray(py));
|
||||
}
|
||||
|
||||
let name = indicator.to_ascii_uppercase();
|
||||
let n = values.len();
|
||||
let mut stitched: Vec<f64> = Vec::with_capacity(n);
|
||||
let mut start = 0usize;
|
||||
let mut chunk_index = 0usize;
|
||||
|
||||
loop {
|
||||
let end = (start + chunk_size + overlap).min(n);
|
||||
let chunk = &values[start..end];
|
||||
let out = compute_close_indicator(name.as_str(), chunk, timeperiod)?;
|
||||
|
||||
let discard = if chunk_index == 0 { 0 } else { overlap };
|
||||
if discard > out.len() {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"overlap ({discard}) must be <= chunk output length ({})",
|
||||
out.len()
|
||||
)));
|
||||
}
|
||||
stitched.extend_from_slice(&out[discard..]);
|
||||
|
||||
if end >= n {
|
||||
break;
|
||||
}
|
||||
start = end.saturating_sub(overlap);
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
if stitched.len() != n {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"internal chunk stitching error: expected output length {n}, got {}",
|
||||
stitched.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(stitched.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// forward_fill_nan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Forward-fill NaN values in a 1-D array.
|
||||
///
|
||||
/// Leading NaN values are preserved until the first non-NaN value appears.
|
||||
#[pyfunction]
|
||||
pub fn forward_fill_nan<'py>(
|
||||
py: Python<'py>,
|
||||
values: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let input = values.as_slice()?;
|
||||
let mut out = Vec::with_capacity(input.len());
|
||||
let mut last = f64::NAN;
|
||||
|
||||
for &value in input {
|
||||
if value.is_nan() {
|
||||
out.push(last);
|
||||
} else {
|
||||
last = value;
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -140,5 +263,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(trim_overlap, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(stitch_chunks, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(make_chunk_ranges, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(chunk_apply_close_indicator, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(forward_fill_nan, m)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod aggregation;
|
||||
pub mod alerts;
|
||||
pub mod attribution;
|
||||
pub mod backtest;
|
||||
pub mod batch;
|
||||
pub mod chunked;
|
||||
pub mod crypto;
|
||||
@@ -70,5 +71,6 @@ fn _ferro_ta(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
chunked::register(m)?;
|
||||
regime::register(m)?;
|
||||
attribution::register(m)?;
|
||||
backtest::register(m)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod chain;
|
||||
mod greeks;
|
||||
mod iv;
|
||||
mod payoff;
|
||||
mod pricing;
|
||||
mod surface;
|
||||
|
||||
@@ -63,5 +64,21 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(self::chain::select_strike_delta, m)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::strategy_payoff_dense,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::strategy_payoff_legs,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::aggregate_greeks_dense,
|
||||
m
|
||||
)?)?;
|
||||
m.add_function(pyo3::wrap_pyfunction!(
|
||||
self::payoff::aggregate_greeks_legs,
|
||||
m
|
||||
)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::types::{PyAny, PyTuple};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Instrument {
|
||||
Option,
|
||||
Future,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Side {
|
||||
Long,
|
||||
Short,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum OptionType {
|
||||
Call,
|
||||
Put,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
fn sign(self) -> f64 {
|
||||
match self {
|
||||
Side::Long => 1.0,
|
||||
Side::Short => -1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instrument(v: i64) -> PyResult<Instrument> {
|
||||
match v {
|
||||
0 => Ok(Instrument::Option),
|
||||
1 => Ok(Instrument::Future),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"instrument must be 0 (option) or 1 (future)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_side(v: i64) -> PyResult<Side> {
|
||||
match v {
|
||||
1 => Ok(Side::Long),
|
||||
-1 => Ok(Side::Short),
|
||||
_ => Err(PyValueError::new_err("side must be 1 (long) or -1 (short)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_option_type(v: i64) -> PyResult<OptionType> {
|
||||
match v {
|
||||
1 => Ok(OptionType::Call),
|
||||
-1 => Ok(OptionType::Put),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"option_type must be 1 (call) or -1 (put)",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_instrument_label(v: &str) -> PyResult<Instrument> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"option" => Ok(Instrument::Option),
|
||||
"future" => Ok(Instrument::Future),
|
||||
_ => Err(PyValueError::new_err(
|
||||
"instrument must be 'option' or 'future'",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_side_label(v: &str) -> PyResult<Side> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"long" => Ok(Side::Long),
|
||||
"short" => Ok(Side::Short),
|
||||
_ => Err(PyValueError::new_err("side must be 'long' or 'short'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_option_type_label(v: &str) -> PyResult<OptionType> {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"call" => Ok(OptionType::Call),
|
||||
"put" => Ok(OptionType::Put),
|
||||
_ => Err(PyValueError::new_err("option_type must be 'call' or 'put'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn leg_attr_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<String> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
value.extract::<String>().map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected string"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<f64> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
value.extract::<f64>().map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected float"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_optional_string(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<String>> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
if value.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
value.extract::<String>().map(Some).map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected string or None"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn leg_attr_optional_f64(leg: &Bound<'_, PyAny>, name: &str) -> PyResult<Option<f64>> {
|
||||
let value = leg
|
||||
.getattr(name)
|
||||
.map_err(|_| PyValueError::new_err(format!("leg missing '{name}' attribute")))?;
|
||||
if value.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
value.extract::<f64>().map(Some).map_err(|_| {
|
||||
PyValueError::new_err(format!(
|
||||
"leg field '{name}' has invalid type; expected float or None"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Compute aggregate strategy payoff over a spot grid.
|
||||
///
|
||||
/// Encoded arrays (same length = n_legs):
|
||||
/// - `instruments`: 0=option, 1=future
|
||||
/// - `sides`: 1=long, -1=short
|
||||
/// - `option_types`: 1=call, -1=put (ignored for futures)
|
||||
/// - `strikes`: strike for options, ignored for futures
|
||||
/// - `premiums`: premium for options, ignored for futures
|
||||
/// - `entry_prices`: entry price for futures, ignored for options
|
||||
/// - `quantities`, `multipliers`: applied to both instruments
|
||||
#[pyfunction]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn strategy_payoff_dense<'py>(
|
||||
py: Python<'py>,
|
||||
spot_grid: PyReadonlyArray1<'py, f64>,
|
||||
instruments: PyReadonlyArray1<'py, i64>,
|
||||
sides: PyReadonlyArray1<'py, i64>,
|
||||
option_types: PyReadonlyArray1<'py, i64>,
|
||||
strikes: PyReadonlyArray1<'py, f64>,
|
||||
premiums: PyReadonlyArray1<'py, f64>,
|
||||
entry_prices: PyReadonlyArray1<'py, f64>,
|
||||
quantities: PyReadonlyArray1<'py, f64>,
|
||||
multipliers: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let grid = spot_grid.as_slice()?;
|
||||
let inst = instruments.as_slice()?;
|
||||
let side = sides.as_slice()?;
|
||||
let opt_t = option_types.as_slice()?;
|
||||
let strike = strikes.as_slice()?;
|
||||
let premium = premiums.as_slice()?;
|
||||
let entry = entry_prices.as_slice()?;
|
||||
let qty = quantities.as_slice()?;
|
||||
let mult = multipliers.as_slice()?;
|
||||
|
||||
let n_legs = inst.len();
|
||||
if side.len() != n_legs
|
||||
|| opt_t.len() != n_legs
|
||||
|| strike.len() != n_legs
|
||||
|| premium.len() != n_legs
|
||||
|| entry.len() != n_legs
|
||||
|| qty.len() != n_legs
|
||||
|| mult.len() != n_legs
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"All leg arrays must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut total = vec![0.0_f64; grid.len()];
|
||||
|
||||
for leg_idx in 0..n_legs {
|
||||
let instrument = parse_instrument(inst[leg_idx])?;
|
||||
let side_sign = parse_side(side[leg_idx])?.sign();
|
||||
let leg_scale = side_sign * qty[leg_idx] * mult[leg_idx];
|
||||
|
||||
match instrument {
|
||||
Instrument::Option => {
|
||||
let otype = parse_option_type(opt_t[leg_idx])?;
|
||||
let k = strike[leg_idx];
|
||||
let p = premium[leg_idx];
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
let intrinsic = match otype {
|
||||
OptionType::Call => (s - k).max(0.0),
|
||||
OptionType::Put => (k - s).max(0.0),
|
||||
};
|
||||
total[i] += leg_scale * (intrinsic - p);
|
||||
}
|
||||
}
|
||||
Instrument::Future => {
|
||||
let e = entry[leg_idx];
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// Compute aggregate strategy payoff from Python leg objects.
|
||||
///
|
||||
/// `legs` is expected to be a sequence of `PayoffLeg`-like objects
|
||||
/// with attributes used by `ferro_ta.analysis.derivatives_payoff`.
|
||||
#[pyfunction]
|
||||
pub fn strategy_payoff_legs<'py>(
|
||||
py: Python<'py>,
|
||||
spot_grid: PyReadonlyArray1<'py, f64>,
|
||||
legs: Bound<'py, PyTuple>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let grid = spot_grid.as_slice()?;
|
||||
let mut total = vec![0.0_f64; grid.len()];
|
||||
|
||||
for leg in legs.iter() {
|
||||
let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?;
|
||||
let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign();
|
||||
let quantity = leg_attr_f64(&leg, "quantity")?;
|
||||
let multiplier = leg_attr_f64(&leg, "multiplier")?;
|
||||
let leg_scale = side_sign * quantity * multiplier;
|
||||
|
||||
match instrument {
|
||||
Instrument::Option => {
|
||||
let otype_raw =
|
||||
leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| {
|
||||
PyValueError::new_err("Option payoff legs require option_type.")
|
||||
})?;
|
||||
let otype = parse_option_type_label(&otype_raw)?;
|
||||
let strike = leg_attr_optional_f64(&leg, "strike")?
|
||||
.ok_or_else(|| PyValueError::new_err("Option payoff legs require strike."))?;
|
||||
let premium = leg_attr_f64(&leg, "premium")?;
|
||||
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
let intrinsic = match otype {
|
||||
OptionType::Call => (s - strike).max(0.0),
|
||||
OptionType::Put => (strike - s).max(0.0),
|
||||
};
|
||||
total[i] += leg_scale * (intrinsic - premium);
|
||||
}
|
||||
}
|
||||
Instrument::Future => {
|
||||
let entry_price = leg_attr_optional_f64(&leg, "entry_price")?.ok_or_else(|| {
|
||||
PyValueError::new_err("Futures payoff legs require entry_price.")
|
||||
})?;
|
||||
for (i, &s) in grid.iter().enumerate() {
|
||||
total[i] += leg_scale * (s - entry_price);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total.into_pyarray(py))
|
||||
}
|
||||
|
||||
/// Aggregate Greeks over multiple legs.
|
||||
///
|
||||
/// Encodings match `strategy_payoff_dense`.
|
||||
#[pyfunction]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn aggregate_greeks_dense(
|
||||
spot: f64,
|
||||
instruments: PyReadonlyArray1<'_, i64>,
|
||||
sides: PyReadonlyArray1<'_, i64>,
|
||||
option_types: PyReadonlyArray1<'_, i64>,
|
||||
strikes: PyReadonlyArray1<'_, f64>,
|
||||
volatilities: PyReadonlyArray1<'_, f64>,
|
||||
time_to_expiries: PyReadonlyArray1<'_, f64>,
|
||||
rates: PyReadonlyArray1<'_, f64>,
|
||||
carries: PyReadonlyArray1<'_, f64>,
|
||||
quantities: PyReadonlyArray1<'_, f64>,
|
||||
multipliers: PyReadonlyArray1<'_, f64>,
|
||||
) -> PyResult<(f64, f64, f64, f64, f64)> {
|
||||
let inst = instruments.as_slice()?;
|
||||
let side = sides.as_slice()?;
|
||||
let opt_t = option_types.as_slice()?;
|
||||
let strike = strikes.as_slice()?;
|
||||
let vol = volatilities.as_slice()?;
|
||||
let tte = time_to_expiries.as_slice()?;
|
||||
let rate = rates.as_slice()?;
|
||||
let carry = carries.as_slice()?;
|
||||
let qty = quantities.as_slice()?;
|
||||
let mult = multipliers.as_slice()?;
|
||||
|
||||
let n_legs = inst.len();
|
||||
if side.len() != n_legs
|
||||
|| opt_t.len() != n_legs
|
||||
|| strike.len() != n_legs
|
||||
|| vol.len() != n_legs
|
||||
|| tte.len() != n_legs
|
||||
|| rate.len() != n_legs
|
||||
|| carry.len() != n_legs
|
||||
|| qty.len() != n_legs
|
||||
|| mult.len() != n_legs
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"All leg arrays must have the same length",
|
||||
));
|
||||
}
|
||||
|
||||
let mut delta = 0.0_f64;
|
||||
let mut gamma = 0.0_f64;
|
||||
let mut vega = 0.0_f64;
|
||||
let mut theta = 0.0_f64;
|
||||
let mut rho = 0.0_f64;
|
||||
|
||||
for i in 0..n_legs {
|
||||
let instrument = parse_instrument(inst[i])?;
|
||||
let side_sign = parse_side(side[i])?.sign();
|
||||
let leg_scale = side_sign * qty[i] * mult[i];
|
||||
match instrument {
|
||||
Instrument::Future => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
Instrument::Option => {
|
||||
if vol[i].is_nan() || tte[i].is_nan() {
|
||||
return Err(PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
));
|
||||
}
|
||||
let kind = match parse_option_type(opt_t[i])? {
|
||||
OptionType::Call => ferro_ta_core::options::OptionKind::Call,
|
||||
OptionType::Put => ferro_ta_core::options::OptionKind::Put,
|
||||
};
|
||||
let greeks = ferro_ta_core::options::greeks::model_greeks(
|
||||
ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model: ferro_ta_core::options::PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike: strike[i],
|
||||
rate: rate[i],
|
||||
carry: carry[i],
|
||||
time_to_expiry: tte[i],
|
||||
kind,
|
||||
},
|
||||
volatility: vol[i],
|
||||
},
|
||||
);
|
||||
delta += leg_scale * greeks.delta;
|
||||
gamma += leg_scale * greeks.gamma;
|
||||
vega += leg_scale * greeks.vega;
|
||||
theta += leg_scale * greeks.theta;
|
||||
rho += leg_scale * greeks.rho;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((delta, gamma, vega, theta, rho))
|
||||
}
|
||||
|
||||
/// Aggregate Greeks from Python leg objects.
|
||||
#[pyfunction]
|
||||
pub fn aggregate_greeks_legs(
|
||||
spot: f64,
|
||||
legs: Bound<'_, PyTuple>,
|
||||
) -> PyResult<(f64, f64, f64, f64, f64)> {
|
||||
let mut delta = 0.0_f64;
|
||||
let mut gamma = 0.0_f64;
|
||||
let mut vega = 0.0_f64;
|
||||
let mut theta = 0.0_f64;
|
||||
let mut rho = 0.0_f64;
|
||||
|
||||
for leg in legs.iter() {
|
||||
let instrument = parse_instrument_label(&leg_attr_string(&leg, "instrument")?)?;
|
||||
let side_sign = parse_side_label(&leg_attr_string(&leg, "side")?)?.sign();
|
||||
let quantity = leg_attr_f64(&leg, "quantity")?;
|
||||
let multiplier = leg_attr_f64(&leg, "multiplier")?;
|
||||
let leg_scale = side_sign * quantity * multiplier;
|
||||
|
||||
match instrument {
|
||||
Instrument::Future => {
|
||||
delta += leg_scale;
|
||||
}
|
||||
Instrument::Option => {
|
||||
let otype_raw =
|
||||
leg_attr_optional_string(&leg, "option_type")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require option_type for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let otype = parse_option_type_label(&otype_raw)?;
|
||||
let strike = leg_attr_optional_f64(&leg, "strike")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let volatility = leg_attr_optional_f64(&leg, "volatility")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let time_to_expiry =
|
||||
leg_attr_optional_f64(&leg, "time_to_expiry")?.ok_or_else(|| {
|
||||
PyValueError::new_err(
|
||||
"Option legs require strike, volatility, and time_to_expiry for Greeks aggregation.",
|
||||
)
|
||||
})?;
|
||||
let rate = leg_attr_f64(&leg, "rate")?;
|
||||
let carry = leg_attr_f64(&leg, "carry")?;
|
||||
|
||||
let kind = match otype {
|
||||
OptionType::Call => ferro_ta_core::options::OptionKind::Call,
|
||||
OptionType::Put => ferro_ta_core::options::OptionKind::Put,
|
||||
};
|
||||
let greeks = ferro_ta_core::options::greeks::model_greeks(
|
||||
ferro_ta_core::options::OptionEvaluation {
|
||||
contract: ferro_ta_core::options::OptionContract {
|
||||
model: ferro_ta_core::options::PricingModel::BlackScholes,
|
||||
underlying: spot,
|
||||
strike,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind,
|
||||
},
|
||||
volatility,
|
||||
},
|
||||
);
|
||||
delta += leg_scale * greeks.delta;
|
||||
gamma += leg_scale * greeks.gamma;
|
||||
vega += leg_scale * greeks.vega;
|
||||
theta += leg_scale * greeks.theta;
|
||||
rho += leg_scale * greeks.rho;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((delta, gamma, vega, theta, rho))
|
||||
}
|
||||
@@ -350,6 +350,35 @@ pub fn spread<'py>(
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ratio
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the ratio between two series: A / B.
|
||||
///
|
||||
/// Where B is 0, returns NaN.
|
||||
#[pyfunction]
|
||||
pub fn ratio<'py>(
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let av = a.as_slice()?;
|
||||
let bv = b.as_slice()?;
|
||||
let n = av.len();
|
||||
if n == 0 || bv.len() != n {
|
||||
return Err(PyValueError::new_err(
|
||||
"a and b must be non-empty and equal length",
|
||||
));
|
||||
}
|
||||
let result: Vec<f64> = av
|
||||
.iter()
|
||||
.zip(bv.iter())
|
||||
.map(|(&x, &y)| if y == 0.0 { f64::NAN } else { x / y })
|
||||
.collect();
|
||||
Ok(result.into_pyarray(py))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// zscore_series
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -449,6 +478,7 @@ pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_function(wrap_pyfunction!(correlation_matrix, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(relative_strength, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(spread, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(ratio, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(zscore_series, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(compose_weighted, m)?)?;
|
||||
Ok(())
|
||||
|
||||
Reference in New Issue
Block a user