feat: add full derivatives analytics layer (options + futures)

Implements all phases of the derivatives expansion plan:

Rust core (crates/ferro_ta_core/src/options/, src/futures/):
- BSM and Black-76 pricing (scalar + vectorized batch)
- Greeks: delta, gamma, vega, theta, rho
- Implied volatility solver (Newton + bisection fallback)
- Smile/skew metrics: ATM IV, 25-delta RR/BF, skew slope, convexity
- Chain helpers: moneyness labels, strike selection by offset or delta
- Synthetic forwards, basis, annualized basis, implied carry, carry spread
- Continuous contract stitching: weighted, back-adjusted, ratio-adjusted
- Curve analytics: calendar spreads, slope, contango/backwardation summary

PyO3 bindings (src/options/, src/futures/):
- All Rust functions registered and exposed via _ferro_ta extension

Python API (python/ferro_ta/analysis/):
- options.py: pricing, greeks, IV, smile, chain, legacy iv_rank/percentile/zscore
- futures.py: basis, carry, curve, roll, synthetic, continuous contracts
- options_strategy.py: typed strategy schemas (expiry/strike selectors, leg presets, risk controls, simulation limits)
- derivatives_payoff.py: multi-leg payoff aggregation and Greeks aggregation

Bug fix: wrap _to_f64 calls in iv_rank/iv_percentile/iv_zscore to raise
FerroTAInputError (not plain ValueError) for 2D array input.

Docs: derivatives.rst, derivatives-analytics.md, options-volatility.md,
quickstart.rst, index.rst, api/analysis.rst all updated.

