use std::{cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc};
use ahash::AHashMap;
use nautilus_core::UnixNanos;
use nautilus_model::{
data::greeks::{
GreeksData, OptionGreekValues, OptionValuationError, PortfolioGreeks,
black_scholes_greeks_checked, check_option_inputs, imply_vol_and_greeks_checked,
},
enums::{AssetClass, InstrumentClass, OptionKind, PositionSide, PriceType},
identifiers::{InstrumentId, StrategyId, Venue},
instruments::{Instrument, any::InstrumentAny},
position::Position,
types::Price,
};
use ustr::Ustr;
use crate::{
actor::DataActorNative,
cache::{Cache, refs::PositionRef},
clock::Clock,
msgbus,
msgbus::TypedHandler,
};
pub type GreeksFilter = Box<dyn Fn(&GreeksData) -> bool>;
#[derive(Clone)]
pub enum GreeksFilterCallback {
Function(fn(&GreeksData) -> bool),
Closure(std::rc::Rc<dyn Fn(&GreeksData) -> bool>),
}
impl GreeksFilterCallback {
pub fn from_fn(f: fn(&GreeksData) -> bool) -> Self {
Self::Function(f)
}
pub fn from_closure<F>(f: F) -> Self
where
F: Fn(&GreeksData) -> bool + 'static,
{
Self::Closure(std::rc::Rc::new(f))
}
pub fn call(&self, data: &GreeksData) -> bool {
match self {
Self::Function(f) => f(data),
Self::Closure(f) => f(data),
}
}
pub fn to_greeks_filter(self) -> GreeksFilter {
match self {
Self::Function(f) => Box::new(f),
Self::Closure(f) => {
let f_clone = f.clone();
Box::new(move |data| f_clone(data))
}
}
}
}
impl Debug for GreeksFilterCallback {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Function(_) => f.write_str("GreeksFilterCallback::Function"),
Self::Closure(_) => f.write_str("GreeksFilterCallback::Closure"),
}
}
}
#[derive(Debug, bon::Builder)]
pub struct InstrumentGreeksParams {
pub instrument_id: InstrumentId,
#[builder(default = 0.0425)]
pub flat_interest_rate: f64,
pub flat_dividend_yield: Option<f64>,
#[builder(default = 0.0)]
pub spot_shock: f64,
#[builder(default = 0.0)]
pub vol_shock: f64,
#[builder(default = 0.0)]
pub time_to_expiry_shock: f64,
#[builder(default = false)]
pub use_cached_greeks: bool,
#[builder(default = false)]
pub update_vol: bool,
#[builder(default = false)]
pub cache_greeks: bool,
#[builder(default = false)]
pub publish_greeks: bool,
pub ts_event: Option<UnixNanos>,
pub position: Option<Position>,
#[builder(default = false)]
pub percent_greeks: bool,
pub index_instrument_id: Option<InstrumentId>,
pub beta_weights: Option<HashMap<InstrumentId, f64>>,
pub vega_time_weight_base: Option<i32>,
pub vol_index_instrument_id: Option<InstrumentId>,
pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
}
impl InstrumentGreeksParams {
pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<GreeksData> {
calculator.instrument_greeks(
self.instrument_id,
Some(self.flat_interest_rate),
self.flat_dividend_yield,
Some(self.spot_shock),
Some(self.vol_shock),
Some(self.time_to_expiry_shock),
Some(self.use_cached_greeks),
Some(self.update_vol),
Some(self.cache_greeks),
Some(self.publish_greeks),
self.ts_event,
self.position.clone(),
Some(self.percent_greeks),
self.index_instrument_id,
self.beta_weights.as_ref(),
self.vega_time_weight_base,
self.vol_index_instrument_id,
self.vol_beta_weights.as_ref(),
)
}
}
#[derive(bon::Builder)]
pub struct PortfolioGreeksParams {
pub underlyings: Option<Vec<String>>,
pub venue: Option<Venue>,
pub instrument_id: Option<InstrumentId>,
pub strategy_id: Option<StrategyId>,
pub side: Option<PositionSide>,
#[builder(default = 0.0425)]
pub flat_interest_rate: f64,
pub flat_dividend_yield: Option<f64>,
#[builder(default = 0.0)]
pub spot_shock: f64,
#[builder(default = 0.0)]
pub vol_shock: f64,
#[builder(default = 0.0)]
pub time_to_expiry_shock: f64,
#[builder(default = false)]
pub use_cached_greeks: bool,
#[builder(default = false)]
pub update_vol: bool,
#[builder(default = false)]
pub cache_greeks: bool,
#[builder(default = false)]
pub publish_greeks: bool,
#[builder(default = false)]
pub percent_greeks: bool,
pub index_instrument_id: Option<InstrumentId>,
pub beta_weights: Option<HashMap<InstrumentId, f64>>,
pub greeks_filter: Option<GreeksFilterCallback>,
pub vega_time_weight_base: Option<i32>,
pub vol_index_instrument_id: Option<InstrumentId>,
pub vol_beta_weights: Option<HashMap<InstrumentId, f64>>,
}
impl Debug for PortfolioGreeksParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct(stringify!(PortfolioGreeksParams))
.field("underlyings", &self.underlyings)
.field("venue", &self.venue)
.field("instrument_id", &self.instrument_id)
.field("strategy_id", &self.strategy_id)
.field("side", &self.side)
.field("flat_interest_rate", &self.flat_interest_rate)
.field("flat_dividend_yield", &self.flat_dividend_yield)
.field("spot_shock", &self.spot_shock)
.field("vol_shock", &self.vol_shock)
.field("time_to_expiry_shock", &self.time_to_expiry_shock)
.field("use_cached_greeks", &self.use_cached_greeks)
.field("update_vol", &self.update_vol)
.field("cache_greeks", &self.cache_greeks)
.field("publish_greeks", &self.publish_greeks)
.field("percent_greeks", &self.percent_greeks)
.field("index_instrument_id", &self.index_instrument_id)
.field("beta_weights", &self.beta_weights)
.field("greeks_filter", &self.greeks_filter)
.field("vega_time_weight_base", &self.vega_time_weight_base)
.field("vol_index_instrument_id", &self.vol_index_instrument_id)
.field("vol_beta_weights", &self.vol_beta_weights)
.finish()
}
}
impl PortfolioGreeksParams {
pub fn calculate(&self, calculator: &GreeksCalculator) -> anyhow::Result<PortfolioGreeks> {
let greeks_filter = self
.greeks_filter
.as_ref()
.map(|f| f.clone().to_greeks_filter());
calculator.portfolio_greeks(
self.underlyings.as_deref(),
self.venue,
self.instrument_id,
self.strategy_id,
self.side,
Some(self.flat_interest_rate),
self.flat_dividend_yield,
Some(self.spot_shock),
Some(self.vol_shock),
Some(self.time_to_expiry_shock),
Some(self.use_cached_greeks),
Some(self.update_vol),
Some(self.cache_greeks),
Some(self.publish_greeks),
Some(self.percent_greeks),
self.index_instrument_id,
self.beta_weights.as_ref(),
greeks_filter.as_ref(),
self.vega_time_weight_base,
self.vol_index_instrument_id,
self.vol_beta_weights.as_ref(),
)
}
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct GreeksCalculator {
cache: Rc<RefCell<Cache>>,
clock: Rc<RefCell<dyn Clock>>,
cached_futures_spreads: RefCell<AHashMap<InstrumentId, (InstrumentId, Price)>>,
cross_venue_underlyings: RefCell<AHashMap<Ustr, InstrumentId>>,
}
impl GreeksCalculator {
pub fn new(cache: Rc<RefCell<Cache>>, clock: Rc<RefCell<dyn Clock>>) -> Self {
Self {
cache,
clock,
cached_futures_spreads: RefCell::new(AHashMap::new()),
cross_venue_underlyings: RefCell::new(AHashMap::new()),
}
}
pub fn from_actor(actor: &impl DataActorNative) -> Self {
Self::new(actor.cache_rc(), actor.clock_rc())
}
#[expect(clippy::too_many_arguments)]
pub fn instrument_greeks(
&self,
instrument_id: InstrumentId,
flat_interest_rate: Option<f64>,
flat_dividend_yield: Option<f64>,
spot_shock: Option<f64>,
vol_shock: Option<f64>,
time_to_expiry_shock: Option<f64>,
use_cached_greeks: Option<bool>,
update_vol: Option<bool>,
cache_greeks: Option<bool>,
publish_greeks: Option<bool>,
ts_event: Option<UnixNanos>,
position: Option<Position>,
percent_greeks: Option<bool>,
index_instrument_id: Option<InstrumentId>,
beta_weights: Option<&HashMap<InstrumentId, f64>>,
vega_time_weight_base: Option<i32>,
vol_index_instrument_id: Option<InstrumentId>,
vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
) -> anyhow::Result<GreeksData> {
let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
let spot_shock = spot_shock.unwrap_or(0.0);
let vol_shock = vol_shock.unwrap_or(0.0);
let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
let use_cached_greeks = use_cached_greeks.unwrap_or(false);
let update_vol = update_vol.unwrap_or(false);
let cache_greeks = cache_greeks.unwrap_or(false);
let publish_greeks = publish_greeks.unwrap_or(false);
let ts_event = ts_event.unwrap_or_default();
let percent_greeks = percent_greeks.unwrap_or(false);
let instrument = {
let cache = self.cache.borrow();
cache.try_instrument(&instrument_id)?.clone()
};
if instrument.instrument_class() == InstrumentClass::OptionSpread {
return Err(OptionValuationError::UnsupportedSpread(instrument_id).into());
}
if instrument.instrument_class() != InstrumentClass::Option {
return self.calculate_non_option_greeks(
&instrument,
instrument_id,
spot_shock,
ts_event,
position,
percent_greeks,
index_instrument_id,
beta_weights,
);
}
let ts_event = self.check_option_valuation(
&instrument,
ts_event,
spot_shock,
vol_shock,
time_to_expiry_shock,
)?;
let has_shocks = spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0;
if use_cached_greeks
&& !has_shocks
&& let Some(mut cached_greeks) = self.cache.borrow().greeks(&instrument_id)
{
check_option_greeks_data(&cached_greeks)?;
if let Some(pos) = position {
cached_greeks.pnl = cached_greeks.price - pos.avg_px_open.as_f64();
}
check_option_greeks_data(&cached_greeks)?;
return Ok(cached_greeks);
}
let underlying_instrument_id =
self.resolve_underlying_instrument_id(&instrument, instrument_id)?;
let (greeks_data, db_dr, is_fresh) = self.calculate_option_greeks(
&instrument,
instrument_id,
underlying_instrument_id,
flat_interest_rate,
flat_dividend_yield,
use_cached_greeks,
update_vol,
ts_event,
percent_greeks,
index_instrument_id,
beta_weights,
vega_time_weight_base,
vol_index_instrument_id,
vol_beta_weights,
)?;
let shocked_greeks = if has_shocks {
Some(self.apply_option_greeks_shocks(
&greeks_data,
underlying_instrument_id,
db_dr,
spot_shock,
vol_shock,
time_to_expiry_shock,
percent_greeks,
index_instrument_id,
beta_weights,
vega_time_weight_base,
vol_index_instrument_id,
vol_beta_weights,
)?)
} else {
None
};
let final_greeks = shocked_greeks.as_ref().unwrap_or(&greeks_data);
check_option_greeks_data(final_greeks)?;
let pnl = position.map_or(final_greeks.pnl, |pos| {
final_greeks.price - pos.avg_px_open.as_f64()
});
check_greeks_values(pnl, final_greeks.price, final_greeks.greeks)?;
if is_fresh {
self.store_option_greeks(
&greeks_data,
underlying_instrument_id.symbol.as_str(),
cache_greeks,
publish_greeks,
);
}
let mut greeks_data = shocked_greeks.unwrap_or(greeks_data);
greeks_data.pnl = pnl;
Ok(greeks_data)
}
fn check_option_valuation(
&self,
instrument: &InstrumentAny,
ts_event: UnixNanos,
spot_shock: f64,
vol_shock: f64,
time_to_expiry_shock: f64,
) -> Result<UnixNanos, OptionValuationError> {
let valuation_time = if ts_event == UnixNanos::default() {
self.clock.borrow().timestamp_ns()
} else {
ts_event
};
let expiration = instrument
.expiration_ns()
.ok_or(OptionValuationError::InvalidInput("expiration"))?;
if valuation_time >= expiration {
return Err(OptionValuationError::Expired(instrument.id()));
}
for (name, value) in [
("spot shock", spot_shock),
("volatility shock", vol_shock),
("time to expiry shock", time_to_expiry_shock),
] {
if !value.is_finite() {
return Err(OptionValuationError::InvalidInput(name));
}
}
Ok(valuation_time)
}
fn store_option_greeks(&self, data: &GreeksData, underlying: &str, cache: bool, publish: bool) {
if cache && let Err(e) = self.cache.borrow_mut().add_greeks(data.clone()) {
log::error!("Failed to add GreeksData to cache: {e}");
}
if publish {
let topic = greeks_topic(underlying, &data.instrument_id.to_string()).into();
msgbus::publish_greeks(topic, data);
}
}
fn resolve_underlying_instrument_id(
&self,
instrument: &InstrumentAny,
instrument_id: InstrumentId,
) -> anyhow::Result<InstrumentId> {
let Some(underlying) = instrument.underlying() else {
anyhow::bail!("Instrument {instrument_id} has no underlying identifier");
};
let same_venue_id = InstrumentId::from(format!("{underlying}.{}", instrument_id.venue));
let cache = self.cache.borrow();
let mut cross_venue_underlyings = self.cross_venue_underlyings.borrow_mut();
let remembered_id = cross_venue_underlyings
.get(&underlying)
.copied()
.filter(|id| cache.instrument(id).is_some());
if remembered_id.is_none() {
cross_venue_underlyings.remove(&underlying);
}
if cache.instrument(&same_venue_id).is_some()
|| self
.cached_futures_spreads
.borrow()
.contains_key(&same_venue_id)
{
return Ok(same_venue_id);
}
if let Some(underlying_id) = remembered_id {
return Ok(underlying_id);
}
let mut matches: Vec<InstrumentId> = cache
.instrument_ids(None)
.into_iter()
.filter(|id| id.symbol.inner() == underlying)
.copied()
.collect();
match matches.len() {
0 => Ok(same_venue_id),
1 => {
cross_venue_underlyings.insert(underlying, matches[0]);
Ok(matches[0])
}
_ => {
matches.sort();
let names = matches
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!("Ambiguous underlying for option {instrument_id}: {names}")
}
}
}
#[expect(clippy::too_many_arguments)]
fn calculate_non_option_greeks(
&self,
instrument: &InstrumentAny,
instrument_id: InstrumentId,
spot_shock: f64,
ts_event: UnixNanos,
position: Option<Position>,
percent_greeks: bool,
index_instrument_id: Option<InstrumentId>,
beta_weights: Option<&HashMap<InstrumentId, f64>>,
) -> anyhow::Result<GreeksData> {
let multiplier = instrument.multiplier();
let underlying_instrument_id = instrument.id();
let underlying_price = self
.get_price(&underlying_instrument_id)
.ok_or_else(|| anyhow::anyhow!("No price available for {underlying_instrument_id}"))?;
let (delta, _, _) = self.modify_greeks(
1.0,
0.0,
underlying_instrument_id,
underlying_price + spot_shock,
underlying_price,
percent_greeks,
index_instrument_id,
beta_weights,
0.0,
0.0,
0,
None,
0.0,
None,
None,
None,
None,
)?;
let mut greeks_data =
GreeksData::from_delta(instrument_id, delta, multiplier.as_f64(), ts_event);
if let Some(pos) = position {
greeks_data.pnl = (underlying_price + spot_shock) - pos.avg_px_open.as_f64();
greeks_data.price = greeks_data.pnl;
}
Ok(greeks_data)
}
#[expect(clippy::too_many_arguments)]
fn calculate_option_greeks(
&self,
instrument: &InstrumentAny,
instrument_id: InstrumentId,
underlying_instrument_id: InstrumentId,
flat_interest_rate: f64,
flat_dividend_yield: Option<f64>,
use_cached_greeks: bool,
update_vol: bool,
ts_event: UnixNanos,
percent_greeks: bool,
index_instrument_id: Option<InstrumentId>,
beta_weights: Option<&HashMap<InstrumentId, f64>>,
vega_time_weight_base: Option<i32>,
vol_index_instrument_id: Option<InstrumentId>,
vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
) -> anyhow::Result<(GreeksData, f64, bool)> {
if use_cached_greeks {
let cache = self.cache.borrow();
if let Some(cached_greeks) = cache.greeks(&instrument_id) {
check_option_greeks_data(&cached_greeks)?;
let db_dr = f64::from(u8::from(
flat_dividend_yield.is_some()
|| cache
.yield_curve(&underlying_instrument_id.to_string())
.is_some(),
));
return Ok((cached_greeks, db_dr, false));
}
}
let utc_now_ns = if ts_event == UnixNanos::default() {
self.clock.borrow().timestamp_ns()
} else {
ts_event
};
let utc_now = utc_now_ns.to_datetime_utc();
let expiry_utc = instrument
.expiration_ns()
.map(|ns| ns.to_datetime_utc())
.unwrap_or_default();
let expiry_int = expiry_utc
.strftime("%Y%m%d")
.to_string()
.parse::<i32>()
.unwrap_or(0);
let raw_days = utc_now.duration_until(expiry_utc).as_hours() / 24;
let expiry_in_days = raw_days.max(1) as i32;
let expiry_in_years = expiry_in_days as f64 / 365.25;
let currency = instrument.quote_currency().code.to_string();
let cache = self.cache.borrow();
let yield_curve = cache.yield_curve(¤cy);
let interest_rate = match yield_curve {
Some(yield_curve) => yield_curve(expiry_in_years),
None => flat_interest_rate,
};
let dividend_curve = cache.yield_curve(&underlying_instrument_id.to_string());
drop(cache);
let (cost_of_carry, db_dr) = if let Some(dividend_curve) = dividend_curve {
(interest_rate - dividend_curve(expiry_in_years), 1.0)
} else if let Some(div_yield) = flat_dividend_yield {
(interest_rate - div_yield, 1.0)
} else {
(0.0, 0.0)
};
let multiplier = instrument.multiplier();
let is_call = instrument.option_kind().unwrap_or(OptionKind::Call) == OptionKind::Call;
let strike = instrument.strike_price().unwrap_or_default().as_f64();
let option_price = self
.get_price(&instrument_id)
.ok_or_else(|| anyhow::anyhow!("No price available for {instrument_id}"))?;
let underlying_price = self.get_underlying_price(&underlying_instrument_id)?;
if let Some(vol_index_id) = vol_index_instrument_id {
self.get_price(&vol_index_id)
.ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?;
}
let seed_vol = if update_vol {
self.cache
.borrow()
.greeks(&instrument_id)
.map(|greeks| greeks.vol)
} else {
None
};
let greeks = imply_vol_and_greeks_checked(
underlying_price,
interest_rate,
cost_of_carry,
is_call,
strike,
expiry_in_years,
option_price,
db_dr,
seed_vol,
)?;
let (delta, gamma, vega) = self.modify_greeks(
greeks.delta,
greeks.gamma,
underlying_instrument_id,
underlying_price,
underlying_price,
percent_greeks,
index_instrument_id,
beta_weights,
greeks.vega,
greeks.vol,
expiry_in_days,
vega_time_weight_base,
greeks.vol,
vol_index_instrument_id,
vol_beta_weights,
None,
None,
)?;
let greeks_data = GreeksData::new(
instrument_id,
is_call,
strike,
expiry_int,
expiry_in_days,
expiry_in_years,
multiplier.as_f64(),
1.0,
underlying_price,
interest_rate,
cost_of_carry,
greeks.vol,
0.0,
greeks.price,
OptionGreekValues {
delta,
gamma,
vega,
theta: greeks.theta,
rho: greeks.rho,
},
greeks.itm_prob,
utc_now_ns,
utc_now_ns,
);
check_option_greeks_data(&greeks_data)?;
Ok((greeks_data, db_dr, true))
}
#[expect(clippy::too_many_arguments)]
fn apply_option_greeks_shocks(
&self,
greeks_data: &GreeksData,
underlying_instrument_id: InstrumentId,
db_dr: f64,
spot_shock: f64,
vol_shock: f64,
time_to_expiry_shock: f64,
percent_greeks: bool,
index_instrument_id: Option<InstrumentId>,
beta_weights: Option<&HashMap<InstrumentId, f64>>,
vega_time_weight_base: Option<i32>,
vol_index_instrument_id: Option<InstrumentId>,
vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
) -> anyhow::Result<GreeksData> {
let underlying_price = greeks_data.underlying_price;
let shocked_underlying_price = underlying_price + spot_shock;
let shocked_vol = greeks_data.vol + vol_shock;
let shocked_time_to_expiry = greeks_data.expiry_in_years - time_to_expiry_shock;
let shocked_expiry_in_days = (shocked_time_to_expiry * 365.25) as i32;
let greeks = black_scholes_greeks_checked(
shocked_underlying_price,
greeks_data.interest_rate,
greeks_data.cost_of_carry,
shocked_vol,
greeks_data.is_call,
greeks_data.strike,
shocked_time_to_expiry,
db_dr,
)?;
let (delta, gamma, vega) = self.modify_greeks(
greeks.delta,
greeks.gamma,
underlying_instrument_id,
shocked_underlying_price,
underlying_price,
percent_greeks,
index_instrument_id,
beta_weights,
greeks.vega,
shocked_vol,
shocked_expiry_in_days,
vega_time_weight_base,
greeks_data.vol,
vol_index_instrument_id,
vol_beta_weights,
None,
None,
)?;
Ok(GreeksData::new(
greeks_data.instrument_id,
greeks_data.is_call,
greeks_data.strike,
greeks_data.expiry,
shocked_expiry_in_days,
shocked_time_to_expiry,
greeks_data.multiplier,
greeks_data.quantity,
shocked_underlying_price,
greeks_data.interest_rate,
greeks_data.cost_of_carry,
shocked_vol,
0.0,
greeks.price,
OptionGreekValues {
delta,
gamma,
vega,
theta: greeks.theta,
rho: greeks.rho,
},
greeks.itm_prob,
greeks_data.ts_event,
greeks_data.ts_event,
))
}
fn get_underlying_price(&self, underlying_instrument_id: &InstrumentId) -> anyhow::Result<f64> {
if let Some(underlying_price) = self.get_price(underlying_instrument_id) {
return Ok(underlying_price);
}
let is_future_or_absent = {
let cache = self.cache.borrow();
cache
.instrument(underlying_instrument_id)
.is_none_or(|inst| inst.instrument_class() == InstrumentClass::Future)
};
if is_future_or_absent
&& let Some(underlying_price) =
self.get_cached_futures_spread_price(*underlying_instrument_id)
{
return Ok(underlying_price.as_f64());
}
anyhow::bail!("No price available for {underlying_instrument_id}")
}
#[expect(clippy::too_many_arguments)]
pub fn modify_greeks(
&self,
delta_input: f64,
gamma_input: f64,
underlying_instrument_id: InstrumentId,
underlying_price: f64,
unshocked_underlying_price: f64,
percent_greeks: bool,
index_instrument_id: Option<InstrumentId>,
beta_weights: Option<&HashMap<InstrumentId, f64>>,
vega_input: f64,
vol: f64,
expiry_in_days: i32,
vega_time_weight_base: Option<i32>,
unshocked_vol: f64,
vol_index_instrument_id: Option<InstrumentId>,
vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
index_price: Option<f64>,
vol_index_price: Option<f64>,
) -> anyhow::Result<(f64, f64, f64)> {
let mut delta = delta_input;
let mut gamma = gamma_input;
let mut vega = vega_input;
let mut used_index_price = index_price
.or_else(|| index_instrument_id.and_then(|index_id| self.get_price(&index_id)));
let mut used_index_vol = vol_index_price;
if used_index_vol.is_none()
&& let Some(vol_index_id) = vol_index_instrument_id
{
used_index_vol = Some(
self.get_price(&vol_index_id)
.ok_or_else(|| anyhow::anyhow!("No price available for {vol_index_id}"))?,
);
}
if used_index_price.is_some() {
let mut beta = 1.0;
if let Some(weights) = beta_weights
&& let Some(&weight) = weights.get(&underlying_instrument_id)
{
beta = weight;
}
if let Some(ref mut idx_price) = used_index_price {
#[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
if underlying_price != unshocked_underlying_price {
*idx_price += 1.0 / beta
* (*idx_price / unshocked_underlying_price)
* (underlying_price - unshocked_underlying_price);
}
let delta_multiplier = beta * underlying_price / *idx_price;
delta *= delta_multiplier;
gamma *= delta_multiplier.powi(2);
}
}
if used_index_vol.is_some() {
let mut vega_beta = 1.0;
let used_vol = if unshocked_vol == 0.0 {
vol
} else {
unshocked_vol
};
if let Some(weights) = vol_beta_weights
&& let Some(&weight) = weights.get(&underlying_instrument_id)
{
vega_beta = weight;
}
if let Some(ref mut idx_vol) = used_index_vol {
*idx_vol *= 0.01;
#[expect(clippy::float_cmp, reason = "exact-equality baseline check")]
if vol != used_vol && used_vol != 0.0 {
*idx_vol += 1.0 / vega_beta * (*idx_vol / used_vol) * (vol - used_vol);
}
if *idx_vol != 0.0 {
vega *= vega_beta * vol / *idx_vol;
}
}
}
if percent_greeks {
if let Some(idx_price) = used_index_price {
delta *= idx_price / 100.0;
gamma *= (idx_price / 100.0).powi(2);
} else {
delta *= underlying_price / 100.0;
gamma *= (underlying_price / 100.0).powi(2);
}
if let Some(idx_vol) = used_index_vol {
vega *= idx_vol / 100.0;
} else {
vega *= vol / 100.0;
}
}
if let Some(time_base) = vega_time_weight_base
&& expiry_in_days > 0
{
let time_weight = (time_base as f64 / expiry_in_days as f64).sqrt();
vega *= time_weight;
}
Ok((delta, gamma, vega))
}
#[expect(clippy::too_many_arguments)]
pub fn portfolio_greeks(
&self,
underlyings: Option<&[String]>,
venue: Option<Venue>,
instrument_id: Option<InstrumentId>,
strategy_id: Option<StrategyId>,
side: Option<PositionSide>,
flat_interest_rate: Option<f64>,
flat_dividend_yield: Option<f64>,
spot_shock: Option<f64>,
vol_shock: Option<f64>,
time_to_expiry_shock: Option<f64>,
use_cached_greeks: Option<bool>,
update_vol: Option<bool>,
cache_greeks: Option<bool>,
publish_greeks: Option<bool>,
percent_greeks: Option<bool>,
index_instrument_id: Option<InstrumentId>,
beta_weights: Option<&HashMap<InstrumentId, f64>>,
greeks_filter: Option<&GreeksFilter>,
vega_time_weight_base: Option<i32>,
vol_index_instrument_id: Option<InstrumentId>,
vol_beta_weights: Option<&HashMap<InstrumentId, f64>>,
) -> anyhow::Result<PortfolioGreeks> {
let ts_event = self.clock.borrow().timestamp_ns();
let mut portfolio_greeks =
PortfolioGreeks::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ts_event, ts_event);
let flat_interest_rate = flat_interest_rate.unwrap_or(0.0425);
let spot_shock = spot_shock.unwrap_or(0.0);
let vol_shock = vol_shock.unwrap_or(0.0);
let time_to_expiry_shock = time_to_expiry_shock.unwrap_or(0.0);
let use_cached_greeks = use_cached_greeks.unwrap_or(false);
let update_vol = update_vol.unwrap_or(false);
let cache_greeks = cache_greeks.unwrap_or(false);
let publish_greeks = publish_greeks.unwrap_or(false);
let percent_greeks = percent_greeks.unwrap_or(false);
let open_positions: Vec<Position> = {
let cache = self.cache.borrow();
cache
.positions_open(
venue.as_ref(),
instrument_id.as_ref(),
strategy_id.as_ref(),
None, side,
)
.iter()
.map(PositionRef::cloned)
.collect()
};
for position in open_positions {
let position_instrument_id = position.instrument_id;
if let Some(underlyings_list) = underlyings {
let mut skip_position = true;
for underlying in underlyings_list {
if position_instrument_id
.symbol
.as_str()
.starts_with(underlying)
{
skip_position = false;
break;
}
}
if skip_position {
continue;
}
}
let quantity = position.signed_qty.as_f64();
let instrument_greeks = self.instrument_greeks(
position_instrument_id,
Some(flat_interest_rate),
flat_dividend_yield,
Some(spot_shock),
Some(vol_shock),
Some(time_to_expiry_shock),
Some(use_cached_greeks),
Some(update_vol),
Some(cache_greeks),
Some(publish_greeks),
Some(ts_event),
Some(position),
Some(percent_greeks),
index_instrument_id,
beta_weights,
vega_time_weight_base,
vol_index_instrument_id,
vol_beta_weights,
)?;
let position_portfolio_greeks = quantity * &instrument_greeks.to_portfolio_greeks();
let position_greeks = GreeksData {
pnl: position_portfolio_greeks.pnl,
price: position_portfolio_greeks.price,
greeks: position_portfolio_greeks.greeks,
..instrument_greeks
};
if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
portfolio_greeks = portfolio_greeks + position_portfolio_greeks;
check_greeks_values(
portfolio_greeks.pnl,
portfolio_greeks.price,
portfolio_greeks.greeks,
)?;
}
}
Ok(portfolio_greeks)
}
pub fn cache_futures_spread(
&self,
call_instrument_id: InstrumentId,
put_instrument_id: InstrumentId,
futures_instrument_id: InstrumentId,
) -> anyhow::Result<Price> {
let cache = self.cache.borrow();
let call_instrument = cache.instrument(&call_instrument_id).cloned();
let put_instrument = cache.instrument(&put_instrument_id).cloned();
let reference_future_instrument = cache.instrument(&futures_instrument_id).cloned();
drop(cache);
let Some(call_instrument) = call_instrument else {
anyhow::bail!(
"Cannot cache futures spread: missing option instrument {call_instrument_id}"
);
};
let Some(put_instrument) = put_instrument else {
anyhow::bail!(
"Cannot cache futures spread: missing option instrument {put_instrument_id}"
);
};
let Some(reference_future_instrument) = reference_future_instrument else {
anyhow::bail!(
"Cannot cache futures spread: no reference futures instrument for {futures_instrument_id}"
);
};
if call_instrument.instrument_class() != InstrumentClass::Option
|| put_instrument.instrument_class() != InstrumentClass::Option
{
anyhow::bail!(
"Cannot cache futures spread: non-option instruments provided call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
);
}
if call_instrument.option_kind() != Some(OptionKind::Call)
|| put_instrument.option_kind() != Some(OptionKind::Put)
{
anyhow::bail!(
"Cannot cache futures spread: expected call/put pair call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
);
}
let Some(call_underlying) = call_instrument.underlying() else {
anyhow::bail!(
"Cannot cache futures spread: missing call underlying for {call_instrument_id}"
);
};
let Some(put_underlying) = put_instrument.underlying() else {
anyhow::bail!(
"Cannot cache futures spread: missing put underlying for {put_instrument_id}"
);
};
if call_underlying != put_underlying {
anyhow::bail!(
"Cannot cache futures spread: option underlyings differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
);
}
if call_instrument.strike_price() != put_instrument.strike_price() {
anyhow::bail!(
"Cannot cache futures spread: strike prices differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
);
}
if call_instrument.expiration_ns() != put_instrument.expiration_ns() {
anyhow::bail!(
"Cannot cache futures spread: expiration dates differ call_instrument_id={call_instrument_id} put_instrument_id={put_instrument_id}"
);
}
let reference_future_price = self.get_price_object(&futures_instrument_id).ok_or_else(|| {
anyhow::anyhow!(
"Cannot cache futures spread: no reference futures price for {futures_instrument_id}"
)
})?;
let call_price = self.get_price(&call_instrument_id).ok_or_else(|| {
anyhow::anyhow!(
"Cannot cache futures spread: missing option price for {call_instrument_id}"
)
})?;
let put_price = self.get_price(&put_instrument_id).ok_or_else(|| {
anyhow::anyhow!(
"Cannot cache futures spread: missing option price for {put_instrument_id}"
)
})?;
let underlying_instrument_id =
InstrumentId::from(format!("{call_underlying}.{}", call_instrument_id.venue));
{
let cache = self.cache.borrow();
if let Some(underlying) = cache.instrument(&underlying_instrument_id)
&& underlying.instrument_class() != InstrumentClass::Future
{
anyhow::bail!(
"Cannot cache futures spread: underlying {underlying_instrument_id} is not a futures contract"
);
}
}
let implied_future_price =
self.calculate_implied_future_price(&call_instrument, call_price, put_price);
let spread = implied_future_price - reference_future_price.as_f64();
let spread_price = reference_future_instrument.make_price(spread);
self.cached_futures_spreads.borrow_mut().insert(
underlying_instrument_id,
(futures_instrument_id, spread_price),
);
Ok(reference_future_price + spread_price)
}
fn calculate_implied_future_price(
&self,
call_instrument: &InstrumentAny,
call_price: f64,
put_price: f64,
) -> f64 {
let expiry_utc = call_instrument
.expiration_ns()
.map(|ns| ns.to_datetime_utc())
.unwrap_or_default();
let now = self.clock.borrow().timestamp_ns().to_datetime_utc();
let expiry_in_days = (now.duration_until(expiry_utc).as_hours() / 24).max(1) as i32;
let expiry_in_years = expiry_in_days as f64 / 365.25;
let currency = call_instrument.quote_currency().code.to_string();
let interest_rate = self
.cache
.borrow()
.yield_curve(¤cy)
.map_or(0.0425, |yield_curve| yield_curve(expiry_in_years));
let strike = call_instrument.strike_price().unwrap_or_default().as_f64();
strike + (interest_rate * expiry_in_years).exp() * (call_price - put_price)
}
#[must_use]
pub fn get_cached_futures_spread_price(
&self,
underlying_instrument_id: InstrumentId,
) -> Option<Price> {
let (futures_instrument_id, spread) = self
.cached_futures_spreads
.borrow()
.get(&underlying_instrument_id)
.copied()?;
let reference_future_price = self.get_price_object(&futures_instrument_id)?;
Some(reference_future_price + spread)
}
fn get_price_object(&self, instrument_id: &InstrumentId) -> Option<Price> {
let cache = self.cache.borrow();
let price = cache
.price(instrument_id, PriceType::Mid)
.or_else(|| cache.price(instrument_id, PriceType::Last));
if let Some(instrument) = cache.instrument(instrument_id)
&& instrument.asset_class() == AssetClass::Index
{
if instrument.instrument_class() == InstrumentClass::Future && price.is_some() {
return price;
}
if let Some(index_price) = cache.index_price(instrument_id) {
return Some(index_price.value);
}
}
price
}
fn get_price(&self, instrument_id: &InstrumentId) -> Option<f64> {
self.get_price_object(instrument_id)
.map(|price| price.as_f64())
}
pub fn subscribe_greeks<F>(&self, underlying: &str, handler: Option<F>)
where
F: Fn(&GreeksData) + 'static,
{
let pattern = greeks_topic(underlying, "*").into();
if let Some(custom_handler) = handler {
let typed_handler = TypedHandler::from(custom_handler);
msgbus::subscribe_greeks(pattern, typed_handler, None);
} else {
let cache_ref = self.cache.clone();
let typed_handler = TypedHandler::from(move |greeks: &GreeksData| {
if let Err(e) = cache_ref.borrow_mut().add_greeks(greeks.clone()) {
log::error!("Failed to add GreeksData to cache: {e}");
}
});
msgbus::subscribe_greeks(pattern, typed_handler, None);
}
}
}
fn greeks_topic(underlying: &str, instrument_id: &str) -> String {
format!("data.GreeksData.underlying={underlying}.instrument_id={instrument_id}")
}
fn check_option_greeks_data(data: &GreeksData) -> Result<(), OptionValuationError> {
check_option_inputs(
data.underlying_price,
data.interest_rate,
data.cost_of_carry,
data.vol,
data.strike,
data.expiry_in_years,
)?;
check_greeks_values(data.pnl, data.price, data.greeks)?;
if !(0.0..=1.0).contains(&data.itm_prob)
|| data.price < 0.0
|| !data.multiplier.is_finite()
|| !data.quantity.is_finite()
{
return Err(OptionValuationError::InvalidResult);
}
Ok(())
}
fn check_greeks_values(
pnl: f64,
price: f64,
greeks: OptionGreekValues,
) -> Result<(), OptionValuationError> {
if [
pnl,
price,
greeks.delta,
greeks.gamma,
greeks.vega,
greeks.theta,
greeks.rho,
]
.iter()
.any(|value| !value.is_finite())
{
return Err(OptionValuationError::InvalidResult);
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, collections::HashMap, rc::Rc};
use jiff::{Timestamp, civil::Date, tz::Offset};
use nautilus_model::{
data::{IndexPriceUpdate, QuoteTick, YieldCurve},
enums::{AssetClass, OmsType, OptionKind, OrderSide, PositionSide},
events::order::spec::OrderFilledSpec,
identifiers::{
ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, Venue,
},
instruments::{
Equity, FuturesContract, OptionContract,
any::InstrumentAny,
stubs::{crypto_option_spread_btc_deribit, option_spread},
},
types::{Currency, Price, Quantity},
};
use rstest::rstest;
use rust_decimal::Decimal;
use ustr::Ustr;
use super::*;
use crate::{cache::Cache, clock::VirtualClock};
fn utc_timestamp(year: i16, month: i8, day: i8, hour: i8, minute: i8, second: i8) -> Timestamp {
Offset::UTC
.to_timestamp(
Date::new(year, month, day)
.unwrap()
.at(hour, minute, second, 0),
)
.unwrap()
}
fn create_test_calculator() -> GreeksCalculator {
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
let clock = Rc::new(RefCell::new(VirtualClock::new()));
GreeksCalculator::new(cache, clock)
}
#[rstest]
fn test_greeks_calculator_debug() {
let calculator = create_test_calculator();
let debug_str = format!("{calculator:?}");
assert!(debug_str.starts_with("GreeksCalculator {"), "{debug_str}");
assert!(debug_str.contains("cache:"), "{debug_str}");
assert!(debug_str.contains("clock:"), "{debug_str}");
assert!(
debug_str.contains("cached_futures_spreads: RefCell { value: {} }"),
"{debug_str}"
);
assert!(
debug_str.contains("cross_venue_underlyings: RefCell { value: {} }"),
"{debug_str}"
);
}
#[rstest]
fn test_instrument_greeks_params_builder_default() {
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let params = InstrumentGreeksParams::builder()
.instrument_id(instrument_id)
.build();
assert_eq!(params.instrument_id, instrument_id);
assert_eq!(params.flat_interest_rate, 0.0425);
assert_eq!(params.flat_dividend_yield, None);
assert_eq!(params.spot_shock, 0.0);
assert_eq!(params.vol_shock, 0.0);
assert_eq!(params.time_to_expiry_shock, 0.0);
assert!(!params.use_cached_greeks);
assert!(!params.cache_greeks);
assert!(!params.publish_greeks);
assert_eq!(params.ts_event, None);
assert_eq!(params.position, None);
assert!(!params.percent_greeks);
assert_eq!(params.index_instrument_id, None);
assert_eq!(params.beta_weights, None);
assert_eq!(params.vol_index_instrument_id, None);
assert_eq!(params.vol_beta_weights, None);
}
#[rstest]
fn test_instrument_greeks_params_builder_custom_values() {
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let index_id = InstrumentId::from("SPY.NASDAQ");
let vol_index_id = InstrumentId::from("VIX.XCBF");
let mut beta_weights = HashMap::new();
beta_weights.insert(instrument_id, 1.2);
let mut vol_beta_weights = HashMap::new();
vol_beta_weights.insert(instrument_id, 0.8);
let params = InstrumentGreeksParams::builder()
.instrument_id(instrument_id)
.flat_interest_rate(0.05)
.flat_dividend_yield(0.02)
.spot_shock(0.01)
.vol_shock(0.05)
.time_to_expiry_shock(0.1)
.use_cached_greeks(true)
.cache_greeks(true)
.publish_greeks(true)
.percent_greeks(true)
.index_instrument_id(index_id)
.beta_weights(beta_weights.clone())
.vol_index_instrument_id(vol_index_id)
.vol_beta_weights(vol_beta_weights.clone())
.build();
assert_eq!(params.instrument_id, instrument_id);
assert_eq!(params.flat_interest_rate, 0.05);
assert_eq!(params.flat_dividend_yield, Some(0.02));
assert_eq!(params.spot_shock, 0.01);
assert_eq!(params.vol_shock, 0.05);
assert_eq!(params.time_to_expiry_shock, 0.1);
assert!(params.use_cached_greeks);
assert!(params.cache_greeks);
assert!(params.publish_greeks);
assert!(params.percent_greeks);
assert_eq!(params.index_instrument_id, Some(index_id));
assert_eq!(params.beta_weights, Some(beta_weights));
assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
}
#[rstest]
fn test_instrument_greeks_params_debug() {
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let params = InstrumentGreeksParams::builder()
.instrument_id(instrument_id)
.build();
let debug_str = format!("{params:?}");
assert!(debug_str.contains("InstrumentGreeksParams"));
assert!(debug_str.contains("AAPL.NASDAQ"));
}
#[rstest]
fn test_portfolio_greeks_params_builder_default() {
let params = PortfolioGreeksParams::builder().build();
assert_eq!(params.underlyings, None);
assert_eq!(params.venue, None);
assert_eq!(params.instrument_id, None);
assert_eq!(params.strategy_id, None);
assert_eq!(params.side, None);
assert_eq!(params.flat_interest_rate, 0.0425);
assert_eq!(params.flat_dividend_yield, None);
assert_eq!(params.spot_shock, 0.0);
assert_eq!(params.vol_shock, 0.0);
assert_eq!(params.time_to_expiry_shock, 0.0);
assert!(!params.use_cached_greeks);
assert!(!params.cache_greeks);
assert!(!params.publish_greeks);
assert!(!params.percent_greeks);
assert_eq!(params.index_instrument_id, None);
assert_eq!(params.beta_weights, None);
assert_eq!(params.vol_index_instrument_id, None);
assert_eq!(params.vol_beta_weights, None);
}
#[rstest]
fn test_portfolio_greeks_params_builder_custom_values() {
let venue = Venue::from("NASDAQ");
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let strategy_id = StrategyId::from("test-strategy");
let index_id = InstrumentId::from("SPY.NASDAQ");
let vol_index_id = InstrumentId::from("VIX.XCBF");
let underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
let mut beta_weights = HashMap::new();
beta_weights.insert(instrument_id, 1.2);
let mut vol_beta_weights = HashMap::new();
vol_beta_weights.insert(instrument_id, 0.8);
let params = PortfolioGreeksParams::builder()
.underlyings(underlyings.clone())
.venue(venue)
.instrument_id(instrument_id)
.strategy_id(strategy_id)
.side(PositionSide::Long)
.flat_interest_rate(0.05)
.flat_dividend_yield(0.02)
.spot_shock(0.01)
.vol_shock(0.05)
.time_to_expiry_shock(0.1)
.use_cached_greeks(true)
.cache_greeks(true)
.publish_greeks(true)
.percent_greeks(true)
.index_instrument_id(index_id)
.beta_weights(beta_weights.clone())
.vol_index_instrument_id(vol_index_id)
.vol_beta_weights(vol_beta_weights.clone())
.build();
assert_eq!(params.underlyings, Some(underlyings));
assert_eq!(params.venue, Some(venue));
assert_eq!(params.instrument_id, Some(instrument_id));
assert_eq!(params.strategy_id, Some(strategy_id));
assert_eq!(params.side, Some(PositionSide::Long));
assert_eq!(params.flat_interest_rate, 0.05);
assert_eq!(params.flat_dividend_yield, Some(0.02));
assert_eq!(params.spot_shock, 0.01);
assert_eq!(params.vol_shock, 0.05);
assert_eq!(params.time_to_expiry_shock, 0.1);
assert!(params.use_cached_greeks);
assert!(params.cache_greeks);
assert!(params.publish_greeks);
assert!(params.percent_greeks);
assert_eq!(params.index_instrument_id, Some(index_id));
assert_eq!(params.beta_weights, Some(beta_weights));
assert_eq!(params.vol_index_instrument_id, Some(vol_index_id));
assert_eq!(params.vol_beta_weights, Some(vol_beta_weights));
}
#[rstest]
fn test_portfolio_greeks_params_debug() {
let venue = Venue::from("NASDAQ");
let params = PortfolioGreeksParams::builder().venue(venue).build();
let debug_str = format!("{params:?}");
assert!(debug_str.contains("PortfolioGreeksParams"));
assert!(debug_str.contains("NASDAQ"));
}
#[rstest]
fn test_portfolio_greeks_params_builder_fluent_api() {
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let params = PortfolioGreeksParams::builder()
.instrument_id(instrument_id)
.flat_interest_rate(0.05)
.spot_shock(0.01)
.percent_greeks(true)
.build();
assert_eq!(params.instrument_id, Some(instrument_id));
assert_eq!(params.flat_interest_rate, 0.05);
assert_eq!(params.spot_shock, 0.01);
assert!(params.percent_greeks);
}
#[rstest]
fn test_instrument_greeks_params_builder_fluent_chaining() {
let instrument_id = InstrumentId::from("TSLA.NASDAQ");
let params = InstrumentGreeksParams::builder()
.instrument_id(instrument_id)
.flat_interest_rate(0.03)
.spot_shock(0.02)
.vol_shock(0.1)
.use_cached_greeks(true)
.percent_greeks(true)
.build();
assert_eq!(params.instrument_id, instrument_id);
assert_eq!(params.flat_interest_rate, 0.03);
assert_eq!(params.spot_shock, 0.02);
assert_eq!(params.vol_shock, 0.1);
assert!(params.use_cached_greeks);
assert!(params.percent_greeks);
}
#[rstest]
fn test_portfolio_greeks_params_builder_with_underlyings() {
let underlyings = vec!["AAPL".to_string(), "MSFT".to_string(), "GOOGL".to_string()];
let params = PortfolioGreeksParams::builder()
.underlyings(underlyings.clone())
.flat_interest_rate(0.04)
.build();
assert_eq!(params.underlyings, Some(underlyings));
assert_eq!(params.flat_interest_rate, 0.04);
}
#[rstest]
fn test_builders_with_empty_beta_weights() {
let instrument_id = InstrumentId::from("NVDA.NASDAQ");
let empty_beta_weights = HashMap::new();
let instrument_params = InstrumentGreeksParams::builder()
.instrument_id(instrument_id)
.beta_weights(empty_beta_weights.clone())
.vol_beta_weights(empty_beta_weights.clone())
.build();
let portfolio_params = PortfolioGreeksParams::builder()
.beta_weights(empty_beta_weights.clone())
.vol_beta_weights(empty_beta_weights.clone())
.build();
assert_eq!(
instrument_params.beta_weights,
Some(empty_beta_weights.clone())
);
assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
assert_eq!(instrument_params.vol_beta_weights, Some(HashMap::new()));
assert_eq!(portfolio_params.vol_beta_weights, Some(HashMap::new()));
}
#[rstest]
fn test_builders_with_all_shocks() {
let instrument_id = InstrumentId::from("AMD.NASDAQ");
let instrument_params = InstrumentGreeksParams::builder()
.instrument_id(instrument_id)
.spot_shock(0.05)
.vol_shock(0.1)
.time_to_expiry_shock(0.01)
.build();
let portfolio_params = PortfolioGreeksParams::builder()
.spot_shock(0.05)
.vol_shock(0.1)
.time_to_expiry_shock(0.01)
.build();
assert_eq!(instrument_params.spot_shock, 0.05);
assert_eq!(instrument_params.vol_shock, 0.1);
assert_eq!(instrument_params.time_to_expiry_shock, 0.01);
assert_eq!(portfolio_params.spot_shock, 0.05);
assert_eq!(portfolio_params.vol_shock, 0.1);
assert_eq!(portfolio_params.time_to_expiry_shock, 0.01);
}
#[rstest]
fn test_builders_with_all_boolean_flags() {
let instrument_id = InstrumentId::from("META.NASDAQ");
let instrument_params = InstrumentGreeksParams::builder()
.instrument_id(instrument_id)
.use_cached_greeks(true)
.cache_greeks(true)
.publish_greeks(true)
.percent_greeks(true)
.build();
let portfolio_params = PortfolioGreeksParams::builder()
.use_cached_greeks(true)
.cache_greeks(true)
.publish_greeks(true)
.percent_greeks(true)
.build();
assert!(instrument_params.use_cached_greeks);
assert!(instrument_params.cache_greeks);
assert!(instrument_params.publish_greeks);
assert!(instrument_params.percent_greeks);
assert!(portfolio_params.use_cached_greeks);
assert!(portfolio_params.cache_greeks);
assert!(portfolio_params.publish_greeks);
assert!(portfolio_params.percent_greeks);
}
#[rstest]
fn test_greeks_filter_callback_function() {
fn filter_positive_delta(data: &GreeksData) -> bool {
data.delta > 0.0
}
let filter = GreeksFilterCallback::from_fn(filter_positive_delta);
let greeks_data = GreeksData::from_delta(
InstrumentId::from("TEST.NASDAQ"),
0.5,
1.0,
UnixNanos::default(),
);
assert!(filter.call(&greeks_data));
let debug_str = format!("{filter:?}");
assert!(debug_str.contains("GreeksFilterCallback::Function"));
}
#[rstest]
fn test_greeks_filter_callback_closure() {
let min_delta = 0.3;
let filter =
GreeksFilterCallback::from_closure(move |data: &GreeksData| data.delta > min_delta);
let greeks_data = GreeksData::from_delta(
InstrumentId::from("TEST.NASDAQ"),
0.5,
1.0,
UnixNanos::default(),
);
assert!(filter.call(&greeks_data));
let debug_str = format!("{filter:?}");
assert!(debug_str.contains("GreeksFilterCallback::Closure"));
}
#[rstest]
fn test_greeks_filter_callback_clone() {
fn filter_fn(data: &GreeksData) -> bool {
data.delta > 0.0
}
let filter1 = GreeksFilterCallback::from_fn(filter_fn);
let filter2 = filter1.clone();
let greeks_data = GreeksData::from_delta(
InstrumentId::from("TEST.NASDAQ"),
0.5,
1.0,
UnixNanos::default(),
);
assert!(filter1.call(&greeks_data));
assert!(filter2.call(&greeks_data));
}
#[rstest]
fn test_portfolio_greeks_params_with_filter() {
fn filter_high_delta(data: &GreeksData) -> bool {
data.delta.abs() > 0.1
}
let filter = GreeksFilterCallback::from_fn(filter_high_delta);
let params = PortfolioGreeksParams::builder()
.greeks_filter(filter)
.flat_interest_rate(0.05)
.build();
assert!(params.greeks_filter.is_some());
assert_eq!(params.flat_interest_rate, 0.05);
let greeks_data = GreeksData::from_delta(
InstrumentId::from("TEST.NASDAQ"),
0.5,
1.0,
UnixNanos::default(),
);
let filter_ref = params.greeks_filter.as_ref().unwrap();
assert!(filter_ref.call(&greeks_data));
}
#[rstest]
fn test_portfolio_greeks_params_with_closure_filter() {
let min_gamma = 0.01;
let filter =
GreeksFilterCallback::from_closure(move |data: &GreeksData| data.gamma > min_gamma);
let params = PortfolioGreeksParams::builder()
.greeks_filter(filter)
.build();
assert!(params.greeks_filter.is_some());
let debug_str = format!("{params:?}");
assert!(debug_str.contains("greeks_filter"));
}
#[rstest]
fn test_greeks_filter_to_greeks_filter_conversion() {
fn filter_fn(data: &GreeksData) -> bool {
data.delta > 0.0
}
let callback = GreeksFilterCallback::from_fn(filter_fn);
let greeks_filter = callback.to_greeks_filter();
let greeks_data = GreeksData::from_delta(
InstrumentId::from("TEST.NASDAQ"),
0.5,
1.0,
UnixNanos::default(),
);
assert!(greeks_filter(&greeks_data));
}
fn option_with_expiration(instrument_id: &str, expiration_ns: UnixNanos) -> OptionContract {
let activation_ns = UnixNanos::from(utc_timestamp(2021, 9, 17, 0, 0, 0));
OptionContract::builder()
.instrument_id(InstrumentId::from(instrument_id))
.raw_symbol(Symbol::from("AAPL211217C00150000"))
.asset_class(AssetClass::Equity)
.exchange(Ustr::from("GMNI"))
.underlying(Ustr::from("AAPL"))
.option_kind(OptionKind::Call)
.strike_price(Price::from("149.0"))
.currency(Currency::from("USD"))
.activation_ns(activation_ns)
.expiration_ns(expiration_ns)
.price_precision(2)
.price_increment(Price::from("0.01"))
.multiplier(Quantity::from(100))
.lot_size(Quantity::from(1))
.ts_event(UnixNanos::default())
.ts_init(UnixNanos::default())
.build()
.unwrap()
}
fn equity_aapl_opra() -> Equity {
Equity::builder()
.instrument_id(InstrumentId::from("AAPL.OPRA"))
.raw_symbol(Symbol::from("AAPL"))
.isin(Ustr::from("US0378331005"))
.currency(Currency::from("USD"))
.price_precision(2)
.price_increment(Price::from("0.01"))
.ts_event(UnixNanos::default())
.ts_init(UnixNanos::default())
.build()
.unwrap()
}
#[rstest]
fn test_resolve_underlying_instrument_id_errors_without_underlying() {
let instrument = InstrumentAny::Equity(equity_aapl_opra());
let error = create_test_calculator()
.resolve_underlying_instrument_id(&instrument, InstrumentId::from("AAPL.OPRA"))
.unwrap_err();
assert_eq!(
error.to_string(),
"Instrument AAPL.OPRA has no underlying identifier"
);
}
fn equity_with_id(instrument_id: &str) -> Equity {
let instrument_id = InstrumentId::from(instrument_id);
Equity::builder()
.instrument_id(instrument_id)
.raw_symbol(instrument_id.symbol)
.currency(Currency::from("USD"))
.price_precision(2)
.price_increment(Price::from("0.01"))
.ts_event(UnixNanos::default())
.ts_init(UnixNanos::default())
.build()
.unwrap()
}
fn calculator_with_option_and_equities(
option: OptionContract,
equity_ids: &[&str],
) -> GreeksCalculator {
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(option))
.unwrap();
for equity_id in equity_ids {
cache
.borrow_mut()
.add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
.unwrap();
}
let clock = Rc::new(RefCell::new(VirtualClock::new()));
GreeksCalculator::new(cache, clock)
}
#[rstest]
#[case::same_venue_preferred(&["AAPL.XNAS", "AAPL.OPRA", "AAPL.ARCX"], "AAPL.OPRA")]
#[case::unique_cross_venue_match(&["AAPL.XNAS", "MSFT.XNAS"], "AAPL.XNAS")]
#[case::no_match_keeps_option_venue(&["MSFT.XNAS"], "AAPL.OPRA")]
fn test_resolve_underlying_instrument_id(#[case] equity_ids: &[&str], #[case] expected: &str) {
let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
let option_id = option.id();
let instrument = InstrumentAny::OptionContract(option.clone());
let calculator = calculator_with_option_and_equities(option, equity_ids);
let underlying_id = calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap();
assert_eq!(underlying_id, InstrumentId::from(expected));
}
#[rstest]
fn test_resolve_underlying_instrument_id_errors_when_cross_venue_match_is_ambiguous() {
let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
let option_id = option.id();
let instrument = InstrumentAny::OptionContract(option.clone());
let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS", "AAPL.ARCX"]);
let error = calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap_err();
assert_eq!(
error.to_string(),
"Ambiguous underlying for option AAPL250417C00150000.OPRA: AAPL.ARCX, AAPL.XNAS"
);
}
#[rstest]
fn test_resolve_underlying_instrument_id_reuses_cross_venue_match() {
let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
let option_id = option.id();
let instrument = InstrumentAny::OptionContract(option.clone());
let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
let first = calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap();
calculator
.cache
.borrow_mut()
.add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.ARCX")))
.unwrap();
let second = calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap();
assert_eq!(first, InstrumentId::from("AAPL.XNAS"));
assert_eq!(second, InstrumentId::from("AAPL.XNAS"));
}
#[rstest]
fn test_resolve_underlying_instrument_id_rescans_after_match_is_purged() {
let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
let option_id = option.id();
let instrument = InstrumentAny::OptionContract(option.clone());
let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap();
{
let mut cache = calculator.cache.borrow_mut();
cache
.add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.ARCX")))
.unwrap();
cache.purge_instrument(InstrumentId::from("AAPL.XNAS"));
}
let underlying_id = calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap();
assert_eq!(underlying_id, InstrumentId::from("AAPL.ARCX"));
}
#[rstest]
#[case::no_underlying_cached(&[])]
#[case::same_venue_cached(&["AAPL.OPRA"])]
fn test_resolve_underlying_instrument_id_forgets_match_once_purged(
#[case] cached_while_purged: &[&str],
) {
let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::default());
let option_id = option.id();
let instrument = InstrumentAny::OptionContract(option.clone());
let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS"]);
calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap();
{
let mut cache = calculator.cache.borrow_mut();
cache.purge_instrument(InstrumentId::from("AAPL.XNAS"));
for equity_id in cached_while_purged {
cache
.add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
.unwrap();
}
}
calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap();
{
let mut cache = calculator.cache.borrow_mut();
for equity_id in cached_while_purged {
cache.purge_instrument(InstrumentId::from(*equity_id));
}
for equity_id in ["AAPL.XNAS", "AAPL.ARCX"] {
cache
.add_instrument(InstrumentAny::Equity(equity_with_id(equity_id)))
.unwrap();
}
}
let error = calculator
.resolve_underlying_instrument_id(&instrument, option_id)
.unwrap_err();
assert_eq!(
error.to_string(),
"Ambiguous underlying for option AAPL250417C00150000.OPRA: AAPL.ARCX, AAPL.XNAS"
);
}
#[rstest]
fn test_instrument_greeks_reads_cached_greeks_when_underlying_is_ambiguous() {
let option =
option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::from(1_000_000_000));
let option_id = option.id();
let position = position_from_fill(
&InstrumentAny::OptionContract(option.clone()),
"P-GREEKS-1",
"O-GREEKS-1",
"T-GREEKS-1",
OrderSide::Buy,
1,
"9.00",
);
let calculator = calculator_with_option_and_equities(option, &["AAPL.XNAS", "AAPL.ARCX"]);
let mut cached_greeks = GreeksData::from_delta(option_id, 0.5, 100.0, UnixNanos::default());
cached_greeks.price = 10.5;
cached_greeks.underlying_price = 150.0;
cached_greeks.strike = 150.0;
cached_greeks.vol = 0.2;
cached_greeks.expiry_in_years = 1.0;
calculator
.cache
.borrow_mut()
.add_greeks(cached_greeks.clone())
.unwrap();
let greeks = calculator
.instrument_greeks(
option_id,
None,
None,
None,
None,
None,
Some(true),
None,
None,
None,
None,
Some(position),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(greeks.instrument_id, option_id);
assert_eq!(greeks.greeks, cached_greeks.greeks);
assert_eq!(greeks.pnl, 1.5);
}
#[rstest]
fn test_instrument_greeks_prices_underlying_on_another_venue() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
let option_id = option.id();
let same_venue_cache = setup_cache_with_option_and_quotes(
option.clone(),
InstrumentId::from("AAPL.OPRA"),
now_ns,
);
let cross_venue_cache = Rc::new(RefCell::new(Cache::new(None, None)));
cross_venue_cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(option))
.unwrap();
cross_venue_cache
.borrow_mut()
.add_instrument(InstrumentAny::Equity(equity_with_id("AAPL.XNAS")))
.unwrap();
for (instrument_id, bid, ask) in [
(option_id, "10.50", "10.60"),
(InstrumentId::from("AAPL.XNAS"), "150.00", "150.10"),
] {
cross_venue_cache
.borrow_mut()
.add_quote(QuoteTick::new(
instrument_id,
Price::from(bid),
Price::from(ask),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
))
.unwrap();
}
let greeks_for = |cache: Rc<RefCell<Cache>>| {
let clock = Rc::new(RefCell::new(VirtualClock::new()));
GreeksCalculator::new(cache, clock)
.instrument_greeks(
option_id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap()
};
let expected = greeks_for(same_venue_cache);
let greeks = greeks_for(cross_venue_cache);
assert_eq!(greeks.underlying_price, expected.underlying_price);
assert_eq!(greeks.vol, expected.vol);
assert_eq!(greeks.price, expected.price);
assert_eq!(greeks.greeks, expected.greeks);
}
fn future_with_expiration(
instrument_id: &str,
underlying: &str,
expiration_ns: UnixNanos,
) -> FuturesContract {
FuturesContract::builder()
.instrument_id(InstrumentId::from(instrument_id))
.raw_symbol(Symbol::from(underlying))
.asset_class(AssetClass::Index)
.exchange(Ustr::from("XCME"))
.underlying(Ustr::from(underlying))
.activation_ns(UnixNanos::default())
.expiration_ns(expiration_ns)
.currency(Currency::from("USD"))
.price_precision(2)
.price_increment(Price::from("0.25"))
.multiplier(Quantity::from(1))
.lot_size(Quantity::from(1))
.ts_event(UnixNanos::default())
.ts_init(UnixNanos::default())
.build()
.unwrap()
}
fn future_option_with_expiration(
instrument_id: &str,
raw_symbol: &str,
underlying: &str,
option_kind: OptionKind,
strike: &str,
expiration_ns: UnixNanos,
) -> OptionContract {
OptionContract::builder()
.instrument_id(InstrumentId::from(instrument_id))
.raw_symbol(Symbol::from(raw_symbol))
.asset_class(AssetClass::Index)
.exchange(Ustr::from("XCME"))
.underlying(Ustr::from(underlying))
.option_kind(option_kind)
.strike_price(Price::from(strike))
.currency(Currency::from("USD"))
.activation_ns(UnixNanos::default())
.expiration_ns(expiration_ns)
.price_precision(2)
.price_increment(Price::from("0.01"))
.multiplier(Quantity::from(1))
.lot_size(Quantity::from(1))
.ts_event(UnixNanos::default())
.ts_init(UnixNanos::default())
.build()
.unwrap()
}
fn setup_cache_with_option_and_quotes(
option: OptionContract,
underlying_id: InstrumentId,
now_ns: UnixNanos,
) -> Rc<RefCell<Cache>> {
let option_id = option.id();
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(option))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::Equity(equity_aapl_opra()))
.unwrap();
let option_quote = QuoteTick::new(
option_id,
Price::from("10.50"),
Price::from("10.60"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
let underlying_quote = QuoteTick::new(
underlying_id,
Price::from("150.00"),
Price::from("150.10"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
cache.borrow_mut().add_quote(option_quote).unwrap();
cache.borrow_mut().add_quote(underlying_quote).unwrap();
cache
}
fn position_from_fill(
instrument: &InstrumentAny,
position_id: &str,
client_order_id: &str,
trade_id: &str,
side: OrderSide,
quantity: u64,
price: &str,
) -> Position {
let fill = OrderFilledSpec::builder()
.instrument_id(instrument.id())
.client_order_id(ClientOrderId::from(client_order_id))
.trade_id(TradeId::from(trade_id))
.order_side(side)
.last_qty(Quantity::from(quantity))
.last_px(Price::from(price))
.currency(Currency::USD())
.position_id(PositionId::from(position_id))
.build();
Position::new(instrument, fill)
}
fn calculate_portfolio_greeks(
calculator: &GreeksCalculator,
side: Option<PositionSide>,
) -> anyhow::Result<PortfolioGreeks> {
calculator.portfolio_greeks(
None, None, None, None, side, None, None, None, None, None, None, None, None, None,
None, None, None, None, None, None, None,
)
}
fn assert_portfolio_greeks_eq(actual: &PortfolioGreeks, expected: &PortfolioGreeks) {
assert_eq!(actual.ts_init, expected.ts_init);
assert_eq!(actual.ts_event, expected.ts_event);
assert_eq!(actual.pnl, expected.pnl);
assert_eq!(actual.price, expected.price);
assert_eq!(actual.delta, expected.delta);
assert_eq!(actual.gamma, expected.gamma);
assert_eq!(actual.vega, expected.vega);
assert_eq!(actual.theta, expected.theta);
assert_eq!(actual.rho, expected.rho);
}
#[rstest]
#[case::neither(false, false)]
#[case::cache(true, false)]
#[case::publish(false, true)]
#[case::cache_and_publish(true, true)]
fn test_portfolio_greeks_cache_and_publish(
#[case] cache_greeks: bool,
#[case] publish_greeks: bool,
) {
msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
let (calculator, position, now_ns) = portfolio_option_calculator();
let option_id = position.instrument_id;
let expected = InstrumentGreeksParams::builder()
.instrument_id(option_id)
.ts_event(now_ns)
.build()
.calculate(&calculator)
.unwrap();
let published = Rc::new(RefCell::new(Vec::new()));
let captured = published.clone();
calculator.subscribe_greeks("AAPL", None::<fn(&GreeksData)>);
calculator.subscribe_greeks(
"AAPL",
Some(move |data: &GreeksData| {
captured.borrow_mut().push(data.clone());
}),
);
let result = PortfolioGreeksParams::builder()
.cache_greeks(cache_greeks)
.publish_greeks(publish_greeks)
.build()
.calculate(&calculator)
.unwrap();
let cached = calculator.cache.borrow().greeks(&option_id);
msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
let mut expected_position = expected.clone();
expected_position.pnl = expected_position.price - position.avg_px_open.as_f64();
assert_portfolio_greeks_eq(
&result,
&(position.signed_qty.as_f64() * &expected_position.to_portfolio_greeks()),
);
assert_eq!(cached.is_some(), cache_greeks || publish_greeks);
assert_eq!(published.borrow().len(), usize::from(publish_greeks));
for actual in cached.iter().chain(published.borrow().iter()) {
assert_greeks_eq(actual, &expected);
}
}
#[rstest]
#[case::underlying("AAPL", 1)]
#[case::underlying_prefix("AAP", 0)]
#[case::all_underlyings("*", 1)]
fn test_subscribe_greeks_matches_underlying_symbol_exactly(
#[case] underlying: &str,
#[case] expected_count: usize,
) {
msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
let (calculator, option_id, now_ns) = option_calculator();
let received = Rc::new(RefCell::new(Vec::new()));
let captured = received.clone();
calculator.subscribe_greeks(
underlying,
Some(move |data: &GreeksData| captured.borrow_mut().push(data.instrument_id)),
);
InstrumentGreeksParams::builder()
.instrument_id(option_id)
.publish_greeks(true)
.ts_event(now_ns)
.build()
.calculate(&calculator)
.unwrap();
msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
assert_eq!(*received.borrow(), vec![option_id; expected_count]);
}
#[rstest]
fn test_publish_greeks_topic_includes_underlying_and_instrument_id() {
msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
let (calculator, option_id, now_ns) = option_calculator();
let received = Rc::new(RefCell::new(Vec::new()));
let captured = received.clone();
let topic = "data.GreeksData.underlying=AAPL.instrument_id=AAPL250417C00150000.OPRA";
let handler = TypedHandler::from(move |data: &GreeksData| {
captured.borrow_mut().push(data.instrument_id);
});
msgbus::subscribe_greeks(topic.into(), handler, None);
InstrumentGreeksParams::builder()
.instrument_id(option_id)
.publish_greeks(true)
.ts_event(now_ns)
.build()
.calculate(&calculator)
.unwrap();
msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
assert_eq!(*received.borrow(), vec![option_id]);
}
#[rstest]
fn test_portfolio_greeks_filter_reenters_calculator_and_caches() {
let (calculator, position, now_ns) = portfolio_option_calculator();
let option_id = position.instrument_id;
let expected = InstrumentGreeksParams::builder()
.instrument_id(option_id)
.ts_event(now_ns)
.build()
.calculate(&calculator)
.unwrap();
let calculator = Rc::new(calculator);
let captured = calculator.clone();
let observed = Rc::new(RefCell::new(Vec::new()));
let captured_observed = observed.clone();
let filter = GreeksFilterCallback::from_closure(move |data| {
InstrumentGreeksParams::builder()
.instrument_id(data.instrument_id)
.ts_event(now_ns)
.cache_greeks(true)
.build()
.calculate(&captured)
.unwrap();
captured_observed.borrow_mut().push(data.clone());
true
});
let result = PortfolioGreeksParams::builder()
.greeks_filter(filter)
.build()
.calculate(&calculator)
.unwrap();
let mut expected_position = expected.clone();
expected_position.pnl = expected_position.price - position.avg_px_open.as_f64();
let expected_portfolio =
position.signed_qty.as_f64() * &expected_position.to_portfolio_greeks();
assert_portfolio_greeks_eq(&result, &expected_portfolio);
assert_greeks_eq(
&calculator.cache.borrow().greeks(&option_id).unwrap(),
&expected,
);
expected_position.pnl = expected_portfolio.pnl;
expected_position.price = expected_portfolio.price;
expected_position.greeks = expected_portfolio.greeks;
let observed = observed.borrow();
assert_eq!(observed.len(), 1);
assert_greeks_eq(&observed[0], &expected_position);
}
fn portfolio_option_calculator() -> (GreeksCalculator, Position, UnixNanos) {
let (calculator, option_id, now_ns) = option_calculator();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(calculator.cache, clock);
let instrument = calculator
.cache
.borrow()
.instrument(&option_id)
.cloned()
.unwrap();
let position = position_from_fill(
&instrument,
"P-OPTION",
"O-OPTION",
"T-OPTION",
OrderSide::Buy,
3,
"9.00",
);
calculator
.cache
.borrow_mut()
.add_position(&position, OmsType::Hedging)
.unwrap();
(calculator, position, now_ns)
}
fn assert_greeks_eq(actual: &GreeksData, expected: &GreeksData) {
assert_eq!(actual.ts_init, expected.ts_init);
assert_eq!(actual.ts_event, expected.ts_event);
assert_eq!(actual.instrument_id, expected.instrument_id);
assert_eq!(actual.is_call, expected.is_call);
assert_eq!(actual.strike, expected.strike);
assert_eq!(actual.expiry, expected.expiry);
assert_eq!(actual.expiry_in_days, expected.expiry_in_days);
assert_eq!(actual.expiry_in_years, expected.expiry_in_years);
assert_eq!(actual.multiplier, expected.multiplier);
assert_eq!(actual.quantity, expected.quantity);
assert_eq!(actual.underlying_price, expected.underlying_price);
assert_eq!(actual.interest_rate, expected.interest_rate);
assert_eq!(actual.cost_of_carry, expected.cost_of_carry);
assert_eq!(actual.vol, expected.vol);
assert_eq!(actual.pnl, expected.pnl);
assert_eq!(actual.price, expected.price);
assert_eq!(actual.greeks, expected.greeks);
assert_eq!(actual.itm_prob, expected.itm_prob);
}
#[rstest]
#[case(OrderSide::Buy, 3, [300.0, 300.0, 150.0])]
#[case(OrderSide::Sell, 2, [-200.0, -200.0, -100.0])]
fn test_portfolio_greeks_scales_futures_multiplier(
#[case] side: OrderSide,
#[case] quantity: u64,
#[case] expected: [f64; 3],
) {
let now_ns = UnixNanos::from(1_700_000_000_000_000_000);
let mut future =
future_with_expiration("ES.GLBX", "ES", UnixNanos::from(1_800_000_000_000_000_000));
future.multiplier = Quantity::from(50);
let instrument = InstrumentAny::FuturesContract(future);
let instrument_id = instrument.id();
let position = position_from_fill(
&instrument,
"P-FUTURE",
"O-FUTURE",
"T-FUTURE",
side,
quantity,
"100.00",
);
let cache = Rc::new(RefCell::new(Cache::default()));
cache.borrow_mut().add_instrument(instrument).unwrap();
cache
.borrow_mut()
.add_position(&position, OmsType::Hedging)
.unwrap();
cache
.borrow_mut()
.add_quote(QuoteTick::new(
instrument_id,
Price::from("101.75"),
Price::from("102.25"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
))
.unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
let result = calculate_portfolio_greeks(&calculator, None).unwrap();
assert_portfolio_greeks_eq(
&result,
&PortfolioGreeks::new(
expected[0],
expected[1],
expected[2],
0.0,
0.0,
0.0,
now_ns,
now_ns,
),
);
}
#[rstest]
fn test_portfolio_greeks_ignores_closed_position_with_missing_price() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let open_option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
let open_option_id = open_option.id();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let cache = setup_cache_with_option_and_quotes(open_option.clone(), underlying_id, now_ns);
let closed_future = future_with_expiration("CLOSED.GLBX", "CLOSED", expiry_ns);
let closed_future_id = closed_future.id();
let open_instrument = InstrumentAny::OptionContract(open_option);
let closed_instrument = InstrumentAny::FuturesContract(closed_future);
let open_position = position_from_fill(
&open_instrument,
"P-OPEN",
"O-OPEN",
"T-OPEN",
OrderSide::Buy,
2,
"10.50",
);
let mut closed_position = position_from_fill(
&closed_instrument,
"P-CLOSED",
"O-CLOSED-OPEN",
"T-CLOSED-OPEN",
OrderSide::Buy,
1,
"100.00",
);
cache
.borrow_mut()
.add_instrument(closed_instrument)
.unwrap();
cache
.borrow_mut()
.add_position(&open_position, OmsType::Hedging)
.unwrap();
cache
.borrow_mut()
.add_position(&closed_position, OmsType::Hedging)
.unwrap();
let closing_fill = OrderFilledSpec::builder()
.instrument_id(closed_future_id)
.client_order_id(ClientOrderId::from("O-CLOSED-CLOSE"))
.trade_id(TradeId::from("T-CLOSED-CLOSE"))
.order_side(OrderSide::Sell)
.last_qty(Quantity::from(1))
.last_px(Price::from("101.00"))
.currency(Currency::USD())
.position_id(PositionId::from("P-CLOSED"))
.build();
closed_position.apply(&closing_fill);
cache
.borrow_mut()
.update_position(&closed_position)
.unwrap();
assert!(closed_position.is_closed());
assert_eq!(
cache
.borrow()
.positions_open(None, None, None, None, None)
.len(),
1
);
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
let expected = calculator
.instrument_greeks(
open_option_id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
Some(open_position.clone()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
let expected = open_position.signed_qty.as_f64() * &expected.to_portfolio_greeks();
assert_ne!(expected.delta, 0.0);
assert_portfolio_greeks_eq(
&calculate_portfolio_greeks(&calculator, None).unwrap(),
&expected,
);
assert_portfolio_greeks_eq(
&calculate_portfolio_greeks(&calculator, Some(PositionSide::Flat)).unwrap(),
&PortfolioGreeks::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, now_ns, now_ns),
);
}
#[rstest]
fn test_portfolio_greeks_preserves_open_position_aggregate_and_side_filters() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
let long_instrument = InstrumentAny::OptionContract(long_option.clone());
let short_instrument = InstrumentAny::OptionContract(short_option.clone());
let underlying_id = InstrumentId::from("AAPL.OPRA");
let cache = setup_cache_with_option_and_quotes(long_option, underlying_id, now_ns);
cache
.borrow_mut()
.add_instrument(short_instrument.clone())
.unwrap();
cache
.borrow_mut()
.add_quote(QuoteTick::new(
short_option.id(),
Price::from("3.50"),
Price::from("3.60"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
))
.unwrap();
let long_position = position_from_fill(
&long_instrument,
"P-LONG",
"O-LONG",
"T-LONG",
OrderSide::Buy,
3,
"10.50",
);
let short_position = position_from_fill(
&short_instrument,
"P-SHORT",
"O-SHORT",
"T-SHORT",
OrderSide::Sell,
2,
"3.50",
);
cache
.borrow_mut()
.add_position(&long_position, OmsType::Hedging)
.unwrap();
cache
.borrow_mut()
.add_position(&short_position, OmsType::Hedging)
.unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
let long_greeks = calculator
.instrument_greeks(
long_instrument.id(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
Some(long_position.clone()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
let short_greeks = calculator
.instrument_greeks(
short_instrument.id(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
Some(short_position.clone()),
None,
None,
None,
None,
None,
None,
)
.unwrap();
let expected_long = long_position.signed_qty.as_f64() * &long_greeks.to_portfolio_greeks();
let expected_short =
short_position.signed_qty.as_f64() * &short_greeks.to_portfolio_greeks();
let expected = expected_long + expected_short;
assert_ne!(expected.pnl, 0.0);
assert_ne!(expected.price, 0.0);
assert_ne!(expected.delta, 0.0);
assert_ne!(expected.gamma, 0.0);
assert_ne!(expected.vega, 0.0);
assert_ne!(expected.theta, 0.0);
assert_portfolio_greeks_eq(
&calculate_portfolio_greeks(&calculator, None).unwrap(),
&expected,
);
assert_portfolio_greeks_eq(
&calculate_portfolio_greeks(&calculator, Some(PositionSide::Long)).unwrap(),
&(long_position.signed_qty.as_f64() * &long_greeks.to_portfolio_greeks()),
);
assert_portfolio_greeks_eq(
&calculate_portfolio_greeks(&calculator, Some(PositionSide::Short)).unwrap(),
&(short_position.signed_qty.as_f64() * &short_greeks.to_portfolio_greeks()),
);
}
#[rstest]
#[case(true, [-200.0, -1700.0, -1600.0, -1500.0, -1400.0, 1300.0, -1200.0])]
#[case(false, [600.0, 900.0, 1200.0, 1500.0, 1800.0, -2100.0, 2400.0])]
fn test_portfolio_greeks_scales_before_filter(
#[case] include_short: bool,
#[case] expected: [f64; 7],
) {
let now_ns = UnixNanos::from(1_700_000_000_000_000_000);
let expiry_ns = UnixNanos::from(1_800_000_000_000_000_000);
let long_option = option_with_expiration("AAPL250417C00145000.OPRA", expiry_ns);
let short_option = option_with_expiration("AAPL250417C00155000.OPRA", expiry_ns);
let long_id = long_option.id();
let short_id = short_option.id();
let cache = Rc::new(RefCell::new(Cache::default()));
let observed = Rc::new(RefCell::new(Vec::new()));
for (option, side, quantity, entry, price, greeks) in [
(
long_option,
OrderSide::Buy,
3,
"1.00",
3.0,
OptionGreekValues {
delta: 4.0,
gamma: 5.0,
vega: 6.0,
theta: -7.0,
rho: 8.0,
},
),
(
short_option,
OrderSide::Sell,
2,
"9.00",
13.0,
OptionGreekValues {
delta: 14.0,
gamma: 15.0,
vega: 16.0,
theta: -17.0,
rho: 18.0,
},
),
] {
let instrument = InstrumentAny::OptionContract(option);
let position = position_from_fill(
&instrument,
instrument.id().symbol.as_str(),
instrument.id().symbol.as_str(),
instrument.id().symbol.as_str(),
side,
quantity,
entry,
);
let data = GreeksData {
instrument_id: instrument.id(),
is_call: false,
strike: 19.0,
expiry: 20_271_015,
expiry_in_days: 23,
expiry_in_years: 0.25,
multiplier: 100.0,
quantity: 1.0,
underlying_price: 29.0,
interest_rate: 0.05,
cost_of_carry: 0.02,
vol: 0.5,
pnl: 31.0,
price,
greeks,
itm_prob: 0.75,
ts_event: UnixNanos::from(13),
ts_init: UnixNanos::from(11),
};
let mut cache = cache.borrow_mut();
cache.add_instrument(instrument).unwrap();
cache.add_position(&position, OmsType::Hedging).unwrap();
cache.add_greeks(data).unwrap();
}
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache.clone(), clock);
let captured = observed.clone();
let filter: GreeksFilter = Box::new(move |data| {
captured.borrow_mut().push(data.clone());
include_short || data.delta > 0.0
});
let result = calculator
.portfolio_greeks(
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(true),
None,
None,
None,
None,
None,
None,
Some(&filter),
None,
None,
None,
)
.unwrap();
assert_eq!(result.ts_init, now_ns);
assert_eq!(result.ts_event, now_ns);
assert_eq!(
[
result.pnl,
result.price,
result.delta,
result.gamma,
result.vega,
result.theta,
result.rho
],
expected
);
let mut observed = observed.borrow_mut();
observed.sort_by_key(|data| data.instrument_id);
assert_eq!(observed.len(), 2);
for (data, id, values) in [
(
&observed[0],
long_id,
[600.0, 900.0, 1200.0, 1500.0, 1800.0, -2100.0, 2400.0],
),
(
&observed[1],
short_id,
[-800.0, -2600.0, -2800.0, -3000.0, -3200.0, 3400.0, -3600.0],
),
] {
assert_eq!(
[
data.pnl, data.price, data.delta, data.gamma, data.vega, data.theta, data.rho
],
values
);
assert_eq!(data.ts_init, UnixNanos::from(11));
assert_eq!(data.ts_event, UnixNanos::from(13));
assert_eq!(data.instrument_id, id);
assert!(!data.is_call);
assert_eq!(data.strike, 19.0);
assert_eq!(data.expiry, 20_271_015);
assert_eq!(data.expiry_in_days, 23);
assert_eq!(data.expiry_in_years, 0.25);
assert_eq!(data.multiplier, 100.0);
assert_eq!(data.quantity, 1.0);
assert_eq!(data.underlying_price, 29.0);
assert_eq!(data.interest_rate, 0.05);
assert_eq!(data.cost_of_carry, 0.02);
assert_eq!(data.vol, 0.5);
assert_eq!(data.itm_prob, 0.75);
}
for (id, price, greeks) in [
(
long_id,
3.0,
OptionGreekValues {
delta: 4.0,
gamma: 5.0,
vega: 6.0,
theta: -7.0,
rho: 8.0,
},
),
(
short_id,
13.0,
OptionGreekValues {
delta: 14.0,
gamma: 15.0,
vega: 16.0,
theta: -17.0,
rho: 18.0,
},
),
] {
let cached = cache.borrow().greeks(&id).unwrap();
assert_eq!(cached.pnl, 31.0);
assert_eq!(cached.price, price);
assert_eq!(cached.greeks, greeks);
}
}
#[rstest]
fn test_expiry_in_days_multi_day_unchanged() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
let option_id = option.id();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
let clock = Rc::new(RefCell::new(VirtualClock::new()));
let calculator = GreeksCalculator::new(cache, clock);
let greeks = calculator
.instrument_greeks(
option_id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(greeks.expiry_in_days, 30);
assert!((greeks.expiry_in_years - 30.0 / 365.25).abs() < 1e-9);
}
#[rstest]
fn test_expiry_in_days_same_day_clamped_to_one() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry_same_day = utc_timestamp(2025, 3, 8, 18, 0, 0);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry_same_day);
let option = option_with_expiration("AAPL250308C00150000.OPRA", expiry_ns);
let option_id = option.id();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
let clock = Rc::new(RefCell::new(VirtualClock::new()));
let calculator = GreeksCalculator::new(cache, clock);
let greeks = calculator
.instrument_greeks(
option_id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(greeks.expiry_in_days, 1);
assert!((greeks.expiry_in_years - 1.0 / 365.25).abs() < 1e-9);
}
#[rstest]
fn test_instrument_greeks_beta_weights_vega_to_vol_index() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
let option_id = option.id();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let vol_index_id = InstrumentId::from("VIX.XCBF");
let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
cache
.borrow_mut()
.add_quote(QuoteTick::new(
vol_index_id,
Price::from("25.00"),
Price::from("25.00"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
))
.unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
let calculator = GreeksCalculator::new(cache, clock);
let greeks = calculator
.instrument_greeks(
option_id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
let mut vol_beta_weights = HashMap::new();
vol_beta_weights.insert(underlying_id, 0.75);
let vol_weighted_greeks = calculator
.instrument_greeks(
option_id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
Some(vol_index_id),
Some(&vol_beta_weights),
)
.unwrap();
let expected_vega = greeks.vega * 0.75 * (greeks.vol * 100.0) / 25.0;
assert_eq!(
(vol_weighted_greeks.delta * 1e12).round(),
(greeks.delta * 1e12).round()
);
assert_eq!(
(vol_weighted_greeks.gamma * 1e12).round(),
(greeks.gamma * 1e12).round()
);
assert_eq!(
(vol_weighted_greeks.vega * 1e12).round(),
(expected_vega * 1e12).round()
);
}
#[rstest]
fn test_instrument_greeks_errors_when_vol_index_price_missing() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry = now + jiff::SignedDuration::from_hours(24 * (30));
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
let option_id = option.id();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let vol_index_id = InstrumentId::from("VIX.XCBF");
let cache = setup_cache_with_option_and_quotes(option, underlying_id, now_ns);
let clock = Rc::new(RefCell::new(VirtualClock::new()));
let calculator = GreeksCalculator::new(cache, clock);
let error = calculator
.instrument_greeks(
option_id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
Some(vol_index_id),
None,
)
.unwrap_err();
assert_eq!(error.to_string(), "No price available for VIX.XCBF");
}
#[rstest]
fn test_modify_greeks_errors_when_vol_index_price_missing() {
let calculator = create_test_calculator();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let vol_index_id = InstrumentId::from("VIX.XCBF");
let error = calculator
.modify_greeks(
1.0,
2.0,
underlying_id,
150.0,
150.0,
false,
None,
None,
2.0,
0.30,
0,
None,
0.0,
Some(vol_index_id),
None,
None,
None,
)
.unwrap_err();
assert_eq!(error.to_string(), "No price available for VIX.XCBF");
}
#[rstest]
fn test_modify_greeks_accepts_explicit_index_prices() {
let calculator = create_test_calculator();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let mut beta_weights = HashMap::new();
beta_weights.insert(underlying_id, 0.5);
let mut vol_beta_weights = HashMap::new();
vol_beta_weights.insert(underlying_id, 0.75);
let (delta, gamma, vega) = calculator
.modify_greeks(
1.0,
2.0,
underlying_id,
150.0,
150.0,
false,
None,
Some(&beta_weights),
2.0,
0.30,
0,
None,
0.0,
None,
Some(&vol_beta_weights),
Some(200.0),
Some(25.0),
)
.unwrap();
assert_eq!((delta * 1e12).round(), 375_000_000_000.0);
assert_eq!((gamma * 1e12).round(), 281_250_000_000.0);
assert_eq!((vega * 1e12).round(), 1_800_000_000_000.0);
let (delta, gamma, vega) = calculator
.modify_greeks(
1.0,
2.0,
underlying_id,
150.0,
150.0,
true,
None,
Some(&beta_weights),
2.0,
0.30,
0,
None,
0.0,
None,
Some(&vol_beta_weights),
Some(200.0),
Some(25.0),
)
.unwrap();
assert_eq!((delta * 1e12).round(), 750_000_000_000.0);
assert_eq!((gamma * 1e12).round(), 1_125_000_000_000.0);
assert_eq!((vega * 1e12).round(), 4_500_000_000.0);
}
#[rstest]
fn test_instrument_greeks_errors_when_future_underlying_price_missing_without_cached_spread() {
let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
let call_option = future_option_with_expiration(
"ESH4C150.GLBX",
"ESH4C150",
"ESH4",
OptionKind::Call,
"150.00",
expiry_ns,
);
let put_option = future_option_with_expiration(
"ESH4P150.GLBX",
"ESH4P150",
"ESH4",
OptionKind::Put,
"150.00",
expiry_ns,
);
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
cache
.borrow_mut()
.add_instrument(InstrumentAny::FuturesContract(future))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(call_option.clone()))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(put_option.clone()))
.unwrap();
let call_quote = QuoteTick::new(
call_option.id(),
Price::from("8.50"),
Price::from("8.50"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
let put_quote = QuoteTick::new(
put_option.id(),
Price::from("3.33"),
Price::from("3.33"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
cache.borrow_mut().add_quote(call_quote).unwrap();
cache.borrow_mut().add_quote(put_quote).unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
let error = calculator
.instrument_greeks(
call_option.id(),
Some(0.0425),
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap_err();
assert_eq!(error.to_string(), "No price available for ESH4.GLBX");
}
#[rstest]
fn test_cache_futures_spread_returns_price_to_reference_future() {
let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
let call_option = future_option_with_expiration(
"ESH4C150.GLBX",
"ESH4C150",
"ESH4",
OptionKind::Call,
"150.00",
expiry_ns,
);
let put_option = future_option_with_expiration(
"ESH4P150.GLBX",
"ESH4P150",
"ESH4",
OptionKind::Put,
"150.00",
expiry_ns,
);
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
cache
.borrow_mut()
.add_instrument(InstrumentAny::FuturesContract(future))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(call_option.clone()))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(put_option.clone()))
.unwrap();
let call_quote = QuoteTick::new(
call_option.id(),
Price::from("8.50"),
Price::from("8.50"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
let put_quote = QuoteTick::new(
put_option.id(),
Price::from("3.33"),
Price::from("3.33"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
let reference_future_quote = QuoteTick::new(
reference_future.id(),
Price::from("155.00"),
Price::from("155.00"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
cache.borrow_mut().add_quote(call_quote).unwrap();
cache.borrow_mut().add_quote(put_quote).unwrap();
cache
.borrow_mut()
.add_quote(reference_future_quote)
.unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
let cached_future_price = calculator
.cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
.unwrap();
let expected_underlying = 150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33);
let expected_cached_underlying = reference_future.make_price(expected_underlying);
assert_eq!(cached_future_price, expected_cached_underlying);
assert_eq!(
calculator.get_cached_futures_spread_price(InstrumentId::from("ESH4.GLBX")),
Some(expected_cached_underlying)
);
}
#[rstest]
fn test_instrument_greeks_uses_cached_futures_spread_when_underlying_price_missing() {
let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
let reference_future = future_with_expiration("ESM4.GLBX", "ESM4", expiry_ns);
let call_option = future_option_with_expiration(
"ESH4C150.GLBX",
"ESH4C150",
"ESH4",
OptionKind::Call,
"150.00",
expiry_ns,
);
let put_option = future_option_with_expiration(
"ESH4P150.GLBX",
"ESH4P150",
"ESH4",
OptionKind::Put,
"150.00",
expiry_ns,
);
let target_call_option = future_option_with_expiration(
"ESH4C152.GLBX",
"ESH4C152",
"ESH4",
OptionKind::Call,
"152.00",
expiry_ns,
);
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
cache
.borrow_mut()
.add_instrument(InstrumentAny::FuturesContract(future))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::FuturesContract(reference_future.clone()))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(call_option.clone()))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(put_option.clone()))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(target_call_option.clone()))
.unwrap();
let call_quote = QuoteTick::new(
call_option.id(),
Price::from("8.50"),
Price::from("8.50"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
let put_quote = QuoteTick::new(
put_option.id(),
Price::from("3.33"),
Price::from("3.33"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
let target_call_quote = QuoteTick::new(
target_call_option.id(),
Price::from("6.75"),
Price::from("6.75"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
let reference_future_quote = QuoteTick::new(
reference_future.id(),
Price::from("155.00"),
Price::from("155.00"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
cache.borrow_mut().add_quote(call_quote).unwrap();
cache.borrow_mut().add_quote(put_quote).unwrap();
cache.borrow_mut().add_quote(target_call_quote).unwrap();
cache
.borrow_mut()
.add_quote(reference_future_quote)
.unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
calculator
.cache_futures_spread(call_option.id(), put_option.id(), reference_future.id())
.unwrap();
let greeks = calculator
.instrument_greeks(
target_call_option.id(),
Some(0.0425),
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
let expected_underlying = reference_future
.make_price(150.0 + (0.0425_f64 * (30.0 / 365.25)).exp() * (8.50 - 3.33))
.as_f64();
assert_eq!(greeks.underlying_price, expected_underlying);
}
#[rstest]
fn test_instrument_greeks_uses_index_price_for_index_underlying() {
let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
let call_option = future_option_with_expiration(
"ESH4C150.GLBX",
"ESH4C150",
"ESH4",
OptionKind::Call,
"150.00",
expiry_ns,
);
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
cache
.borrow_mut()
.add_instrument(InstrumentAny::FuturesContract(future))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(call_option.clone()))
.unwrap();
let call_quote = QuoteTick::new(
call_option.id(),
Price::from("8.50"),
Price::from("8.50"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
cache.borrow_mut().add_quote(call_quote).unwrap();
cache
.borrow_mut()
.add_index_price(IndexPriceUpdate::new(
InstrumentId::from("ESH4.GLBX"),
Price::from("157.25"),
now_ns,
now_ns,
))
.unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
let greeks = calculator
.instrument_greeks(
call_option.id(),
Some(0.0425),
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(greeks.underlying_price, 157.25);
}
#[rstest]
fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
let now = utc_timestamp(2024, 2, 14, 16, 0, 0);
let expiry = utc_timestamp(2024, 3, 15, 16, 0, 0);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(expiry);
let future = future_with_expiration("ESH4.GLBX", "ESH4", expiry_ns);
let call_option = future_option_with_expiration(
"ESH4C5500.GLBX",
"ESH4C5500",
"ESH4",
OptionKind::Call,
"5500.00",
expiry_ns,
);
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
cache
.borrow_mut()
.add_instrument(InstrumentAny::FuturesContract(future))
.unwrap();
cache
.borrow_mut()
.add_instrument(InstrumentAny::OptionContract(call_option.clone()))
.unwrap();
let future_quote = QuoteTick::new(
InstrumentId::from("ESH4.GLBX"),
Price::from("158.50"),
Price::from("159.50"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
cache.borrow_mut().add_quote(future_quote).unwrap();
cache
.borrow_mut()
.add_index_price(IndexPriceUpdate::new(
InstrumentId::from("ESH4.GLBX"),
Price::from("157.25"),
now_ns,
now_ns,
))
.unwrap();
let call_quote = QuoteTick::new(
call_option.id(),
Price::from("8.50"),
Price::from("8.50"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
);
cache.borrow_mut().add_quote(call_quote).unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
let greeks = calculator
.instrument_greeks(
call_option.id(),
Some(0.0425),
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(greeks.underlying_price, 159.0);
}
fn option_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
let now_ns = UnixNanos::from(now);
let option = option_with_expiration("AAPL250417C00150000.OPRA", UnixNanos::from(expiry));
let option_id = option.id();
let cache =
setup_cache_with_option_and_quotes(option, InstrumentId::from("AAPL.OPRA"), now_ns);
let clock = Rc::new(RefCell::new(VirtualClock::new()));
(GreeksCalculator::new(cache, clock), option_id, now_ns)
}
fn option_greeks_with_shocks(
calculator: &GreeksCalculator,
option_id: InstrumentId,
ts_event: UnixNanos,
spot_shock: Option<f64>,
vol_shock: Option<f64>,
time_to_expiry_shock: Option<f64>,
) -> GreeksData {
calculator
.instrument_greeks(
option_id,
None,
None,
spot_shock,
vol_shock,
time_to_expiry_shock,
None,
None,
None,
None,
Some(ts_event),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap()
}
#[rstest]
#[case::spot(Some(5.0), None, None)]
#[case::vol(None, Some(0.05), None)]
#[case::time_to_expiry(None, None, Some(0.01))]
fn test_instrument_greeks_applies_each_shock_dimension_on_its_own(
#[case] spot_shock: Option<f64>,
#[case] vol_shock: Option<f64>,
#[case] time_to_expiry_shock: Option<f64>,
) {
let (calculator, option_id, now_ns) = option_calculator();
let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
let shocked = option_greeks_with_shocks(
&calculator,
option_id,
now_ns,
spot_shock,
vol_shock,
time_to_expiry_shock,
);
assert_ne!(
scaled(shocked.price),
scaled(unshocked.price),
"a single non-zero shock must still reprice the option"
);
}
#[rstest]
fn test_instrument_greeks_leaves_the_price_unshocked_when_every_shock_is_zero() {
let (calculator, option_id, now_ns) = option_calculator();
let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
let zero_shocks = option_greeks_with_shocks(
&calculator,
option_id,
now_ns,
Some(0.0),
Some(0.0),
Some(0.0),
);
assert_eq!(scaled(zero_shocks.price), scaled(unshocked.price));
}
#[rstest]
fn test_instrument_greeks_pnl_is_price_less_the_position_open_price() {
let (calculator, option_id, now_ns) = option_calculator();
let instrument = calculator
.cache
.borrow()
.instrument(&option_id)
.cloned()
.unwrap();
let position = position_from_fill(
&instrument,
"P-GREEKS-1",
"O-GREEKS-1",
"T-GREEKS-1",
OrderSide::Buy,
1,
"9.00",
);
let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
let with_position = calculator
.instrument_greeks(
option_id,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
Some(position),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(with_position.price), scaled(unshocked.price));
assert_eq!(
scaled(with_position.pnl),
scaled(unshocked.price - 9.0),
"pnl must subtract the position open price from the option price"
);
assert_eq!(scaled(unshocked.pnl), 0.0);
}
#[rstest]
fn test_instrument_greeks_records_the_shocked_market_state() {
let (calculator, option_id, now_ns) = option_calculator();
let unshocked = option_greeks_with_shocks(&calculator, option_id, now_ns, None, None, None);
let shocked = option_greeks_with_shocks(
&calculator,
option_id,
now_ns,
Some(5.0),
Some(0.05),
Some(0.01),
);
assert_eq!(scaled(unshocked.underlying_price), 150_050_000_000_000.0);
assert_eq!(scaled(shocked.underlying_price), 155_050_000_000_000.0);
assert_eq!(scaled(shocked.vol), scaled(unshocked.vol + 0.05));
assert_eq!(
scaled(shocked.expiry_in_years),
scaled(unshocked.expiry_in_years - 0.01)
);
assert_eq!(unshocked.expiry_in_days, 30);
assert_eq!(shocked.expiry_in_days, 26);
}
#[rstest]
fn test_instrument_greeks_cost_of_carry_subtracts_the_flat_dividend_yield() {
let (calculator, option_id, now_ns) = option_calculator();
let greeks = calculator
.instrument_greeks(
option_id,
Some(0.05),
Some(0.02),
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
assert_eq!(scaled(greeks.cost_of_carry), 30_000_000_000.0);
}
#[rstest]
fn test_instrument_greeks_cost_of_carry_is_zero_without_a_dividend_yield() {
let (calculator, option_id, now_ns) = option_calculator();
let greeks = calculator
.instrument_greeks(
option_id,
Some(0.05),
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(greeks.cost_of_carry), 0.0);
}
#[rstest]
fn test_instrument_greeks_reports_the_option_kind() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let expiry = now + jiff::SignedDuration::from_hours(24 * 30);
let now_ns = UnixNanos::from(now);
let underlying_id = InstrumentId::from("AAPL.OPRA");
let mut put = option_with_expiration("AAPL250417P00150000.OPRA", UnixNanos::from(expiry));
put.option_kind = OptionKind::Put;
let put_id = put.id();
let cache = setup_cache_with_option_and_quotes(put, underlying_id, now_ns);
cache
.borrow_mut()
.add_quote(QuoteTick::new(
put_id,
Price::from("10.50"),
Price::from("10.60"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
))
.unwrap();
let calculator = GreeksCalculator::new(cache, Rc::new(RefCell::new(VirtualClock::new())));
let greeks = option_greeks_with_shocks(&calculator, put_id, now_ns, None, None, None);
assert!(!greeks.is_call);
}
fn equity_calculator() -> (GreeksCalculator, InstrumentId, UnixNanos) {
let now_ns = UnixNanos::from(utc_timestamp(2025, 3, 8, 12, 0, 0));
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
let equity = equity_aapl_opra();
let equity_id = equity.id();
cache
.borrow_mut()
.add_instrument(InstrumentAny::Equity(equity))
.unwrap();
cache
.borrow_mut()
.add_quote(QuoteTick::new(
equity_id,
Price::from("150.00"),
Price::from("150.10"),
Quantity::from(100),
Quantity::from(100),
now_ns,
now_ns,
))
.unwrap();
let clock = Rc::new(RefCell::new(VirtualClock::new()));
(GreeksCalculator::new(cache, clock), equity_id, now_ns)
}
#[rstest]
fn test_instrument_greeks_for_a_non_option_shocks_the_spot_and_prices_the_position() {
let (calculator, equity_id, now_ns) = equity_calculator();
let instrument = calculator
.cache
.borrow()
.instrument(&equity_id)
.cloned()
.unwrap();
let position = position_from_fill(
&instrument,
"P-EQUITY-1",
"O-EQUITY-1",
"T-EQUITY-1",
OrderSide::Buy,
10,
"140.00",
);
let greeks = calculator
.instrument_greeks(
equity_id,
None,
None,
Some(5.0),
None,
None,
None,
None,
None,
None,
Some(now_ns),
Some(position),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(greeks.pnl), 15_050_000_000_000.0);
assert_eq!(scaled(greeks.price), 15_050_000_000_000.0);
}
#[rstest]
fn test_instrument_greeks_for_a_non_option_scales_percent_delta_by_the_shocked_spot() {
let (calculator, equity_id, now_ns) = equity_calculator();
let greeks = calculator
.instrument_greeks(
equity_id,
None,
None,
Some(5.0),
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
Some(true),
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(greeks.delta), 1_550_500_000_000.0);
}
#[rstest]
fn test_instrument_greeks_cost_of_carry_prefers_the_dividend_curve_over_the_flat_yield() {
let (calculator, option_id, now_ns) = option_calculator();
calculator
.cache
.borrow_mut()
.add_yield_curve(YieldCurve::new(
"AAPL.OPRA".to_string(),
vec![0.0, 5.0, 10.0],
vec![0.02, 0.02, 0.02],
now_ns,
now_ns,
))
.unwrap();
let greeks = calculator
.instrument_greeks(
option_id,
Some(0.05),
Some(0.04),
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(greeks.interest_rate), 50_000_000_000.0);
assert_eq!(
scaled(greeks.cost_of_carry),
30_000_000_000.0,
"the dividend curve must take precedence over the flat dividend yield"
);
}
fn scaled(value: f64) -> f64 {
(value * 1e12).round()
}
#[rstest]
fn test_modify_greeks_reprices_the_index_when_the_underlying_is_shocked() {
let calculator = create_test_calculator();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let mut beta_weights = HashMap::new();
beta_weights.insert(underlying_id, 0.5);
let (delta, gamma, vega) = calculator
.modify_greeks(
1.0,
2.0,
underlying_id,
165.0,
150.0,
false,
None,
Some(&beta_weights),
3.0,
0.30,
0,
None,
0.0,
None,
None,
Some(200.0),
None,
)
.unwrap();
assert_eq!(scaled(delta), 343_750_000_000.0);
assert_eq!(scaled(gamma), 236_328_125_000.0);
assert_eq!(scaled(vega), 3_000_000_000_000.0);
}
#[rstest]
fn test_modify_greeks_reprices_the_vol_index_when_the_vol_is_shocked() {
let calculator = create_test_calculator();
let underlying_id = InstrumentId::from("AAPL.OPRA");
let mut vol_beta_weights = HashMap::new();
vol_beta_weights.insert(underlying_id, 0.75);
let (delta, gamma, vega) = calculator
.modify_greeks(
1.0,
2.0,
underlying_id,
150.0,
150.0,
false,
None,
None,
2.0,
0.35,
0,
None,
0.30,
None,
Some(&vol_beta_weights),
None,
Some(25.0),
)
.unwrap();
assert_eq!(scaled(delta), 1_000_000_000_000.0);
assert_eq!(scaled(gamma), 2_000_000_000_000.0);
assert_eq!(scaled(vega), 1_718_181_818_182.0);
}
#[rstest]
fn test_modify_greeks_uses_the_shocked_vol_as_the_baseline_when_unshocked_vol_is_zero() {
let calculator = create_test_calculator();
let (_, _, vega) = calculator
.modify_greeks(
1.0,
2.0,
InstrumentId::from("AAPL.OPRA"),
150.0,
150.0,
false,
None,
None,
2.0,
0.40,
0,
None,
0.0,
None,
None,
None,
Some(25.0),
)
.unwrap();
assert_eq!(scaled(vega), 3_200_000_000_000.0);
}
#[rstest]
fn test_modify_greeks_leaves_vega_untouched_for_a_zero_vol_index() {
let calculator = create_test_calculator();
let (_, _, vega) = calculator
.modify_greeks(
1.0,
2.0,
InstrumentId::from("AAPL.OPRA"),
150.0,
150.0,
false,
None,
None,
2.0,
0.40,
0,
None,
0.0,
None,
None,
None,
Some(0.0),
)
.unwrap();
assert_eq!(scaled(vega), 2_000_000_000_000.0);
}
#[rstest]
fn test_modify_greeks_percent_scaling_falls_back_to_the_underlying_and_vol() {
let calculator = create_test_calculator();
let (delta, gamma, vega) = calculator
.modify_greeks(
1.0,
2.0,
InstrumentId::from("AAPL.OPRA"),
150.0,
150.0,
true,
None,
None,
2.0,
0.30,
0,
None,
0.0,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(delta), 1_500_000_000_000.0);
assert_eq!(scaled(gamma), 4_500_000_000_000.0);
assert_eq!(scaled(vega), 6_000_000_000.0);
}
#[rstest]
fn test_modify_greeks_applies_vega_time_weighting() {
let calculator = create_test_calculator();
let (_, _, vega) = calculator
.modify_greeks(
1.0,
2.0,
InstrumentId::from("AAPL.OPRA"),
150.0,
150.0,
false,
None,
None,
2.0,
0.30,
120,
Some(30),
0.0,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(vega), 1_000_000_000_000.0);
}
#[rstest]
fn test_modify_greeks_skips_vega_time_weighting_without_days_to_expiry() {
let calculator = create_test_calculator();
let (_, _, vega) = calculator
.modify_greeks(
1.0,
2.0,
InstrumentId::from("AAPL.OPRA"),
150.0,
150.0,
false,
None,
None,
2.0,
0.30,
0,
Some(30),
0.0,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(scaled(vega), 2_000_000_000_000.0);
}
const SPREAD_CALL_ID: &str = "ESH4C150.GLBX";
const SPREAD_PUT_ID: &str = "ESH4P150.GLBX";
const SPREAD_REFERENCE_ID: &str = "ESM4.GLBX";
const SPREAD_UNDERLYING_ID: &str = "ESH4.GLBX";
fn spread_now_ns() -> UnixNanos {
UnixNanos::from(utc_timestamp(2024, 2, 14, 16, 0, 0))
}
fn spread_expiry_ns() -> UnixNanos {
UnixNanos::from(utc_timestamp(2024, 3, 15, 16, 0, 0))
}
fn spread_call() -> OptionContract {
future_option_with_expiration(
SPREAD_CALL_ID,
"ESH4C150",
"ESH4",
OptionKind::Call,
"150.00",
spread_expiry_ns(),
)
}
fn spread_put() -> OptionContract {
future_option_with_expiration(
SPREAD_PUT_ID,
"ESH4P150",
"ESH4",
OptionKind::Put,
"150.00",
spread_expiry_ns(),
)
}
fn spread_reference_future() -> FuturesContract {
future_with_expiration(SPREAD_REFERENCE_ID, "ESM4", spread_expiry_ns())
}
fn spread_quote(instrument_id: InstrumentId, price: &str) -> QuoteTick {
let price = Price::from(price);
QuoteTick::new(
instrument_id,
price,
price,
Quantity::from(100),
Quantity::from(100),
spread_now_ns(),
spread_now_ns(),
)
}
fn spread_calculator(
instruments: Vec<InstrumentAny>,
quotes: Vec<QuoteTick>,
) -> GreeksCalculator {
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
for instrument in instruments {
cache.borrow_mut().add_instrument(instrument).unwrap();
}
for quote in quotes {
cache.borrow_mut().add_quote(quote).unwrap();
}
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(spread_now_ns());
GreeksCalculator::new(cache, clock)
}
fn spread_instruments() -> Vec<InstrumentAny> {
vec![
InstrumentAny::OptionContract(spread_call()),
InstrumentAny::OptionContract(spread_put()),
InstrumentAny::FuturesContract(spread_reference_future()),
]
}
fn spread_quotes() -> Vec<QuoteTick> {
vec![
spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
]
}
fn cache_spread_error(instruments: Vec<InstrumentAny>, quotes: Vec<QuoteTick>) -> String {
spread_calculator(instruments, quotes)
.cache_futures_spread(
InstrumentId::from(SPREAD_CALL_ID),
InstrumentId::from(SPREAD_PUT_ID),
InstrumentId::from(SPREAD_REFERENCE_ID),
)
.unwrap_err()
.to_string()
}
#[rstest]
fn test_cache_futures_spread_errors_when_call_instrument_missing() {
let instruments = vec![
InstrumentAny::OptionContract(spread_put()),
InstrumentAny::FuturesContract(spread_reference_future()),
];
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: missing option instrument ESH4C150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_put_instrument_missing() {
let instruments = vec![
InstrumentAny::OptionContract(spread_call()),
InstrumentAny::FuturesContract(spread_reference_future()),
];
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: missing option instrument ESH4P150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_reference_future_instrument_missing() {
let instruments = vec![
InstrumentAny::OptionContract(spread_call()),
InstrumentAny::OptionContract(spread_put()),
];
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: no reference futures instrument for ESM4.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_call_leg_is_not_an_option() {
let non_option = future_with_expiration(SPREAD_CALL_ID, "ESH4", spread_expiry_ns());
let instruments = vec![
InstrumentAny::FuturesContract(non_option),
InstrumentAny::OptionContract(spread_put()),
InstrumentAny::FuturesContract(spread_reference_future()),
];
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: non-option instruments provided \
call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_legs_are_not_a_call_put_pair() {
let second_put = future_option_with_expiration(
SPREAD_CALL_ID,
"ESH4P150",
"ESH4",
OptionKind::Put,
"150.00",
spread_expiry_ns(),
);
let instruments = vec![
InstrumentAny::OptionContract(second_put),
InstrumentAny::OptionContract(spread_put()),
InstrumentAny::FuturesContract(spread_reference_future()),
];
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: expected call/put pair \
call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_underlyings_differ() {
let mismatched_put = future_option_with_expiration(
SPREAD_PUT_ID,
"ESM4P150",
"ESM4",
OptionKind::Put,
"150.00",
spread_expiry_ns(),
);
let instruments = vec![
InstrumentAny::OptionContract(spread_call()),
InstrumentAny::OptionContract(mismatched_put),
InstrumentAny::FuturesContract(spread_reference_future()),
];
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: option underlyings differ \
call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_strike_prices_differ() {
let mismatched_put = future_option_with_expiration(
SPREAD_PUT_ID,
"ESH4P155",
"ESH4",
OptionKind::Put,
"155.00",
spread_expiry_ns(),
);
let instruments = vec![
InstrumentAny::OptionContract(spread_call()),
InstrumentAny::OptionContract(mismatched_put),
InstrumentAny::FuturesContract(spread_reference_future()),
];
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: strike prices differ \
call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_expirations_differ() {
let mismatched_put = future_option_with_expiration(
SPREAD_PUT_ID,
"ESH4P150",
"ESH4",
OptionKind::Put,
"150.00",
UnixNanos::from(utc_timestamp(2024, 6, 21, 16, 0, 0)),
);
let instruments = vec![
InstrumentAny::OptionContract(spread_call()),
InstrumentAny::OptionContract(mismatched_put),
InstrumentAny::FuturesContract(spread_reference_future()),
];
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: expiration dates differ \
call_instrument_id=ESH4C150.GLBX put_instrument_id=ESH4P150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_reference_future_price_missing() {
let quotes = vec![
spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
];
assert_eq!(
cache_spread_error(spread_instruments(), quotes),
"Cannot cache futures spread: no reference futures price for ESM4.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_call_price_missing() {
let quotes = vec![
spread_quote(InstrumentId::from(SPREAD_PUT_ID), "3.33"),
spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
];
assert_eq!(
cache_spread_error(spread_instruments(), quotes),
"Cannot cache futures spread: missing option price for ESH4C150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_put_price_missing() {
let quotes = vec![
spread_quote(InstrumentId::from(SPREAD_CALL_ID), "8.50"),
spread_quote(InstrumentId::from(SPREAD_REFERENCE_ID), "155.00"),
];
assert_eq!(
cache_spread_error(spread_instruments(), quotes),
"Cannot cache futures spread: missing option price for ESH4P150.GLBX"
);
}
#[rstest]
fn test_cache_futures_spread_errors_when_cached_underlying_is_not_a_future() {
let underlying_equity = Equity::builder()
.instrument_id(InstrumentId::from(SPREAD_UNDERLYING_ID))
.raw_symbol(Symbol::from("ESH4"))
.currency(Currency::from("USD"))
.price_precision(2)
.price_increment(Price::from("0.01"))
.ts_event(UnixNanos::default())
.ts_init(UnixNanos::default())
.build()
.unwrap();
let mut instruments = spread_instruments();
instruments.push(InstrumentAny::Equity(underlying_equity));
assert_eq!(
cache_spread_error(instruments, spread_quotes()),
"Cannot cache futures spread: underlying ESH4.GLBX is not a futures contract"
);
}
#[rstest]
fn test_cache_futures_spread_leaves_no_entry_when_validation_fails() {
let calculator = spread_calculator(
vec![
InstrumentAny::OptionContract(spread_call()),
InstrumentAny::OptionContract(spread_put()),
],
spread_quotes(),
);
calculator
.cache_futures_spread(
InstrumentId::from(SPREAD_CALL_ID),
InstrumentId::from(SPREAD_PUT_ID),
InstrumentId::from(SPREAD_REFERENCE_ID),
)
.unwrap_err();
assert_eq!(
calculator.get_cached_futures_spread_price(InstrumentId::from(SPREAD_UNDERLYING_ID)),
None
);
}
#[rstest]
fn test_resolve_underlying_instrument_id_prefers_cached_futures_spread_over_cross_venue_match()
{
let mut instruments = spread_instruments();
instruments.push(InstrumentAny::FuturesContract(future_with_expiration(
"ESH4.XCME",
"ESH4",
spread_expiry_ns(),
)));
let calculator = spread_calculator(instruments, spread_quotes());
calculator
.cache_futures_spread(
InstrumentId::from(SPREAD_CALL_ID),
InstrumentId::from(SPREAD_PUT_ID),
InstrumentId::from(SPREAD_REFERENCE_ID),
)
.unwrap();
let underlying_id = calculator
.resolve_underlying_instrument_id(
&InstrumentAny::OptionContract(spread_call()),
InstrumentId::from(SPREAD_CALL_ID),
)
.unwrap();
assert_eq!(underlying_id, InstrumentId::from(SPREAD_UNDERLYING_ID));
}
#[rstest]
fn test_get_cached_futures_spread_price_returns_none_for_unknown_underlying() {
let calculator = spread_calculator(spread_instruments(), spread_quotes());
assert_eq!(
calculator.get_cached_futures_spread_price(InstrumentId::from("CLZ4.NYMEX")),
None
);
}
#[cfg(feature = "defi")]
#[rstest]
fn test_instrument_greeks_converts_mid_of_max_float_precision_quote() {
let now = utc_timestamp(2025, 3, 8, 12, 0, 0);
let now_ns = UnixNanos::from(now);
let expiry_ns = UnixNanos::from(now + jiff::SignedDuration::from_hours(24 * 30));
let future = future_with_expiration("ES.GLBX", "ES", expiry_ns);
let instrument = InstrumentAny::FuturesContract(future.clone());
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
cache
.borrow_mut()
.add_instrument(instrument.clone())
.unwrap();
let quote = QuoteTick::new(
future.id(),
Price::from("100.0000000000000000"),
Price::from("100.0000000000000001"),
Quantity::from(1),
Quantity::from(1),
now_ns,
now_ns,
);
cache.borrow_mut().add_quote(quote).unwrap();
let position =
position_from_fill(&instrument, "P-1", "O-1", "T-1", OrderSide::Buy, 1, "99.00");
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(cache, clock);
let greeks = calculator
.instrument_greeks(
future.id(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(now_ns),
Some(position),
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(greeks.delta, 1.0);
assert_eq!(greeks.pnl, 1.0);
assert_eq!(greeks.price, 1.0);
}
#[rstest]
#[case(false, -1)]
#[case(false, 0)]
#[case(true, -1)]
#[case(true, 0)]
fn test_instrument_greeks_rejects_expired_options_before_cached_return(
#[case] cached: bool,
#[case] offset_ns: i64,
#[values(false, true)] clock_default: bool,
) {
let now_ns = UnixNanos::from(1_000_000_000);
let expiry_ns = UnixNanos::from((1_000_000_000_i64 + offset_ns) as u64);
let option = option_with_expiration("AAPL250417C00150000.OPRA", expiry_ns);
let id = option.id();
let calculator = calculator_with_option_and_equities(option, &[]);
let clock = Rc::new(RefCell::new(VirtualClock::new()));
clock.borrow_mut().set_time(now_ns);
let calculator = GreeksCalculator::new(calculator.cache, clock);
if cached {
let mut data = GreeksData::from_delta(id, 0.5, 100.0, now_ns);
data.underlying_price = 150.0;
data.strike = 150.0;
data.vol = 0.2;
data.expiry_in_years = 1.0;
calculator.cache.borrow_mut().add_greeks(data).unwrap();
}
let mut params = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now_ns)
.use_cached_greeks(cached)
.build();
if clock_default {
params.ts_event = None;
}
let result = params.calculate(&calculator).unwrap_err();
assert_eq!(
result.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::Expired(id))
);
}
#[rstest]
#[case(InstrumentAny::OptionSpread(option_spread()))]
#[case(InstrumentAny::CryptoOptionSpread(crypto_option_spread_btc_deribit()))]
fn test_instrument_greeks_rejects_option_spreads(#[case] instrument: InstrumentAny) {
let id = instrument.id();
let calculator = create_test_calculator();
calculator
.cache
.borrow_mut()
.add_instrument(instrument)
.unwrap();
let result = InstrumentGreeksParams::builder()
.instrument_id(id)
.build()
.calculate(&calculator)
.unwrap_err();
assert_eq!(
result.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::UnsupportedSpread(id))
);
}
#[rstest]
#[case(-200.0, 0.0, 0.0, "underlying price")]
#[case(0.0, -2.0, 0.0, "volatility")]
#[case(0.0, 0.0, 1.0, "time to expiry")]
#[case(f64::NAN, 0.0, 0.0, "spot shock")]
#[case(0.0, f64::INFINITY, 0.0, "volatility shock")]
#[case(0.0, 0.0, f64::NAN, "time to expiry shock")]
fn test_instrument_greeks_rejects_invalid_shocks_without_caching(
#[case] spot: f64,
#[case] vol: f64,
#[case] time: f64,
#[case] input: &'static str,
) {
let (calculator, id, now) = option_calculator();
let result = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.spot_shock(spot)
.vol_shock(vol)
.time_to_expiry_shock(time)
.cache_greeks(true)
.build()
.calculate(&calculator)
.unwrap_err();
assert_eq!(
result.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::InvalidInput(input))
);
assert!(calculator.cache.borrow().greeks(&id).is_none());
}
#[rstest]
fn test_instrument_greeks_rejects_unattainable_price() {
let (calculator, id, now) = option_calculator();
calculator
.cache
.borrow_mut()
.add_quote(QuoteTick::new(
id,
Price::from("200.00"),
Price::from("201.00"),
Quantity::from(100),
Quantity::from(100),
now,
now,
))
.unwrap();
let result = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.cache_greeks(true)
.build()
.calculate(&calculator)
.unwrap_err();
assert_eq!(
result.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::ImpliedVolatilityUnavailable)
);
assert!(calculator.cache.borrow().greeks(&id).is_none());
}
#[rstest]
#[case(false)]
#[case(true)]
fn test_instrument_greeks_rho_uses_explicit_carry_source(#[case] dividend_linked: bool) {
let (calculator, id, now) = option_calculator();
let mut params = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.flat_interest_rate(0.05)
.build();
params.flat_dividend_yield = dividend_linked.then_some(0.05);
let greeks = params.calculate(&calculator).unwrap();
let expected = if dividend_linked {
greeks.expiry_in_years
* greeks.strike
* (-greeks.interest_rate * greeks.expiry_in_years).exp()
* greeks.itm_prob
* 0.01
} else {
-greeks.expiry_in_years * greeks.price * 0.01
};
assert_eq!(greeks.cost_of_carry, 0.0);
assert!((greeks.rho - expected).abs() < 1e-12);
}
#[rstest]
fn test_portfolio_greeks_propagates_invalid_valuation() {
let (calculator, position, now) = portfolio_option_calculator();
let id = position.instrument_id;
calculator
.cache
.borrow_mut()
.add_quote(QuoteTick::new(
id,
Price::from("200.00"),
Price::from("201.00"),
Quantity::from(100),
Quantity::from(100),
now,
now,
))
.unwrap();
let result = PortfolioGreeksParams::builder()
.instrument_id(id)
.build()
.calculate(&calculator)
.unwrap_err();
assert_eq!(
result.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::ImpliedVolatilityUnavailable)
);
}
#[rstest]
#[case::unshocked(0.0)]
#[case::shocked(1.0)]
fn test_instrument_greeks_cached_valuation_does_not_publish(#[case] spot_shock: f64) {
msgbus::set_message_bus(Rc::new(RefCell::new(msgbus::MessageBus::default())));
let (calculator, id, now) = option_calculator();
let base = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.cache_greeks(true)
.build()
.calculate(&calculator)
.unwrap();
let observed = Rc::new(RefCell::new(Vec::new()));
let captured = Rc::clone(&observed);
calculator.subscribe_greeks(
"AAPL",
Some(move |data: &GreeksData| {
captured.borrow_mut().push(data.clone());
}),
);
let result = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(UnixNanos::from(now.as_u64() + 1_000_000_000))
.use_cached_greeks(true)
.spot_shock(spot_shock)
.cache_greeks(true)
.publish_greeks(true)
.build()
.calculate(&calculator)
.unwrap();
let cached = calculator.cache.borrow().greeks(&id).unwrap();
assert!(observed.borrow().is_empty());
assert_greeks_eq(&cached, &base);
assert_eq!(result.underlying_price, base.underlying_price + spot_shock);
assert_eq!(result.ts_event, base.ts_event);
}
#[rstest]
fn test_instrument_greeks_rejects_invalid_shocks_without_publishing() {
let (calculator, id, now) = option_calculator();
let observed = Rc::new(RefCell::new(Vec::new()));
let captured = Rc::clone(&observed);
calculator.subscribe_greeks(
"AAPL",
Some(move |data: &GreeksData| {
captured.borrow_mut().push(data.clone());
}),
);
let mut params = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.spot_shock(-200.0)
.cache_greeks(true)
.publish_greeks(true)
.build();
let e = params.calculate(&calculator).unwrap_err();
assert_eq!(
e.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::InvalidInput("underlying price"))
);
assert_eq!(observed.borrow().len(), 0);
assert!(calculator.cache.borrow().greeks(&id).is_none());
params.spot_shock = 1.0;
let result = params.calculate(&calculator).unwrap();
let cached = calculator.cache.borrow().greeks(&id).unwrap();
let observed = observed.borrow();
assert_eq!(observed.len(), 1);
assert_greeks_eq(&observed[0], &cached);
assert_eq!(cached.underlying_price, 150.05);
assert_eq!(result.underlying_price, 151.05);
}
#[rstest]
#[case("probability below zero", -0.1)]
#[case("probability above one", 1.1)]
#[case("price", -1.0)]
#[case("multiplier", f64::NAN)]
#[case("quantity", f64::INFINITY)]
fn test_instrument_greeks_rejects_invalid_cached_results(
#[case] field: &str,
#[case] value: f64,
) {
let (calculator, id, now) = option_calculator();
let mut data = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.build()
.calculate(&calculator)
.unwrap();
match field {
"probability below zero" | "probability above one" => data.itm_prob = value,
"price" => data.price = value,
"multiplier" => data.multiplier = value,
"quantity" => data.quantity = value,
_ => unreachable!(),
}
calculator.cache.borrow_mut().add_greeks(data).unwrap();
let error = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.use_cached_greeks(true)
.build()
.calculate(&calculator)
.unwrap_err();
assert_eq!(
error.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::InvalidResult)
);
}
#[rstest]
fn test_instrument_greeks_rejects_invalid_cached_volatility_seed() {
let (calculator, id, now) = option_calculator();
let mut data = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.build()
.calculate(&calculator)
.unwrap();
data.vol = f64::NAN;
calculator.cache.borrow_mut().add_greeks(data).unwrap();
let error = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.update_vol(true)
.build()
.calculate(&calculator)
.unwrap_err();
assert_eq!(
error.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::InvalidInput("volatility"))
);
}
#[rstest]
fn test_portfolio_greeks_rejects_overflow_after_scaling() {
let (calculator, position, now) = portfolio_option_calculator();
let id = position.instrument_id;
let mut data = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.build()
.calculate(&calculator)
.unwrap();
data.multiplier = f64::MAX;
calculator.cache.borrow_mut().add_greeks(data).unwrap();
let error = PortfolioGreeksParams::builder()
.instrument_id(id)
.use_cached_greeks(true)
.build()
.calculate(&calculator)
.unwrap_err();
assert_eq!(
error.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::InvalidResult)
);
}
#[rstest]
fn test_instrument_greeks_projects_extreme_decimal_price() {
let (calculator, mut position, now) = portfolio_option_calculator();
let id = position.instrument_id;
position.avg_px_open = Decimal::MAX;
let observed = Rc::new(RefCell::new(Vec::new()));
let captured = Rc::clone(&observed);
calculator.subscribe_greeks(
"AAPL",
Some(move |data: &GreeksData| {
captured.borrow_mut().push(data.clone());
}),
);
let result = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.position(position)
.spot_shock(1.0)
.cache_greeks(true)
.publish_greeks(true)
.build()
.calculate(&calculator)
.unwrap();
assert_eq!(result.pnl, result.price - Decimal::MAX.as_f64());
let cached = calculator.cache.borrow().greeks(&id).unwrap();
assert_eq!(observed.borrow().len(), 1);
assert_greeks_eq(&observed.borrow()[0], &cached);
}
#[rstest]
fn test_instrument_greeks_rho_uses_dividend_curve_source() {
let (calculator, id, now) = option_calculator();
calculator
.cache
.borrow_mut()
.add_yield_curve(YieldCurve::new(
"AAPL.OPRA".to_string(),
vec![0.0],
vec![0.05],
now,
now,
))
.unwrap();
let result = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.flat_interest_rate(0.05)
.build()
.calculate(&calculator)
.unwrap();
assert_eq!(result.cost_of_carry, 0.0);
let reference = result.expiry_in_years
* result.strike
* (-result.interest_rate * result.expiry_in_years).exp()
* result.itm_prob
* 0.01;
assert!((result.rho - reference).abs() < 1e-12);
}
#[rstest]
fn test_instrument_greeks_shocked_cache_uses_current_carry_sensitivity() {
let (calculator, id, now) = option_calculator();
let base = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.cache_greeks(true)
.build()
.calculate(&calculator)
.unwrap();
let mut params = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.use_cached_greeks(true)
.spot_shock(1.0)
.build();
params.flat_dividend_yield = Some(base.interest_rate);
let result = params.calculate(&calculator).unwrap();
assert_eq!(result.cost_of_carry, 0.0);
let reference = result.expiry_in_years
* result.strike
* (-result.interest_rate * result.expiry_in_years).exp()
* result.itm_prob
* 0.01;
assert!((result.rho - reference).abs() < 1e-12);
assert_eq!(base.rho, -base.expiry_in_years * base.price * 0.01);
}
#[rstest]
#[case(false)]
#[case(true)]
fn test_instrument_greeks_rejects_nonfinite_transformed_sensitivities(
#[case] beta_weight: bool,
) {
let (calculator, id, now) = option_calculator();
let mut params = InstrumentGreeksParams::builder()
.instrument_id(id)
.ts_event(now)
.cache_greeks(true)
.build();
if beta_weight {
let underlying = InstrumentId::from("AAPL.OPRA");
params.index_instrument_id = Some(underlying);
params.beta_weights = Some(HashMap::from([(underlying, f64::NAN)]));
} else {
params.vega_time_weight_base = Some(-1);
}
let e = params.calculate(&calculator).unwrap_err();
assert_eq!(
e.downcast_ref::<OptionValuationError>(),
Some(&OptionValuationError::InvalidResult)
);
assert!(calculator.cache.borrow().greeks(&id).is_none());
}
}