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
@@ -4,8 +4,9 @@
|
||||
//! Or: cd crates/ferro_ta_core && cargo bench
|
||||
//!
|
||||
//! Input sizes: 1k, 10k, 100k, and 1M bars for key indicators.
|
||||
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use ferro_ta_core::{momentum, overlap, volatility};
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion};
|
||||
use ferro_ta_core::{futures, momentum, options, overlap, volatility};
|
||||
use std::hint::black_box;
|
||||
|
||||
fn synthetic_close(n: usize) -> Vec<f64> {
|
||||
let mut v = Vec::with_capacity(n);
|
||||
@@ -83,12 +84,129 @@ fn bench_bbands(c: &mut Criterion) {
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_bsm_price(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("BSM_PRICE");
|
||||
for size in [1_000_usize, 10_000, 100_000] {
|
||||
let close = synthetic_close(size);
|
||||
let strikes: Vec<f64> = close.iter().map(|_| 100.0).collect();
|
||||
let vols: Vec<f64> = close.iter().map(|_| 0.2).collect();
|
||||
group.bench_with_input(BenchmarkId::from_parameter(size), &close, |b, close| {
|
||||
b.iter(|| {
|
||||
close
|
||||
.iter()
|
||||
.zip(strikes.iter())
|
||||
.zip(vols.iter())
|
||||
.map(|((&spot, &strike), &vol)| {
|
||||
options::pricing::black_scholes_price(
|
||||
black_box(spot),
|
||||
black_box(strike),
|
||||
black_box(0.02),
|
||||
black_box(0.0),
|
||||
black_box(0.5),
|
||||
black_box(vol),
|
||||
options::OptionKind::Call,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_implied_volatility(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("IMPLIED_VOL");
|
||||
for size in [1_000_usize, 10_000] {
|
||||
let prices: Vec<f64> = (0..size)
|
||||
.map(|i| {
|
||||
let spot = 90.0 + (i % 20) as f64;
|
||||
options::pricing::black_scholes_price(
|
||||
spot,
|
||||
100.0,
|
||||
0.02,
|
||||
0.0,
|
||||
0.5,
|
||||
0.2,
|
||||
options::OptionKind::Call,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
group.bench_with_input(BenchmarkId::from_parameter(size), &prices, |b, prices| {
|
||||
b.iter(|| {
|
||||
prices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &price)| {
|
||||
options::iv::implied_volatility(
|
||||
options::OptionContract {
|
||||
model: options::PricingModel::BlackScholes,
|
||||
underlying: black_box(90.0 + (i % 20) as f64),
|
||||
strike: black_box(100.0),
|
||||
rate: black_box(0.02),
|
||||
carry: black_box(0.0),
|
||||
time_to_expiry: black_box(0.5),
|
||||
kind: options::OptionKind::Call,
|
||||
},
|
||||
black_box(price),
|
||||
options::IvSolverConfig {
|
||||
initial_guess: black_box(0.25),
|
||||
tolerance: black_box(1e-8),
|
||||
max_iterations: black_box(100),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_smile_metrics(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("SMILE_METRICS");
|
||||
let strikes: Vec<f64> = (0..41).map(|i| 80.0 + i as f64).collect();
|
||||
let vols: Vec<f64> = strikes
|
||||
.iter()
|
||||
.map(|&k| 0.18 + ((k - 100.0).abs() / 100.0) * 0.15)
|
||||
.collect();
|
||||
group.bench_function("single_chain", |b| {
|
||||
b.iter(|| {
|
||||
options::surface::smile_metrics(
|
||||
black_box(&strikes),
|
||||
black_box(&vols),
|
||||
black_box(100.0),
|
||||
black_box(0.02),
|
||||
black_box(0.0),
|
||||
black_box(0.5),
|
||||
options::PricingModel::BlackScholes,
|
||||
)
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_curve_summary(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("FUTURES_CURVE");
|
||||
let tenors = vec![0.1, 0.25, 0.5, 0.75, 1.0];
|
||||
let prices = vec![101.0, 101.8, 102.7, 103.4, 104.1];
|
||||
group.bench_function("curve_summary", |b| {
|
||||
b.iter(|| {
|
||||
futures::curve::curve_summary(black_box(100.0), black_box(&tenors), black_box(&prices))
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_sma,
|
||||
bench_ema,
|
||||
bench_rsi,
|
||||
bench_atr,
|
||||
bench_bbands
|
||||
bench_bbands,
|
||||
bench_bsm_price,
|
||||
bench_implied_volatility,
|
||||
bench_smile_metrics,
|
||||
bench_curve_summary
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Basis and carry analytics.
|
||||
|
||||
/// Futures basis: futures - spot.
|
||||
pub fn basis(spot: f64, future: f64) -> f64 {
|
||||
if !spot.is_finite() || !future.is_finite() {
|
||||
f64::NAN
|
||||
} else {
|
||||
future - spot
|
||||
}
|
||||
}
|
||||
|
||||
/// Annualized simple basis return.
|
||||
pub fn annualized_basis(spot: f64, future: f64, time_to_expiry: f64) -> f64 {
|
||||
if !spot.is_finite()
|
||||
|| !future.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| time_to_expiry <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(future / spot - 1.0) / time_to_expiry
|
||||
}
|
||||
|
||||
/// Implied continuously compounded carry rate.
|
||||
pub fn implied_carry_rate(spot: f64, future: f64, time_to_expiry: f64) -> f64 {
|
||||
if !spot.is_finite()
|
||||
|| !future.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| spot <= 0.0
|
||||
|| future <= 0.0
|
||||
|| time_to_expiry <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(future / spot).ln() / time_to_expiry
|
||||
}
|
||||
|
||||
/// Carry spread relative to the risk-free rate.
|
||||
pub fn carry_spread(spot: f64, future: f64, rate: f64, time_to_expiry: f64) -> f64 {
|
||||
implied_carry_rate(spot, future, time_to_expiry) - rate
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{annualized_basis, basis, carry_spread, implied_carry_rate};
|
||||
|
||||
#[test]
|
||||
fn basis_helpers_work() {
|
||||
assert_eq!(basis(100.0, 103.0), 3.0);
|
||||
assert!(annualized_basis(100.0, 103.0, 0.25) > 0.0);
|
||||
assert!(implied_carry_rate(100.0, 103.0, 0.25) > 0.0);
|
||||
assert!(carry_spread(100.0, 103.0, 0.02, 0.25).is_finite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Futures curve and term-structure analytics.
|
||||
|
||||
use super::basis;
|
||||
|
||||
/// Curve summary metrics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CurveSummary {
|
||||
pub front_basis: f64,
|
||||
pub average_basis: f64,
|
||||
pub slope: f64,
|
||||
pub is_contango: bool,
|
||||
}
|
||||
|
||||
fn regression_slope(xs: &[f64], ys: &[f64]) -> f64 {
|
||||
if xs.len() != ys.len() || xs.len() < 2 {
|
||||
return f64::NAN;
|
||||
}
|
||||
let n = xs.len() as f64;
|
||||
let mean_x = xs.iter().sum::<f64>() / n;
|
||||
let mean_y = ys.iter().sum::<f64>() / n;
|
||||
let mut cov = 0.0;
|
||||
let mut var = 0.0;
|
||||
for (&x, &y) in xs.iter().zip(ys.iter()) {
|
||||
cov += (x - mean_x) * (y - mean_y);
|
||||
var += (x - mean_x) * (x - mean_x);
|
||||
}
|
||||
if var == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
cov / var
|
||||
}
|
||||
}
|
||||
|
||||
/// Calendar spreads between adjacent contracts.
|
||||
pub fn calendar_spreads(futures_prices: &[f64]) -> Vec<f64> {
|
||||
futures_prices.windows(2).map(|w| w[1] - w[0]).collect()
|
||||
}
|
||||
|
||||
/// Curve slope across tenor buckets.
|
||||
pub fn curve_slope(tenors: &[f64], futures_prices: &[f64]) -> f64 {
|
||||
regression_slope(tenors, futures_prices)
|
||||
}
|
||||
|
||||
/// Summary statistics for a forward curve.
|
||||
pub fn curve_summary(spot: f64, tenors: &[f64], futures_prices: &[f64]) -> CurveSummary {
|
||||
if futures_prices.is_empty() || tenors.len() != futures_prices.len() {
|
||||
return CurveSummary {
|
||||
front_basis: f64::NAN,
|
||||
average_basis: f64::NAN,
|
||||
slope: f64::NAN,
|
||||
is_contango: false,
|
||||
};
|
||||
}
|
||||
let bases: Vec<f64> = futures_prices
|
||||
.iter()
|
||||
.map(|&price| basis::basis(spot, price))
|
||||
.collect();
|
||||
let average_basis = bases.iter().sum::<f64>() / bases.len() as f64;
|
||||
let is_contango = futures_prices.windows(2).all(|w| w[1] >= w[0]);
|
||||
CurveSummary {
|
||||
front_basis: basis::basis(spot, futures_prices[0]),
|
||||
average_basis,
|
||||
slope: curve_slope(tenors, futures_prices),
|
||||
is_contango,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{calendar_spreads, curve_slope, curve_summary};
|
||||
|
||||
#[test]
|
||||
fn calendar_spreads_are_correct() {
|
||||
assert_eq!(calendar_spreads(&[100.0, 101.0, 103.0]), vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curve_summary_detects_contango() {
|
||||
let summary = curve_summary(100.0, &[0.1, 0.5, 1.0], &[101.0, 102.0, 104.0]);
|
||||
assert!(summary.is_contango);
|
||||
assert!(curve_slope(&[0.1, 0.5, 1.0], &[101.0, 102.0, 104.0]) > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Futures analytics core.
|
||||
|
||||
pub mod basis;
|
||||
pub mod curve;
|
||||
pub mod roll;
|
||||
pub mod synthetic;
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Continuous futures roll helpers.
|
||||
|
||||
/// Weighted stitching using next-contract weights in [0, 1].
|
||||
pub fn weighted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec<f64> {
|
||||
if front.len() != next.len() || front.len() != next_weights.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
front
|
||||
.iter()
|
||||
.zip(next.iter())
|
||||
.zip(next_weights.iter())
|
||||
.map(|((&f, &n), &w)| f * (1.0 - w) + n * w)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn roll_index(weights: &[f64]) -> Option<usize> {
|
||||
if weights.is_empty() {
|
||||
return None;
|
||||
}
|
||||
weights
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, w)| **w >= 0.5)
|
||||
.map(|(idx, _)| idx)
|
||||
.or_else(|| weights.iter().position(|w| *w > 0.0))
|
||||
.or(Some(weights.len() - 1))
|
||||
}
|
||||
|
||||
/// Back-adjusted continuous series using the roll date implied by the weights.
|
||||
pub fn back_adjusted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec<f64> {
|
||||
if front.len() != next.len() || front.len() != next_weights.len() || front.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let idx = roll_index(next_weights).unwrap_or(front.len() - 1);
|
||||
let gap = next[idx] - front[idx];
|
||||
front
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &value)| if i < idx { value + gap } else { next[i] })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ratio-adjusted continuous series using the roll date implied by the weights.
|
||||
pub fn ratio_adjusted_continuous(front: &[f64], next: &[f64], next_weights: &[f64]) -> Vec<f64> {
|
||||
if front.len() != next.len() || front.len() != next_weights.len() || front.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let idx = roll_index(next_weights).unwrap_or(front.len() - 1);
|
||||
let ratio = if front[idx] == 0.0 {
|
||||
1.0
|
||||
} else {
|
||||
next[idx] / front[idx]
|
||||
};
|
||||
front
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &value)| if i < idx { value * ratio } else { next[i] })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Annualized roll yield from front and next prices.
|
||||
pub fn roll_yield(front_price: f64, next_price: f64, time_to_expiry: f64) -> f64 {
|
||||
if !front_price.is_finite()
|
||||
|| !next_price.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| front_price <= 0.0
|
||||
|| time_to_expiry <= 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(next_price / front_price - 1.0) / time_to_expiry
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
back_adjusted_continuous, ratio_adjusted_continuous, roll_yield, weighted_continuous,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn weighted_roll_blends_contracts() {
|
||||
let out = weighted_continuous(&[100.0, 101.0], &[102.0, 103.0], &[0.0, 1.0]);
|
||||
assert_eq!(out, vec![100.0, 103.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjusted_rolls_return_full_series() {
|
||||
let weights = [0.0, 0.25, 0.75, 1.0];
|
||||
assert_eq!(
|
||||
back_adjusted_continuous(
|
||||
&[100.0, 101.0, 102.0, 103.0],
|
||||
&[101.0, 102.0, 103.0, 104.0],
|
||||
&weights
|
||||
)
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
ratio_adjusted_continuous(
|
||||
&[100.0, 101.0, 102.0, 103.0],
|
||||
&[101.0, 102.0, 103.0, 104.0],
|
||||
&weights
|
||||
)
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
assert!(roll_yield(100.0, 102.0, 30.0 / 365.0).is_finite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Synthetic futures helpers built from put-call parity.
|
||||
|
||||
/// Synthetic forward price from call/put parity.
|
||||
pub fn synthetic_forward(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
if !call_price.is_finite()
|
||||
|| !put_price.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !rate.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(call_price - put_price) * (rate * time_to_expiry).exp() + strike
|
||||
}
|
||||
|
||||
/// Synthetic spot price implied by call/put parity with continuous carry.
|
||||
pub fn synthetic_spot(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
if !call_price.is_finite()
|
||||
|| !put_price.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !rate.is_finite()
|
||||
|| !carry.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
(call_price - put_price + strike * (-rate * time_to_expiry).exp())
|
||||
* (carry * time_to_expiry).exp()
|
||||
}
|
||||
|
||||
/// Put-call parity residual. Zero means the inputs are parity-consistent.
|
||||
pub fn parity_gap(
|
||||
call_price: f64,
|
||||
put_price: f64,
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
) -> f64 {
|
||||
call_price
|
||||
- put_price
|
||||
- (spot * (-carry * time_to_expiry).exp() - strike * (-rate * time_to_expiry).exp())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parity_gap, synthetic_forward};
|
||||
|
||||
#[test]
|
||||
fn synthetic_forward_is_consistent() {
|
||||
let forward = synthetic_forward(8.0, 5.0, 100.0, 0.02, 0.5);
|
||||
assert!(forward > 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parity_gap_zero_when_consistent() {
|
||||
let gap = parity_gap(10.45, 5.57, 100.0, 100.0, 0.05, 0.0, 1.0);
|
||||
assert!(gap.abs() < 0.05);
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,10 @@ assert!((sma[2] - 2.0).abs() < 1e-10);
|
||||
```
|
||||
*/
|
||||
|
||||
pub mod futures;
|
||||
pub mod math;
|
||||
pub mod momentum;
|
||||
pub mod options;
|
||||
pub mod overlap;
|
||||
pub mod statistic;
|
||||
pub mod volatility;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Option chain analytics helpers.
|
||||
|
||||
use super::greeks::model_greeks;
|
||||
use super::{ChainGreeksContext, OptionContract, OptionEvaluation, OptionKind};
|
||||
|
||||
/// Return the index of the strike closest to the reference price.
|
||||
pub fn atm_index(strikes: &[f64], reference_price: f64) -> Option<usize> {
|
||||
if strikes.is_empty() || !reference_price.is_finite() {
|
||||
return None;
|
||||
}
|
||||
strikes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, strike)| strike.is_finite())
|
||||
.min_by(|(_, a), (_, b)| {
|
||||
(*a - reference_price)
|
||||
.abs()
|
||||
.partial_cmp(&(*b - reference_price).abs())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(idx, _)| idx)
|
||||
}
|
||||
|
||||
/// Label strikes as ITM (1), ATM (0), or OTM (-1).
|
||||
pub fn label_moneyness(strikes: &[f64], reference_price: f64, kind: OptionKind) -> Vec<i8> {
|
||||
let mut labels = Vec::with_capacity(strikes.len());
|
||||
let atm_idx = atm_index(strikes, reference_price);
|
||||
for (idx, &strike) in strikes.iter().enumerate() {
|
||||
if Some(idx) == atm_idx {
|
||||
labels.push(0);
|
||||
continue;
|
||||
}
|
||||
let label = match kind {
|
||||
OptionKind::Call => {
|
||||
if strike < reference_price {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
OptionKind::Put => {
|
||||
if strike > reference_price {
|
||||
1
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
}
|
||||
};
|
||||
labels.push(label);
|
||||
}
|
||||
labels
|
||||
}
|
||||
|
||||
/// Select a strike relative to the ATM strike by offset steps.
|
||||
pub fn select_strike_by_offset(
|
||||
strikes: &[f64],
|
||||
reference_price: f64,
|
||||
offset: isize,
|
||||
) -> Option<f64> {
|
||||
let idx = atm_index(strikes, reference_price)? as isize + offset;
|
||||
if idx < 0 || idx >= strikes.len() as isize {
|
||||
None
|
||||
} else {
|
||||
Some(strikes[idx as usize])
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the strike whose delta is closest to the requested target.
|
||||
pub fn select_strike_by_delta(
|
||||
strikes: &[f64],
|
||||
vols: &[f64],
|
||||
context: ChainGreeksContext,
|
||||
target_delta: f64,
|
||||
) -> Option<f64> {
|
||||
if strikes.len() != vols.len() || strikes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
strikes
|
||||
.iter()
|
||||
.zip(vols.iter())
|
||||
.filter(|(strike, vol)| strike.is_finite() && vol.is_finite())
|
||||
.min_by(|(strike_a, vol_a), (strike_b, vol_b)| {
|
||||
let delta_a = model_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: context.model,
|
||||
underlying: context.reference_price,
|
||||
strike: **strike_a,
|
||||
rate: context.rate,
|
||||
carry: context.carry,
|
||||
time_to_expiry: context.time_to_expiry,
|
||||
kind: context.kind,
|
||||
},
|
||||
volatility: **vol_a,
|
||||
})
|
||||
.delta;
|
||||
let delta_b = model_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: context.model,
|
||||
underlying: context.reference_price,
|
||||
strike: **strike_b,
|
||||
rate: context.rate,
|
||||
carry: context.carry,
|
||||
time_to_expiry: context.time_to_expiry,
|
||||
kind: context.kind,
|
||||
},
|
||||
volatility: **vol_b,
|
||||
})
|
||||
.delta;
|
||||
(delta_a - target_delta)
|
||||
.abs()
|
||||
.partial_cmp(&(delta_b - target_delta).abs())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|(strike, _)| *strike)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{atm_index, label_moneyness, select_strike_by_delta, select_strike_by_offset};
|
||||
use crate::options::{ChainGreeksContext, OptionKind, PricingModel};
|
||||
|
||||
#[test]
|
||||
fn atm_index_finds_nearest() {
|
||||
let strikes = [90.0, 100.0, 110.0];
|
||||
assert_eq!(atm_index(&strikes, 103.0), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moneyness_labels_calls() {
|
||||
let strikes = [90.0, 100.0, 110.0];
|
||||
assert_eq!(
|
||||
label_moneyness(&strikes, 100.0, OptionKind::Call),
|
||||
vec![1, 0, -1]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_selects_expected_strike() {
|
||||
let strikes = [90.0, 100.0, 110.0];
|
||||
assert_eq!(select_strike_by_offset(&strikes, 101.0, 1), Some(110.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delta_selection_returns_a_strike() {
|
||||
let strikes = [80.0, 90.0, 100.0, 110.0, 120.0];
|
||||
let vols = [0.28, 0.24, 0.20, 0.22, 0.26];
|
||||
let strike = select_strike_by_delta(
|
||||
&strikes,
|
||||
&vols,
|
||||
ChainGreeksContext {
|
||||
model: PricingModel::BlackScholes,
|
||||
reference_price: 100.0,
|
||||
rate: 0.01,
|
||||
carry: 0.0,
|
||||
time_to_expiry: 0.5,
|
||||
kind: OptionKind::Call,
|
||||
},
|
||||
0.25,
|
||||
);
|
||||
assert!(strike.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Option Greeks.
|
||||
|
||||
use super::normal::{cdf, pdf};
|
||||
use super::pricing::{black_76_price, black_scholes_price};
|
||||
use super::{Greeks, OptionEvaluation, OptionKind, PricingModel};
|
||||
|
||||
fn bs_inputs_valid(
|
||||
underlying: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
) -> bool {
|
||||
underlying.is_finite()
|
||||
&& strike.is_finite()
|
||||
&& rate.is_finite()
|
||||
&& carry.is_finite()
|
||||
&& time_to_expiry.is_finite()
|
||||
&& volatility.is_finite()
|
||||
&& underlying > 0.0
|
||||
&& strike > 0.0
|
||||
&& time_to_expiry > 0.0
|
||||
&& volatility > 0.0
|
||||
}
|
||||
|
||||
fn numerical_theta<F>(time_to_expiry: f64, price_fn: F) -> f64
|
||||
where
|
||||
F: Fn(f64) -> f64,
|
||||
{
|
||||
if time_to_expiry <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let h = time_to_expiry.clamp(1e-6, 1.0 / 365.0);
|
||||
let t_minus = (time_to_expiry - h).max(1e-8);
|
||||
let t_plus = time_to_expiry + h;
|
||||
let price_minus = price_fn(t_minus);
|
||||
let price_plus = price_fn(t_plus);
|
||||
(price_minus - price_plus) / (t_plus - t_minus)
|
||||
}
|
||||
|
||||
/// Black-Scholes-Merton Greeks.
|
||||
pub fn black_scholes_greeks(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
dividend_yield: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> Greeks {
|
||||
if !bs_inputs_valid(
|
||||
spot,
|
||||
strike,
|
||||
rate,
|
||||
dividend_yield,
|
||||
time_to_expiry,
|
||||
volatility,
|
||||
) {
|
||||
return Greeks {
|
||||
delta: f64::NAN,
|
||||
gamma: f64::NAN,
|
||||
vega: f64::NAN,
|
||||
theta: f64::NAN,
|
||||
rho: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
let carry_discount = (-dividend_yield * time_to_expiry).exp();
|
||||
let d1 = ((spot / strike).ln()
|
||||
+ (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
let pdf_d1 = pdf(d1);
|
||||
|
||||
let delta = match kind {
|
||||
OptionKind::Call => carry_discount * cdf(d1),
|
||||
OptionKind::Put => carry_discount * (cdf(d1) - 1.0),
|
||||
};
|
||||
let gamma = carry_discount * pdf_d1 / (spot * sigma_sqrt_t);
|
||||
let vega = spot * carry_discount * pdf_d1 * sqrt_t;
|
||||
let theta = match kind {
|
||||
OptionKind::Call => {
|
||||
-(spot * carry_discount * pdf_d1 * volatility) / (2.0 * sqrt_t)
|
||||
- rate * strike * discount * cdf(d2)
|
||||
+ dividend_yield * spot * carry_discount * cdf(d1)
|
||||
}
|
||||
OptionKind::Put => {
|
||||
-(spot * carry_discount * pdf_d1 * volatility) / (2.0 * sqrt_t)
|
||||
+ rate * strike * discount * cdf(-d2)
|
||||
- dividend_yield * spot * carry_discount * cdf(-d1)
|
||||
}
|
||||
};
|
||||
let rho = match kind {
|
||||
OptionKind::Call => strike * time_to_expiry * discount * cdf(d2),
|
||||
OptionKind::Put => -strike * time_to_expiry * discount * cdf(-d2),
|
||||
};
|
||||
|
||||
Greeks {
|
||||
delta,
|
||||
gamma,
|
||||
vega,
|
||||
theta,
|
||||
rho,
|
||||
}
|
||||
}
|
||||
|
||||
/// Black-76 Greeks with respect to the forward.
|
||||
pub fn black_76_greeks(
|
||||
forward: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> Greeks {
|
||||
if !bs_inputs_valid(forward, strike, rate, 0.0, time_to_expiry, volatility) {
|
||||
return Greeks {
|
||||
delta: f64::NAN,
|
||||
gamma: f64::NAN,
|
||||
vega: f64::NAN,
|
||||
theta: f64::NAN,
|
||||
rho: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
let d1 =
|
||||
((forward / strike).ln() + 0.5 * volatility * volatility * time_to_expiry) / sigma_sqrt_t;
|
||||
let pdf_d1 = pdf(d1);
|
||||
|
||||
let delta = match kind {
|
||||
OptionKind::Call => discount * cdf(d1),
|
||||
OptionKind::Put => -discount * cdf(-d1),
|
||||
};
|
||||
let gamma = discount * pdf_d1 / (forward * sigma_sqrt_t);
|
||||
let vega = discount * forward * pdf_d1 * sqrt_t;
|
||||
let theta = numerical_theta(time_to_expiry, |t| {
|
||||
black_76_price(forward, strike, rate, t, volatility, kind)
|
||||
});
|
||||
let rho =
|
||||
-time_to_expiry * black_76_price(forward, strike, rate, time_to_expiry, volatility, kind);
|
||||
|
||||
Greeks {
|
||||
delta,
|
||||
gamma,
|
||||
vega,
|
||||
theta,
|
||||
rho,
|
||||
}
|
||||
}
|
||||
|
||||
/// Model-dispatched Greeks.
|
||||
pub fn model_greeks(input: OptionEvaluation) -> Greeks {
|
||||
let contract = input.contract;
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => black_scholes_greeks(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.carry,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
PricingModel::Black76 => black_76_greeks(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Price derivative with respect to calendar time using the selected model.
|
||||
pub fn model_theta(input: OptionEvaluation) -> f64 {
|
||||
let contract = input.contract;
|
||||
numerical_theta(contract.time_to_expiry, |t| match contract.model {
|
||||
PricingModel::BlackScholes => black_scholes_price(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.carry,
|
||||
t,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
PricingModel::Black76 => black_76_price(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
t,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{black_76_greeks, black_scholes_greeks};
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
fn bsm_greeks_are_finite() {
|
||||
let g = black_scholes_greeks(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
assert!(g.delta.is_finite());
|
||||
assert!(g.gamma.is_finite());
|
||||
assert!(g.vega.is_finite());
|
||||
assert!(g.theta.is_finite());
|
||||
assert!(g.rho.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_76_greeks_are_finite() {
|
||||
let g = black_76_greeks(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Put);
|
||||
assert!(g.delta.is_finite());
|
||||
assert!(g.gamma.is_finite());
|
||||
assert!(g.vega.is_finite());
|
||||
assert!(g.theta.is_finite());
|
||||
assert!(g.rho.is_finite());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Implied volatility inversion and IV-series helpers.
|
||||
|
||||
use super::greeks::model_greeks;
|
||||
use super::pricing::{model_price, price_lower_bound, price_upper_bound};
|
||||
use super::{IvSolverConfig, OptionContract, OptionEvaluation};
|
||||
|
||||
/// Solve implied volatility with guarded Newton iterations and bisection fallback.
|
||||
pub fn implied_volatility(
|
||||
contract: OptionContract,
|
||||
target_price: f64,
|
||||
config: IvSolverConfig,
|
||||
) -> f64 {
|
||||
if !target_price.is_finite()
|
||||
|| !contract.underlying.is_finite()
|
||||
|| !contract.strike.is_finite()
|
||||
|| !contract.rate.is_finite()
|
||||
|| !contract.carry.is_finite()
|
||||
|| !contract.time_to_expiry.is_finite()
|
||||
|| target_price < 0.0
|
||||
|| contract.underlying <= 0.0
|
||||
|| contract.strike <= 0.0
|
||||
|| contract.time_to_expiry < 0.0
|
||||
{
|
||||
return f64::NAN;
|
||||
}
|
||||
if contract.time_to_expiry == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let lower = price_lower_bound(contract);
|
||||
let upper = price_upper_bound(contract);
|
||||
if target_price < lower - config.tolerance || target_price > upper + config.tolerance {
|
||||
return f64::NAN;
|
||||
}
|
||||
if (target_price - lower).abs() <= config.tolerance {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mut low_vol = 1e-9;
|
||||
let mut high_vol = config.initial_guess.max(0.25).max(low_vol * 10.0);
|
||||
let mut high_price = model_price(OptionEvaluation {
|
||||
contract,
|
||||
volatility: high_vol,
|
||||
});
|
||||
while high_price < target_price && high_vol < 10.0 {
|
||||
high_vol *= 2.0;
|
||||
high_price = model_price(OptionEvaluation {
|
||||
contract,
|
||||
volatility: high_vol,
|
||||
});
|
||||
}
|
||||
if high_price < target_price {
|
||||
return f64::NAN;
|
||||
}
|
||||
|
||||
let mut vol = config.initial_guess.clamp(low_vol, high_vol).max(1e-4);
|
||||
for _ in 0..config.max_iterations.max(1) {
|
||||
let price = model_price(OptionEvaluation {
|
||||
contract,
|
||||
volatility: vol,
|
||||
});
|
||||
let diff = price - target_price;
|
||||
if diff.abs() <= config.tolerance {
|
||||
return vol;
|
||||
}
|
||||
|
||||
if diff > 0.0 {
|
||||
high_vol = high_vol.min(vol);
|
||||
} else {
|
||||
low_vol = low_vol.max(vol);
|
||||
}
|
||||
|
||||
let vega = model_greeks(OptionEvaluation {
|
||||
contract,
|
||||
volatility: vol,
|
||||
})
|
||||
.vega;
|
||||
|
||||
let next = if vega.is_finite() && vega.abs() > 1e-10 {
|
||||
let candidate = vol - diff / vega;
|
||||
if candidate > low_vol && candidate < high_vol {
|
||||
candidate
|
||||
} else {
|
||||
0.5 * (low_vol + high_vol)
|
||||
}
|
||||
} else {
|
||||
0.5 * (low_vol + high_vol)
|
||||
};
|
||||
vol = next;
|
||||
}
|
||||
|
||||
let final_price = model_price(OptionEvaluation {
|
||||
contract,
|
||||
volatility: vol,
|
||||
});
|
||||
if (final_price - target_price).abs() <= config.tolerance * 10.0 {
|
||||
vol
|
||||
} else {
|
||||
f64::NAN
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_window(window: usize) -> bool {
|
||||
window >= 1
|
||||
}
|
||||
|
||||
/// Rolling IV rank.
|
||||
pub fn iv_rank(iv_series: &[f64], window: usize) -> Vec<f64> {
|
||||
let n = iv_series.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if !validate_window(window) || n < window {
|
||||
return out;
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let mut min_v = f64::INFINITY;
|
||||
let mut max_v = f64::NEG_INFINITY;
|
||||
for &v in &iv_series[start..=end] {
|
||||
if v.is_finite() {
|
||||
min_v = min_v.min(v);
|
||||
max_v = max_v.max(v);
|
||||
}
|
||||
}
|
||||
let current = iv_series[end];
|
||||
if !current.is_finite() || !min_v.is_finite() || !max_v.is_finite() {
|
||||
out[end] = f64::NAN;
|
||||
continue;
|
||||
}
|
||||
let spread = max_v - min_v;
|
||||
out[end] = if spread == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
(current - min_v) / spread
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling IV percentile.
|
||||
pub fn iv_percentile(iv_series: &[f64], window: usize) -> Vec<f64> {
|
||||
let n = iv_series.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if !validate_window(window) || n < window {
|
||||
return out;
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let current = iv_series[end];
|
||||
let count = iv_series[start..=end]
|
||||
.iter()
|
||||
.filter(|&&v| v <= current)
|
||||
.count();
|
||||
out[end] = count as f64 / window as f64;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rolling IV z-score.
|
||||
pub fn iv_zscore(iv_series: &[f64], window: usize) -> Vec<f64> {
|
||||
let n = iv_series.len();
|
||||
let mut out = vec![f64::NAN; n];
|
||||
if !validate_window(window) || n < window {
|
||||
return out;
|
||||
}
|
||||
|
||||
for end in (window - 1)..n {
|
||||
let start = end + 1 - window;
|
||||
let mut count = 0usize;
|
||||
let mut sum = 0.0;
|
||||
for &v in &iv_series[start..=end] {
|
||||
if v.is_finite() {
|
||||
count += 1;
|
||||
sum += v;
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
out[end] = f64::NAN;
|
||||
continue;
|
||||
}
|
||||
let mean = sum / count as f64;
|
||||
let mut var = 0.0;
|
||||
for &v in &iv_series[start..=end] {
|
||||
if v.is_finite() {
|
||||
let d = v - mean;
|
||||
var += d * d;
|
||||
}
|
||||
}
|
||||
let std = (var / count as f64).sqrt();
|
||||
let current = iv_series[end];
|
||||
out[end] = if !current.is_finite() || std == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
(current - mean) / std
|
||||
};
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{implied_volatility, iv_percentile, iv_rank, iv_zscore};
|
||||
use crate::options::pricing::black_scholes_price;
|
||||
use crate::options::{IvSolverConfig, OptionContract, OptionKind, PricingModel};
|
||||
|
||||
#[test]
|
||||
fn solver_recovers_input_vol() {
|
||||
let price = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
let iv = implied_volatility(
|
||||
OptionContract {
|
||||
model: PricingModel::BlackScholes,
|
||||
underlying: 100.0,
|
||||
strike: 100.0,
|
||||
rate: 0.05,
|
||||
carry: 0.0,
|
||||
time_to_expiry: 1.0,
|
||||
kind: OptionKind::Call,
|
||||
},
|
||||
price,
|
||||
IvSolverConfig {
|
||||
initial_guess: 0.3,
|
||||
tolerance: 1e-8,
|
||||
max_iterations: 100,
|
||||
},
|
||||
);
|
||||
assert!((iv - 0.2).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iv_helpers_match_expected_values() {
|
||||
let iv = [10.0, 20.0, 30.0, 15.0, 22.0];
|
||||
let rank = iv_rank(&iv, 3);
|
||||
let pct = iv_percentile(&iv, 3);
|
||||
let z = iv_zscore(&iv, 3);
|
||||
assert!(rank[0].is_nan() && rank[1].is_nan());
|
||||
assert!((rank[2] - 1.0).abs() < 1e-12);
|
||||
assert!((pct[3] - (1.0 / 3.0)).abs() < 1e-12);
|
||||
assert!((z[2] - 1.224_744_871).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Options analytics core.
|
||||
//!
|
||||
//! This module contains pricing, Greeks, implied volatility inversion,
|
||||
//! IV-series helpers, and smile/chain utilities. The public API is scalar-first
|
||||
//! and is used by the PyO3 bridge to build vectorized batch functions.
|
||||
|
||||
pub mod chain;
|
||||
pub mod greeks;
|
||||
pub mod iv;
|
||||
pub mod normal;
|
||||
pub mod pricing;
|
||||
pub mod surface;
|
||||
|
||||
/// Option side.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum OptionKind {
|
||||
/// Call option.
|
||||
Call,
|
||||
/// Put option.
|
||||
Put,
|
||||
}
|
||||
|
||||
impl OptionKind {
|
||||
/// Returns +1 for calls and -1 for puts.
|
||||
pub fn sign(self) -> f64 {
|
||||
match self {
|
||||
Self::Call => 1.0,
|
||||
Self::Put => -1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supported pricing models.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PricingModel {
|
||||
/// Black-Scholes-Merton with continuous carry/dividend yield.
|
||||
BlackScholes,
|
||||
/// Black-76 using the forward price as the underlying input.
|
||||
Black76,
|
||||
}
|
||||
|
||||
/// Primary first-order Greeks returned by the pricing engine.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Greeks {
|
||||
pub delta: f64,
|
||||
pub gamma: f64,
|
||||
pub vega: f64,
|
||||
pub theta: f64,
|
||||
pub rho: f64,
|
||||
}
|
||||
|
||||
/// Shared contract fields for model-based option analytics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OptionContract {
|
||||
pub model: PricingModel,
|
||||
pub underlying: f64,
|
||||
pub strike: f64,
|
||||
pub rate: f64,
|
||||
pub carry: f64,
|
||||
pub time_to_expiry: f64,
|
||||
pub kind: OptionKind,
|
||||
}
|
||||
|
||||
/// Contract plus volatility for pricing and Greeks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OptionEvaluation {
|
||||
pub contract: OptionContract,
|
||||
pub volatility: f64,
|
||||
}
|
||||
|
||||
/// Solver configuration for implied volatility inversion.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct IvSolverConfig {
|
||||
pub initial_guess: f64,
|
||||
pub tolerance: f64,
|
||||
pub max_iterations: usize,
|
||||
}
|
||||
|
||||
/// Shared context for strike selection and smile analytics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ChainGreeksContext {
|
||||
pub model: PricingModel,
|
||||
pub reference_price: f64,
|
||||
pub rate: f64,
|
||||
pub carry: f64,
|
||||
pub time_to_expiry: f64,
|
||||
pub kind: OptionKind,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Normal distribution helpers.
|
||||
|
||||
const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
|
||||
|
||||
/// Standard normal probability density function.
|
||||
pub fn pdf(x: f64) -> f64 {
|
||||
INV_SQRT_2PI * (-0.5 * x * x).exp()
|
||||
}
|
||||
|
||||
/// Standard normal cumulative distribution function.
|
||||
///
|
||||
/// Uses a common Abramowitz-Stegun style approximation that is fast and
|
||||
/// sufficiently accurate for option pricing work.
|
||||
pub fn cdf(x: f64) -> f64 {
|
||||
let ax = x.abs();
|
||||
let t = 1.0 / (1.0 + 0.231_641_9 * ax);
|
||||
let poly = (((((1.330_274_429 * t - 1.821_255_978) * t) + 1.781_477_937) * t - 0.356_563_782)
|
||||
* t
|
||||
+ 0.319_381_530)
|
||||
* t;
|
||||
let approx = 1.0 - pdf(ax) * poly;
|
||||
if x >= 0.0 {
|
||||
approx
|
||||
} else {
|
||||
1.0 - approx
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cdf, pdf};
|
||||
|
||||
#[test]
|
||||
fn cdf_is_reasonable() {
|
||||
assert!((cdf(0.0) - 0.5).abs() < 1e-7);
|
||||
assert!((cdf(1.0) - 0.841_344_746).abs() < 5e-5);
|
||||
assert!((cdf(-1.0) - 0.158_655_254).abs() < 5e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_is_reasonable() {
|
||||
assert!((pdf(0.0) - 0.398_942_280_4).abs() < 1e-10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Option pricing models.
|
||||
|
||||
use super::normal::cdf;
|
||||
use super::{OptionContract, OptionEvaluation, OptionKind, PricingModel};
|
||||
|
||||
fn invalid_inputs(underlying: f64, strike: f64, time_to_expiry: f64, volatility: f64) -> bool {
|
||||
!underlying.is_finite()
|
||||
|| !strike.is_finite()
|
||||
|| !time_to_expiry.is_finite()
|
||||
|| !volatility.is_finite()
|
||||
|| underlying <= 0.0
|
||||
|| strike <= 0.0
|
||||
|| time_to_expiry < 0.0
|
||||
|| volatility < 0.0
|
||||
}
|
||||
|
||||
/// Black-Scholes-Merton price with continuous carry/dividend yield.
|
||||
pub fn black_scholes_price(
|
||||
spot: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
dividend_yield: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> f64 {
|
||||
if invalid_inputs(spot, strike, time_to_expiry, volatility) || !rate.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
if time_to_expiry == 0.0 {
|
||||
return match kind {
|
||||
OptionKind::Call => (spot - strike).max(0.0),
|
||||
OptionKind::Put => (strike - spot).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
let carry_discount = (-dividend_yield * time_to_expiry).exp();
|
||||
if volatility == 0.0 {
|
||||
return match kind {
|
||||
OptionKind::Call => (spot * carry_discount - strike * discount).max(0.0),
|
||||
OptionKind::Put => (strike * discount - spot * carry_discount).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let d1 = ((spot / strike).ln()
|
||||
+ (rate - dividend_yield + 0.5 * volatility * volatility) * time_to_expiry)
|
||||
/ sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
|
||||
match kind {
|
||||
OptionKind::Call => spot * carry_discount * cdf(d1) - strike * discount * cdf(d2),
|
||||
OptionKind::Put => strike * discount * cdf(-d2) - spot * carry_discount * cdf(-d1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Black-76 price using the forward price as the underlying input.
|
||||
pub fn black_76_price(
|
||||
forward: f64,
|
||||
strike: f64,
|
||||
rate: f64,
|
||||
time_to_expiry: f64,
|
||||
volatility: f64,
|
||||
kind: OptionKind,
|
||||
) -> f64 {
|
||||
if invalid_inputs(forward, strike, time_to_expiry, volatility) || !rate.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
let discount = (-rate * time_to_expiry).exp();
|
||||
if time_to_expiry == 0.0 {
|
||||
return discount
|
||||
* match kind {
|
||||
OptionKind::Call => (forward - strike).max(0.0),
|
||||
OptionKind::Put => (strike - forward).max(0.0),
|
||||
};
|
||||
}
|
||||
if volatility == 0.0 {
|
||||
return discount
|
||||
* match kind {
|
||||
OptionKind::Call => (forward - strike).max(0.0),
|
||||
OptionKind::Put => (strike - forward).max(0.0),
|
||||
};
|
||||
}
|
||||
|
||||
let sqrt_t = time_to_expiry.sqrt();
|
||||
let sigma_sqrt_t = volatility * sqrt_t;
|
||||
let d1 =
|
||||
((forward / strike).ln() + 0.5 * volatility * volatility * time_to_expiry) / sigma_sqrt_t;
|
||||
let d2 = d1 - sigma_sqrt_t;
|
||||
|
||||
let signed = kind.sign();
|
||||
discount * signed * (forward * cdf(signed * d1) - strike * cdf(signed * d2))
|
||||
}
|
||||
|
||||
/// Model-dispatched option price.
|
||||
pub fn model_price(input: OptionEvaluation) -> f64 {
|
||||
let contract = input.contract;
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => black_scholes_price(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.carry,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
PricingModel::Black76 => black_76_price(
|
||||
contract.underlying,
|
||||
contract.strike,
|
||||
contract.rate,
|
||||
contract.time_to_expiry,
|
||||
input.volatility,
|
||||
contract.kind,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lower no-arbitrage bound for the option price.
|
||||
pub fn price_lower_bound(contract: OptionContract) -> f64 {
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => {
|
||||
let discount = (-contract.rate * contract.time_to_expiry).exp();
|
||||
let carry_discount = (-contract.carry * contract.time_to_expiry).exp();
|
||||
match contract.kind {
|
||||
OptionKind::Call => {
|
||||
(contract.underlying * carry_discount - contract.strike * discount).max(0.0)
|
||||
}
|
||||
OptionKind::Put => {
|
||||
(contract.strike * discount - contract.underlying * carry_discount).max(0.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
PricingModel::Black76 => {
|
||||
let discount = (-contract.rate * contract.time_to_expiry).exp();
|
||||
discount
|
||||
* match contract.kind {
|
||||
OptionKind::Call => (contract.underlying - contract.strike).max(0.0),
|
||||
OptionKind::Put => (contract.strike - contract.underlying).max(0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper no-arbitrage bound for the option price.
|
||||
pub fn price_upper_bound(contract: OptionContract) -> f64 {
|
||||
match contract.model {
|
||||
PricingModel::BlackScholes => match contract.kind {
|
||||
OptionKind::Call => {
|
||||
contract.underlying * (-contract.carry * contract.time_to_expiry).exp()
|
||||
}
|
||||
OptionKind::Put => contract.strike * (-contract.rate * contract.time_to_expiry).exp(),
|
||||
},
|
||||
PricingModel::Black76 => {
|
||||
let discount = (-contract.rate * contract.time_to_expiry).exp();
|
||||
discount
|
||||
* match contract.kind {
|
||||
OptionKind::Call => contract.underlying,
|
||||
OptionKind::Put => contract.strike,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{black_76_price, black_scholes_price};
|
||||
use crate::options::OptionKind;
|
||||
|
||||
#[test]
|
||||
fn black_scholes_prices_are_reasonable() {
|
||||
let call = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Call);
|
||||
let put = black_scholes_price(100.0, 100.0, 0.05, 0.0, 1.0, 0.2, OptionKind::Put);
|
||||
assert!((call - 10.4506).abs() < 1e-3);
|
||||
assert!((put - 5.5735).abs() < 1e-3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_76_prices_are_reasonable() {
|
||||
let call = black_76_price(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Call);
|
||||
let put = black_76_price(100.0, 100.0, 0.03, 1.0, 0.2, OptionKind::Put);
|
||||
assert!((call - 7.730_148).abs() < 1e-3);
|
||||
assert!((put - 7.730_148).abs() < 1e-3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Smile and surface analytics helpers.
|
||||
|
||||
use super::chain::atm_index;
|
||||
use super::greeks::model_greeks;
|
||||
use super::{ChainGreeksContext, OptionContract, OptionEvaluation, OptionKind, PricingModel};
|
||||
|
||||
/// Smile summary metrics.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SmileMetrics {
|
||||
pub atm_iv: f64,
|
||||
pub risk_reversal_25d: f64,
|
||||
pub butterfly_25d: f64,
|
||||
pub skew_slope: f64,
|
||||
pub convexity: f64,
|
||||
}
|
||||
|
||||
/// Linear interpolation helper.
|
||||
pub fn linear_interpolate(xs: &[f64], ys: &[f64], target: f64) -> f64 {
|
||||
if xs.len() != ys.len() || xs.is_empty() {
|
||||
return f64::NAN;
|
||||
}
|
||||
if target <= xs[0] {
|
||||
return ys[0];
|
||||
}
|
||||
for i in 1..xs.len() {
|
||||
if target <= xs[i] {
|
||||
let x0 = xs[i - 1];
|
||||
let x1 = xs[i];
|
||||
let y0 = ys[i - 1];
|
||||
let y1 = ys[i];
|
||||
let w = if x1 == x0 {
|
||||
0.0
|
||||
} else {
|
||||
(target - x0) / (x1 - x0)
|
||||
};
|
||||
return y0 + w * (y1 - y0);
|
||||
}
|
||||
}
|
||||
ys[ys.len() - 1]
|
||||
}
|
||||
|
||||
/// ATM implied volatility by nearest strike.
|
||||
pub fn atm_iv(strikes: &[f64], vols: &[f64], reference_price: f64) -> f64 {
|
||||
if strikes.len() != vols.len() || strikes.is_empty() || !reference_price.is_finite() {
|
||||
return f64::NAN;
|
||||
}
|
||||
atm_index(strikes, reference_price)
|
||||
.and_then(|idx| vols.get(idx).copied())
|
||||
.unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn regression_slope(xs: &[f64], ys: &[f64]) -> f64 {
|
||||
if xs.len() != ys.len() || xs.len() < 2 {
|
||||
return f64::NAN;
|
||||
}
|
||||
let n = xs.len() as f64;
|
||||
let mean_x = xs.iter().sum::<f64>() / n;
|
||||
let mean_y = ys.iter().sum::<f64>() / n;
|
||||
let mut cov = 0.0;
|
||||
let mut var = 0.0;
|
||||
for (&x, &y) in xs.iter().zip(ys.iter()) {
|
||||
cov += (x - mean_x) * (y - mean_y);
|
||||
var += (x - mean_x) * (x - mean_x);
|
||||
}
|
||||
if var == 0.0 {
|
||||
f64::NAN
|
||||
} else {
|
||||
cov / var
|
||||
}
|
||||
}
|
||||
|
||||
fn closest_delta_iv(
|
||||
strikes: &[f64],
|
||||
vols: &[f64],
|
||||
context: ChainGreeksContext,
|
||||
target_delta: f64,
|
||||
) -> f64 {
|
||||
let mut best_iv = f64::NAN;
|
||||
let mut best_distance = f64::INFINITY;
|
||||
for (&strike, &vol) in strikes.iter().zip(vols.iter()) {
|
||||
if !strike.is_finite() || !vol.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let delta = model_greeks(OptionEvaluation {
|
||||
contract: OptionContract {
|
||||
model: context.model,
|
||||
underlying: context.reference_price,
|
||||
strike,
|
||||
rate: context.rate,
|
||||
carry: context.carry,
|
||||
time_to_expiry: context.time_to_expiry,
|
||||
kind: context.kind,
|
||||
},
|
||||
volatility: vol,
|
||||
})
|
||||
.delta;
|
||||
if !delta.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let distance = (delta - target_delta).abs();
|
||||
if distance < best_distance {
|
||||
best_distance = distance;
|
||||
best_iv = vol;
|
||||
}
|
||||
}
|
||||
best_iv
|
||||
}
|
||||
|
||||
/// Smile metrics from a single expiry slice.
|
||||
pub fn smile_metrics(
|
||||
strikes: &[f64],
|
||||
vols: &[f64],
|
||||
reference_price: f64,
|
||||
rate: f64,
|
||||
carry: f64,
|
||||
time_to_expiry: f64,
|
||||
model: PricingModel,
|
||||
) -> SmileMetrics {
|
||||
if strikes.len() != vols.len() || strikes.len() < 3 || reference_price <= 0.0 {
|
||||
return SmileMetrics {
|
||||
atm_iv: f64::NAN,
|
||||
risk_reversal_25d: f64::NAN,
|
||||
butterfly_25d: f64::NAN,
|
||||
skew_slope: f64::NAN,
|
||||
convexity: f64::NAN,
|
||||
};
|
||||
}
|
||||
|
||||
let atm_idx = match atm_index(strikes, reference_price) {
|
||||
Some(idx) => idx,
|
||||
None => {
|
||||
return SmileMetrics {
|
||||
atm_iv: f64::NAN,
|
||||
risk_reversal_25d: f64::NAN,
|
||||
butterfly_25d: f64::NAN,
|
||||
skew_slope: f64::NAN,
|
||||
convexity: f64::NAN,
|
||||
}
|
||||
}
|
||||
};
|
||||
let atm_iv = vols[atm_idx];
|
||||
|
||||
let call_25 = closest_delta_iv(
|
||||
strikes,
|
||||
vols,
|
||||
ChainGreeksContext {
|
||||
model,
|
||||
reference_price,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind: OptionKind::Call,
|
||||
},
|
||||
0.25,
|
||||
);
|
||||
let put_25 = closest_delta_iv(
|
||||
strikes,
|
||||
vols,
|
||||
ChainGreeksContext {
|
||||
model,
|
||||
reference_price,
|
||||
rate,
|
||||
carry,
|
||||
time_to_expiry,
|
||||
kind: OptionKind::Put,
|
||||
},
|
||||
-0.25,
|
||||
);
|
||||
let risk_reversal_25d = call_25 - put_25;
|
||||
let butterfly_25d = 0.5 * (call_25 + put_25) - atm_iv;
|
||||
|
||||
let log_moneyness: Vec<f64> = strikes
|
||||
.iter()
|
||||
.map(|&k| (k / reference_price).ln())
|
||||
.collect();
|
||||
let skew_slope = regression_slope(&log_moneyness, vols);
|
||||
let convexity = if atm_idx > 0 && atm_idx + 1 < strikes.len() {
|
||||
let x0 = log_moneyness[atm_idx - 1];
|
||||
let x1 = log_moneyness[atm_idx];
|
||||
let x2 = log_moneyness[atm_idx + 1];
|
||||
let y0 = vols[atm_idx - 1];
|
||||
let y1 = vols[atm_idx];
|
||||
let y2 = vols[atm_idx + 1];
|
||||
let left = if x1 == x0 { 0.0 } else { (y1 - y0) / (x1 - x0) };
|
||||
let right = if x2 == x1 { 0.0 } else { (y2 - y1) / (x2 - x1) };
|
||||
right - left
|
||||
} else {
|
||||
f64::NAN
|
||||
};
|
||||
|
||||
SmileMetrics {
|
||||
atm_iv,
|
||||
risk_reversal_25d,
|
||||
butterfly_25d,
|
||||
skew_slope,
|
||||
convexity,
|
||||
}
|
||||
}
|
||||
|
||||
/// Term-structure slope from (tenor, atm_iv) points.
|
||||
pub fn term_structure_slope(tenors: &[f64], atm_ivs: &[f64]) -> f64 {
|
||||
regression_slope(tenors, atm_ivs)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{atm_iv, smile_metrics, term_structure_slope};
|
||||
use crate::options::PricingModel;
|
||||
|
||||
#[test]
|
||||
fn atm_selection_works() {
|
||||
let strikes = [90.0, 100.0, 110.0];
|
||||
let vols = [0.24, 0.20, 0.22];
|
||||
assert!((atm_iv(&strikes, &vols, 102.0) - 0.20).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smile_metrics_are_finite() {
|
||||
let strikes = [80.0, 90.0, 100.0, 110.0, 120.0];
|
||||
let vols = [0.30, 0.25, 0.20, 0.22, 0.27];
|
||||
let metrics = smile_metrics(
|
||||
&strikes,
|
||||
&vols,
|
||||
100.0,
|
||||
0.02,
|
||||
0.0,
|
||||
0.5,
|
||||
PricingModel::BlackScholes,
|
||||
);
|
||||
assert!(metrics.atm_iv.is_finite());
|
||||
assert!(metrics.skew_slope.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn term_slope_is_reasonable() {
|
||||
let tenors = [0.1, 0.5, 1.0];
|
||||
let vols = [0.18, 0.20, 0.22];
|
||||
assert!(term_structure_slope(&tenors, &vols) > 0.0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user