Tests: 2053 pass, 12 skipped. All CI checks pass locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Pratik Bhadane
2026-03-24 02:41:50 +05:30
co-authored by Claude Sonnet 4.6
parent 2d5000262f
commit 602d675749
47 changed files with 4538 additions and 280 deletions
+34
View File
@@ -0,0 +1,34 @@
use pyo3::prelude::*;
#[pyfunction]
pub fn futures_basis(spot: f64, future: f64) -> PyResult<f64> {
Ok(ferro_ta_core::futures::basis::basis(spot, future))
}
#[pyfunction]
pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> PyResult<f64> {
Ok(ferro_ta_core::futures::basis::annualized_basis(
spot,
future,
time_to_expiry,
))
}
#[pyfunction]
pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> PyResult<f64> {
Ok(ferro_ta_core::futures::basis::implied_carry_rate(
spot,
future,
time_to_expiry,
))
}
#[pyfunction]
pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> PyResult<f64> {
Ok(ferro_ta_core::futures::basis::carry_spread(
spot,
future,
rate,
time_to_expiry,
))
}
+52
View File
@@ -0,0 +1,52 @@
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
#[pyfunction]
pub fn calendar_spreads<'py>(
py: Python<'py>,
futures_prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
Ok(
ferro_ta_core::futures::curve::calendar_spreads(futures_prices.as_slice()?)
.into_pyarray(py),
)
}
#[pyfunction]
pub fn curve_slope<'py>(
tenors: PyReadonlyArray1<'py, f64>,
futures_prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<f64> {
let tenors = tenors.as_slice()?;
let futures_prices = futures_prices.as_slice()?;
validation::validate_equal_length(&[
(tenors.len(), "tenors"),
(futures_prices.len(), "futures_prices"),
])?;
Ok(ferro_ta_core::futures::curve::curve_slope(
tenors,
futures_prices,
))
}
#[pyfunction]
pub fn curve_summary<'py>(
spot: f64,
tenors: PyReadonlyArray1<'py, f64>,
futures_prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<(f64, f64, f64, bool)> {
let tenors = tenors.as_slice()?;
let futures_prices = futures_prices.as_slice()?;
validation::validate_equal_length(&[
(tenors.len(), "tenors"),
(futures_prices.len(), "futures_prices"),
])?;
let summary = ferro_ta_core::futures::curve::curve_summary(spot, tenors, futures_prices);
Ok((
summary.front_basis,
summary.average_basis,
summary.slope,
summary.is_contango,
))
}
+38
View File
@@ -0,0 +1,38 @@
//! PyO3 wrappers for futures analytics.
mod basis;
mod curve;
mod roll;
mod synthetic;
use pyo3::prelude::*;
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(pyo3::wrap_pyfunction!(
self::synthetic::synthetic_forward,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::synthetic::synthetic_spot, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::synthetic::parity_gap, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::basis::futures_basis, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::basis::annualized_basis, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::basis::implied_carry_rate, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::basis::carry_spread, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::roll::weighted_continuous_contract,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::roll::back_adjusted_continuous_contract,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::roll::ratio_adjusted_continuous_contract,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::roll::roll_yield, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::curve::calendar_spreads, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::curve::curve_slope, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::curve::curve_summary, m)?)?;
Ok(())
}
+75
View File
@@ -0,0 +1,75 @@
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
#[pyfunction]
pub fn weighted_continuous_contract<'py>(
py: Python<'py>,
front: PyReadonlyArray1<'py, f64>,
next: PyReadonlyArray1<'py, f64>,
next_weights: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let front = front.as_slice()?;
let next = next.as_slice()?;
let next_weights = next_weights.as_slice()?;
validation::validate_equal_length(&[
(front.len(), "front"),
(next.len(), "next"),
(next_weights.len(), "next_weights"),
])?;
Ok(
ferro_ta_core::futures::roll::weighted_continuous(front, next, next_weights)
.into_pyarray(py),
)
}
#[pyfunction]
pub fn back_adjusted_continuous_contract<'py>(
py: Python<'py>,
front: PyReadonlyArray1<'py, f64>,
next: PyReadonlyArray1<'py, f64>,
next_weights: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let front = front.as_slice()?;
let next = next.as_slice()?;
let next_weights = next_weights.as_slice()?;
validation::validate_equal_length(&[
(front.len(), "front"),
(next.len(), "next"),
(next_weights.len(), "next_weights"),
])?;
Ok(
ferro_ta_core::futures::roll::back_adjusted_continuous(front, next, next_weights)
.into_pyarray(py),
)
}
#[pyfunction]
pub fn ratio_adjusted_continuous_contract<'py>(
py: Python<'py>,
front: PyReadonlyArray1<'py, f64>,
next: PyReadonlyArray1<'py, f64>,
next_weights: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let front = front.as_slice()?;
let next = next.as_slice()?;
let next_weights = next_weights.as_slice()?;
validation::validate_equal_length(&[
(front.len(), "front"),
(next.len(), "next"),
(next_weights.len(), "next_weights"),
])?;
Ok(
ferro_ta_core::futures::roll::ratio_adjusted_continuous(front, next, next_weights)
.into_pyarray(py),
)
}
#[pyfunction]
pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> PyResult<f64> {
Ok(ferro_ta_core::futures::roll::roll_yield(
front_price,
next_price,
time_to_expiry,
))
}
+60
View File
@@ -0,0 +1,60 @@
use pyo3::prelude::*;
#[pyfunction]
pub fn synthetic_forward(
call_price: f64,
put_price: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
) -> PyResult<f64> {
Ok(ferro_ta_core::futures::synthetic::synthetic_forward(
call_price,
put_price,
strike,
rate,
time_to_expiry,
))
}
#[pyfunction]
#[pyo3(signature = (call_price, put_price, strike, rate, time_to_expiry, carry = 0.0))]
pub fn synthetic_spot(
call_price: f64,
put_price: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
carry: f64,
) -> PyResult<f64> {
Ok(ferro_ta_core::futures::synthetic::synthetic_spot(
call_price,
put_price,
strike,
rate,
carry,
time_to_expiry,
))
}
#[pyfunction]
#[pyo3(signature = (call_price, put_price, spot, strike, rate, time_to_expiry, carry = 0.0))]
pub fn parity_gap(
call_price: f64,
put_price: f64,
spot: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
carry: f64,
) -> PyResult<f64> {
Ok(ferro_ta_core::futures::synthetic::parity_gap(
call_price,
put_price,
spot,
strike,
rate,
carry,
time_to_expiry,
))
}
+4
View File
@@ -6,8 +6,10 @@ pub mod chunked;
pub mod crypto;
pub mod cycle;
pub mod extended;
pub mod futures;
pub mod math_ops;
pub mod momentum;
pub mod options;
pub mod overlap;
pub mod pattern;
pub mod portfolio;
@@ -57,6 +59,8 @@ fn _ferro_ta(m: &Bound<'_, PyModule>) -> PyResult<()> {
streaming::register(m)?;
extended::register(m)?;
math_ops::register(m)?;
options::register(m)?;
futures::register(m)?;
resampling::register(m)?;
aggregation::register(m)?;
portfolio::register(m)?;
+64
View File
@@ -0,0 +1,64 @@
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
#[pyfunction]
#[pyo3(signature = (strikes, reference_price, option_type = "call"))]
pub fn moneyness_labels<'py>(
py: Python<'py>,
strikes: PyReadonlyArray1<'py, f64>,
reference_price: f64,
option_type: &str,
) -> PyResult<Bound<'py, PyArray1<i8>>> {
let kind = super::parse_option_kind(option_type)?;
let strikes = strikes.as_slice()?;
let labels = ferro_ta_core::options::chain::label_moneyness(strikes, reference_price, kind);
Ok(labels.into_pyarray(py))
}
#[pyfunction]
pub fn select_strike_offset<'py>(
strikes: PyReadonlyArray1<'py, f64>,
reference_price: f64,
offset: isize,
) -> PyResult<Option<f64>> {
Ok(ferro_ta_core::options::chain::select_strike_by_offset(
strikes.as_slice()?,
reference_price,
offset,
))
}
#[pyfunction]
#[pyo3(signature = (strikes, vols, reference_price, time_to_expiry, target_delta, option_type = "call", model = "bsm", rate = 0.0, carry = 0.0))]
#[allow(clippy::too_many_arguments)]
pub fn select_strike_delta<'py>(
strikes: PyReadonlyArray1<'py, f64>,
vols: PyReadonlyArray1<'py, f64>,
reference_price: f64,
time_to_expiry: f64,
target_delta: f64,
option_type: &str,
model: &str,
rate: f64,
carry: f64,
) -> PyResult<Option<f64>> {
let kind = super::parse_option_kind(option_type)?;
let model = super::parse_pricing_model(model)?;
let strikes = strikes.as_slice()?;
let vols = vols.as_slice()?;
validation::validate_equal_length(&[(strikes.len(), "strikes"), (vols.len(), "vols")])?;
Ok(ferro_ta_core::options::chain::select_strike_by_delta(
strikes,
vols,
ferro_ta_core::options::ChainGreeksContext {
model,
reference_price,
rate,
carry,
time_to_expiry,
kind,
},
target_delta,
))
}
+125
View File
@@ -0,0 +1,125 @@
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
type GreekArrays<'py> = (
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
Bound<'py, PyArray1<f64>>,
);
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = 0.0))]
#[allow(clippy::too_many_arguments)]
pub fn option_greeks(
underlying: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
volatility: f64,
option_type: &str,
model: &str,
carry: f64,
) -> PyResult<(f64, f64, f64, f64, f64)> {
let kind = super::parse_option_kind(option_type)?;
let model = super::parse_pricing_model(model)?;
let greeks =
ferro_ta_core::options::greeks::model_greeks(ferro_ta_core::options::OptionEvaluation {
contract: ferro_ta_core::options::OptionContract {
model,
underlying,
strike,
rate,
carry,
time_to_expiry,
kind,
},
volatility,
});
Ok((
greeks.delta,
greeks.gamma,
greeks.vega,
greeks.theta,
greeks.rho,
))
}
#[pyfunction]
#[pyo3(signature = (underlying, strike, rate, time_to_expiry, volatility, option_type = "call", model = "bsm", carry = None))]
#[allow(clippy::too_many_arguments)]
pub fn option_greeks_batch<'py>(
py: Python<'py>,
underlying: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
volatility: PyReadonlyArray1<'py, f64>,
option_type: &str,
model: &str,
carry: Option<PyReadonlyArray1<'py, f64>>,
) -> PyResult<GreekArrays<'py>> {
let kind = super::parse_option_kind(option_type)?;
let model = super::parse_pricing_model(model)?;
let underlying = underlying.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let time_to_expiry = time_to_expiry.as_slice()?;
let volatility = volatility.as_slice()?;
let carry_vec = match carry {
Some(array) => array.as_slice()?.to_vec(),
None => vec![0.0; underlying.len()],
};
validation::validate_equal_length(&[
(underlying.len(), "underlying"),
(strike.len(), "strike"),
(rate.len(), "rate"),
(time_to_expiry.len(), "time_to_expiry"),
(volatility.len(), "volatility"),
(carry_vec.len(), "carry"),
])?;
let mut delta = Vec::with_capacity(underlying.len());
let mut gamma = Vec::with_capacity(underlying.len());
let mut vega = Vec::with_capacity(underlying.len());
let mut theta = Vec::with_capacity(underlying.len());
let mut rho = Vec::with_capacity(underlying.len());
for (((((&u, &k), &r), &t), &vol), &c) in underlying
.iter()
.zip(strike.iter())
.zip(rate.iter())
.zip(time_to_expiry.iter())
.zip(volatility.iter())
.zip(carry_vec.iter())
{
let g = ferro_ta_core::options::greeks::model_greeks(
ferro_ta_core::options::OptionEvaluation {
contract: ferro_ta_core::options::OptionContract {
model,
underlying: u,
strike: k,
rate: r,
carry: c,
time_to_expiry: t,
kind,
},
volatility: vol,
},
);
delta.push(g.delta);
gamma.push(g.gamma);
vega.push(g.vega);
theta.push(g.theta);
rho.push(g.rho);
}
Ok((
delta.into_pyarray(py),
gamma.into_pyarray(py),
vega.into_pyarray(py),
theta.into_pyarray(py),
rho.into_pyarray(py),
))
}
+149
View File
@@ -0,0 +1,149 @@
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
#[pyfunction]
#[pyo3(signature = (price, underlying, strike, rate, time_to_expiry, option_type = "call", model = "bsm", carry = 0.0, initial_guess = 0.2, tolerance = 1e-8, max_iterations = 100))]
#[allow(clippy::too_many_arguments)]
pub fn implied_volatility(
price: f64,
underlying: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
option_type: &str,
model: &str,
carry: f64,
initial_guess: f64,
tolerance: f64,
max_iterations: usize,
) -> PyResult<f64> {
let kind = super::parse_option_kind(option_type)?;
let model = super::parse_pricing_model(model)?;
Ok(ferro_ta_core::options::iv::implied_volatility(
ferro_ta_core::options::OptionContract {
model,
underlying,
strike,
rate,
carry,
time_to_expiry,
kind,
},
price,
ferro_ta_core::options::IvSolverConfig {
initial_guess,
tolerance,
max_iterations,
},
))
}
#[pyfunction]
#[pyo3(signature = (price, underlying, strike, rate, time_to_expiry, option_type = "call", model = "bsm", carry = None, initial_guess = None, tolerance = 1e-8, max_iterations = 100))]
#[allow(clippy::too_many_arguments)]
pub fn implied_volatility_batch<'py>(
py: Python<'py>,
price: PyReadonlyArray1<'py, f64>,
underlying: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
option_type: &str,
model: &str,
carry: Option<PyReadonlyArray1<'py, f64>>,
initial_guess: Option<PyReadonlyArray1<'py, f64>>,
tolerance: f64,
max_iterations: usize,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let kind = super::parse_option_kind(option_type)?;
let model = super::parse_pricing_model(model)?;
let price = price.as_slice()?;
let underlying = underlying.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let time_to_expiry = time_to_expiry.as_slice()?;
let carry_vec = match carry {
Some(array) => array.as_slice()?.to_vec(),
None => vec![0.0; price.len()],
};
let guess_vec = match initial_guess {
Some(array) => array.as_slice()?.to_vec(),
None => vec![0.2; price.len()],
};
validation::validate_equal_length(&[
(price.len(), "price"),
(underlying.len(), "underlying"),
(strike.len(), "strike"),
(rate.len(), "rate"),
(time_to_expiry.len(), "time_to_expiry"),
(carry_vec.len(), "carry"),
(guess_vec.len(), "initial_guess"),
])?;
let out: Vec<f64> = price
.iter()
.zip(underlying.iter())
.zip(strike.iter())
.zip(rate.iter())
.zip(time_to_expiry.iter())
.zip(carry_vec.iter())
.zip(guess_vec.iter())
.map(|((((((&p, &u), &k), &r), &t), &c), &guess)| {
ferro_ta_core::options::iv::implied_volatility(
ferro_ta_core::options::OptionContract {
model,
underlying: u,
strike: k,
rate: r,
carry: c,
time_to_expiry: t,
kind,
},
p,
ferro_ta_core::options::IvSolverConfig {
initial_guess: guess,
tolerance,
max_iterations,
},
)
})
.collect();
Ok(out.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (iv_series, window = 252))]
pub fn iv_rank<'py>(
py: Python<'py>,
iv_series: PyReadonlyArray1<'py, f64>,
window: i64,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let window = validation::parse_timeperiod(window, "window", 1)?;
let out = ferro_ta_core::options::iv::iv_rank(iv_series.as_slice()?, window);
Ok(out.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (iv_series, window = 252))]
pub fn iv_percentile<'py>(
py: Python<'py>,
iv_series: PyReadonlyArray1<'py, f64>,
window: i64,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let window = validation::parse_timeperiod(window, "window", 1)?;
let out = ferro_ta_core::options::iv::iv_percentile(iv_series.as_slice()?, window);
Ok(out.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (iv_series, window = 252))]
pub fn iv_zscore<'py>(
py: Python<'py>,
iv_series: PyReadonlyArray1<'py, f64>,
window: i64,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let window = validation::parse_timeperiod(window, "window", 1)?;
let out = ferro_ta_core::options::iv::iv_zscore(iv_series.as_slice()?, window);
Ok(out.into_pyarray(py))
}
+67
View File
@@ -0,0 +1,67 @@
//! PyO3 wrappers for options analytics.
mod chain;
mod greeks;
mod iv;
mod pricing;
mod surface;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
pub(crate) fn parse_option_kind(option_type: &str) -> PyResult<ferro_ta_core::options::OptionKind> {
match option_type.to_ascii_lowercase().as_str() {
"call" | "c" => Ok(ferro_ta_core::options::OptionKind::Call),
"put" | "p" => Ok(ferro_ta_core::options::OptionKind::Put),
_ => Err(PyValueError::new_err(format!(
"option_type must be 'call' or 'put', got {option_type}"
))),
}
}
pub(crate) fn parse_pricing_model(model: &str) -> PyResult<ferro_ta_core::options::PricingModel> {
match model.to_ascii_lowercase().as_str() {
"bsm" | "black_scholes" | "black-scholes" | "blackscholes" => {
Ok(ferro_ta_core::options::PricingModel::BlackScholes)
}
"black76" | "black_76" | "black-76" => Ok(ferro_ta_core::options::PricingModel::Black76),
_ => Err(PyValueError::new_err(format!(
"model must be one of 'bsm'/'black_scholes' or 'black76', got {model}"
))),
}
}
pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(pyo3::wrap_pyfunction!(self::pricing::bsm_price, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::pricing::black76_price, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::pricing::bsm_price_batch, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::pricing::black76_price_batch,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::greeks::option_greeks, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::greeks::option_greeks_batch,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::iv::implied_volatility, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::iv::implied_volatility_batch,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_rank, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_percentile, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::iv::iv_zscore, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::surface::smile_metrics, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::surface::term_structure_slope,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::chain::moneyness_labels, m)?)?;
m.add_function(pyo3::wrap_pyfunction!(
self::chain::select_strike_offset,
m
)?)?;
m.add_function(pyo3::wrap_pyfunction!(self::chain::select_strike_delta, m)?)?;
Ok(())
}
+128
View File
@@ -0,0 +1,128 @@
use crate::validation;
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::prelude::*;
#[pyfunction]
#[pyo3(signature = (spot, strike, rate, time_to_expiry, volatility, option_type = "call", dividend_yield = 0.0))]
pub fn bsm_price(
spot: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
volatility: f64,
option_type: &str,
dividend_yield: f64,
) -> PyResult<f64> {
let kind = super::parse_option_kind(option_type)?;
Ok(ferro_ta_core::options::pricing::black_scholes_price(
spot,
strike,
rate,
dividend_yield,
time_to_expiry,
volatility,
kind,
))
}
#[pyfunction]
#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))]
pub fn black76_price(
forward: f64,
strike: f64,
rate: f64,
time_to_expiry: f64,
volatility: f64,
option_type: &str,
) -> PyResult<f64> {
let kind = super::parse_option_kind(option_type)?;
Ok(ferro_ta_core::options::pricing::black_76_price(
forward,
strike,
rate,
time_to_expiry,
volatility,
kind,
))
}
#[pyfunction]
#[pyo3(signature = (spot, strike, rate, time_to_expiry, volatility, dividend_yield, option_type = "call"))]
#[allow(clippy::too_many_arguments)]
pub fn bsm_price_batch<'py>(
py: Python<'py>,
spot: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
volatility: PyReadonlyArray1<'py, f64>,
dividend_yield: PyReadonlyArray1<'py, f64>,
option_type: &str,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let kind = super::parse_option_kind(option_type)?;
let spot = spot.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let time_to_expiry = time_to_expiry.as_slice()?;
let volatility = volatility.as_slice()?;
let dividend_yield = dividend_yield.as_slice()?;
validation::validate_equal_length(&[
(spot.len(), "spot"),
(strike.len(), "strike"),
(rate.len(), "rate"),
(time_to_expiry.len(), "time_to_expiry"),
(volatility.len(), "volatility"),
(dividend_yield.len(), "dividend_yield"),
])?;
let out: Vec<f64> = spot
.iter()
.zip(strike.iter())
.zip(rate.iter())
.zip(time_to_expiry.iter())
.zip(volatility.iter())
.zip(dividend_yield.iter())
.map(|(((((&s, &k), &r), &t), &vol), &q)| {
ferro_ta_core::options::pricing::black_scholes_price(s, k, r, q, t, vol, kind)
})
.collect();
Ok(out.into_pyarray(py))
}
#[pyfunction]
#[pyo3(signature = (forward, strike, rate, time_to_expiry, volatility, option_type = "call"))]
pub fn black76_price_batch<'py>(
py: Python<'py>,
forward: PyReadonlyArray1<'py, f64>,
strike: PyReadonlyArray1<'py, f64>,
rate: PyReadonlyArray1<'py, f64>,
time_to_expiry: PyReadonlyArray1<'py, f64>,
volatility: PyReadonlyArray1<'py, f64>,
option_type: &str,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let kind = super::parse_option_kind(option_type)?;
let forward = forward.as_slice()?;
let strike = strike.as_slice()?;
let rate = rate.as_slice()?;
let time_to_expiry = time_to_expiry.as_slice()?;
let volatility = volatility.as_slice()?;
validation::validate_equal_length(&[
(forward.len(), "forward"),
(strike.len(), "strike"),
(rate.len(), "rate"),
(time_to_expiry.len(), "time_to_expiry"),
(volatility.len(), "volatility"),
])?;
let out: Vec<f64> = forward
.iter()
.zip(strike.iter())
.zip(rate.iter())
.zip(time_to_expiry.iter())
.zip(volatility.iter())
.map(|((((&f, &k), &r), &t), &vol)| {
ferro_ta_core::options::pricing::black_76_price(f, k, r, t, vol, kind)
})
.collect();
Ok(out.into_pyarray(py))
}
+49
View File
@@ -0,0 +1,49 @@
use crate::validation;
use numpy::PyReadonlyArray1;
use pyo3::prelude::*;
#[pyfunction]
#[pyo3(signature = (strikes, vols, reference_price, time_to_expiry, model = "bsm", rate = 0.0, carry = 0.0))]
pub fn smile_metrics<'py>(
strikes: PyReadonlyArray1<'py, f64>,
vols: PyReadonlyArray1<'py, f64>,
reference_price: f64,
time_to_expiry: f64,
model: &str,
rate: f64,
carry: f64,
) -> PyResult<(f64, f64, f64, f64, f64)> {
let strikes = strikes.as_slice()?;
let vols = vols.as_slice()?;
validation::validate_equal_length(&[(strikes.len(), "strikes"), (vols.len(), "vols")])?;
let model = super::parse_pricing_model(model)?;
let metrics = ferro_ta_core::options::surface::smile_metrics(
strikes,
vols,
reference_price,
rate,
carry,
time_to_expiry,
model,
);
Ok((
metrics.atm_iv,
metrics.risk_reversal_25d,
metrics.butterfly_25d,
metrics.skew_slope,
metrics.convexity,
))
}
#[pyfunction]
pub fn term_structure_slope<'py>(
tenors: PyReadonlyArray1<'py, f64>,
atm_ivs: PyReadonlyArray1<'py, f64>,
) -> PyResult<f64> {
let tenors = tenors.as_slice()?;
let atm_ivs = atm_ivs.as_slice()?;
validation::validate_equal_length(&[(tenors.len(), "tenors"), (atm_ivs.len(), "atm_ivs")])?;
Ok(ferro_ta_core::options::surface::term_structure_slope(
tenors, atm_ivs,
))
}
+4 -4
View File
@@ -13,7 +13,7 @@ fn price_return(curr: f64, prev: f64) -> f64 {
fn beta_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec<f64> {
let n = x.len();
let mut result = vec![f64::NAN; n];
for end in timeperiod..n {
for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod) {
let start = end - timeperiod;
let mut rx = vec![0.0_f64; timeperiod];
let mut ry = vec![0.0_f64; timeperiod];
@@ -36,7 +36,7 @@ fn beta_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec<f64> {
.map(|&value| (value - mean_x).powi(2))
.sum::<f64>()
/ timeperiod as f64;
result[end] = if var_x != 0.0 { cov / var_x } else { f64::NAN };
*slot = if var_x != 0.0 { cov / var_x } else { f64::NAN };
}
result
}
@@ -102,8 +102,8 @@ pub fn beta<'py>(
}
}
for end in timeperiod..n {
result[end] = if invalid_pairs == 0 {
for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod) {
*slot = if invalid_pairs == 0 {
let denom = period * sum_rx2 - sum_rx * sum_rx;
if denom != 0.0 {
(period * sum_rxry - sum_rx * sum_ry) / denom
+4 -4
View File
@@ -5,7 +5,7 @@ use pyo3::prelude::*;
fn correl_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec<f64> {
let n = x.len();
let mut result = vec![f64::NAN; n];
for end in (timeperiod - 1)..n {
for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod - 1) {
let wx = &x[(end + 1 - timeperiod)..=end];
let wy = &y[(end + 1 - timeperiod)..=end];
let mean_x = wx.iter().sum::<f64>() / timeperiod as f64;
@@ -26,7 +26,7 @@ fn correl_fallback(x: &[f64], y: &[f64], timeperiod: usize) -> Vec<f64> {
.sum::<f64>()
.sqrt();
let denom = std_x * std_y;
result[end] = if denom != 0.0 { cov / denom } else { f64::NAN };
*slot = if denom != 0.0 { cov / denom } else { f64::NAN };
}
result
}
@@ -72,10 +72,10 @@ pub fn correl<'py>(
.map(|(&lhs, &rhs)| lhs * rhs)
.sum::<f64>();
for end in (timeperiod - 1)..n {
for (end, slot) in result.iter_mut().enumerate().take(n).skip(timeperiod - 1) {
let denom_x = period * sum_x2 - sum_x * sum_x;
let denom_y = period * sum_y2 - sum_y * sum_y;
result[end] = if denom_x > 0.0 && denom_y > 0.0 {
*slot = if denom_x > 0.0 && denom_y > 0.0 {
(period * sum_xy - sum_x * sum_y) / (denom_x * denom_y).sqrt()
} else {
f64::NAN