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:
co-authored by
Claude Sonnet 4.6
parent
2d5000262f
commit
602d675749
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user