Files
ferro-ta/src/options/payoff.rs
T
Pratik BhadaneandGitHub 3e0f289d51 chore: update ferro-ta version to 1.1.3 (#8)
- Bumped version numbers across Cargo.toml, Cargo.lock, pyproject.toml, and conda/meta.yaml to 1.1.3.
- Added new features including American option pricing, digital options, extended Greeks, and historical volatility estimators.
- Enhanced documentation and tests for new functionalities.
- Updated CHANGELOG.md to reflect changes for version 1.1.3.
2026-04-02 16:38:32 +05:30

496 lines
17 KiB
Rust

use numpy::{IntoPyArray, PyArray1, PyReadonlyArray1};
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::{PyAny, PyTuple};
#[derive(Clone, Copy)]
enum Instrument {
Option,
Future,
Stock,
}
#[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),
2 => Ok(Instrument::Stock),
_ => Err(PyValueError::new_err(
"instrument must be 0 (option), 1 (future), or 2 (stock)",
)),
}
}
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),
"stock" => Ok(Instrument::Stock),
_ => Err(PyValueError::new_err(
"instrument must be 'option', 'future', or 'stock'",
)),
}
}
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 | Instrument::Stock => {
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 | Instrument::Stock => {
let entry_price = leg_attr_optional_f64(&leg, "entry_price")?.ok_or_else(|| {
PyValueError::new_err("Futures/stock 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 | Instrument::Stock => {
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 | Instrument::Stock => {
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))
}
/// Compute BSM-based strategy value over a spot grid (pre-expiry mark-to-market).
///
/// Unlike `strategy_payoff_dense` (which uses intrinsic at expiry), this function
/// values each option leg using the Black-Scholes model price. Futures and stock
/// legs are valued the same as in `strategy_payoff_dense`.
///
/// Delegates to `ferro_ta_core::options::payoff::strategy_value_grid`.
///
/// NOTE: `crates/ferro_ta_core/src/options/mod.rs` must declare `pub mod payoff;`
/// for this function to compile.
#[pyfunction]
#[allow(clippy::too_many_arguments)]
pub fn strategy_value_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>,
time_to_expiries: PyReadonlyArray1<'py, f64>,
volatilities: PyReadonlyArray1<'py, f64>,
rates_per_leg: PyReadonlyArray1<'py, f64>,
carries_per_leg: 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 tte = time_to_expiries.as_slice()?;
let vol = volatilities.as_slice()?;
let rate = rates_per_leg.as_slice()?;
let carry = carries_per_leg.as_slice()?;
let result = ferro_ta_core::options::payoff::strategy_value_grid(
grid, inst, side, opt_t, strike, premium, entry, qty, mult, tte, vol, rate, carry,
);
Ok(result.into_pyarray(py))
}