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
+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,
))
}