use std::{cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc};
use ahash::AHashMap;
use nautilus_core::UnixNanos;
use nautilus_model::{
data::greeks::{
GreeksData, OptionGreekValues, PortfolioGreeks, black_scholes_greeks, imply_vol_and_greeks,
refine_vol_and_greeks,
},
enums::{AssetClass, InstrumentClass, OptionKind, PositionSide, PriceType},
identifiers::{InstrumentId, StrategyId, Venue},
instruments::{Instrument, any::InstrumentAny},
position::Position,
types::Price,
};
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)>>,
}
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()),
}
}
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::Option {
return self.calculate_non_option_greeks(
&instrument,
instrument_id,
spot_shock,
ts_event,
position,
percent_greeks,
index_instrument_id,
beta_weights,
);
}
let underlying_instrument_id =
Self::resolve_underlying_instrument_id(&instrument, instrument_id)?;
let mut greeks_data = self.calculate_option_greeks(
&instrument,
instrument_id,
underlying_instrument_id,
flat_interest_rate,
flat_dividend_yield,
use_cached_greeks,
update_vol,
cache_greeks,
publish_greeks,
ts_event,
percent_greeks,
index_instrument_id,
beta_weights,
vega_time_weight_base,
vol_index_instrument_id,
vol_beta_weights,
)?;
if spot_shock != 0.0 || vol_shock != 0.0 || time_to_expiry_shock != 0.0 {
greeks_data = self.apply_option_greeks_shocks(
&greeks_data,
underlying_instrument_id,
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,
)?;
}
if let Some(pos) = position {
greeks_data.pnl = greeks_data.price - pos.avg_px_open;
}
Ok(greeks_data)
}
fn resolve_underlying_instrument_id(
instrument: &InstrumentAny,
instrument_id: InstrumentId,
) -> anyhow::Result<InstrumentId> {
let Some(underlying) = instrument.underlying() else {
anyhow::bail!("Instrument {instrument_id} has no underlying identifier");
};
Ok(InstrumentId::from(format!(
"{}.{}",
underlying, instrument_id.venue
)))
}
#[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;
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,
cache_greeks: bool,
publish_greeks: 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> {
if use_cached_greeks {
let cache = self.cache.borrow();
if let Some(cached_greeks) = cache.greeks(&instrument_id) {
return Ok(cached_greeks);
}
}
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 mut cost_of_carry = 0.0;
if let Some(dividend_curve) = dividend_curve {
cost_of_carry = interest_rate - dividend_curve(expiry_in_years);
} else if let Some(div_yield) = flat_dividend_yield {
cost_of_carry = interest_rate - div_yield;
}
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 greeks = if update_vol {
let cached_greeks = self.cache.borrow().greeks(&instrument_id);
match cached_greeks {
Some(cached_greeks) => refine_vol_and_greeks(
underlying_price,
interest_rate,
cost_of_carry,
is_call,
strike,
expiry_in_years,
option_price,
cached_greeks.vol,
),
None => imply_vol_and_greeks(
underlying_price,
interest_rate,
cost_of_carry,
is_call,
strike,
expiry_in_years,
option_price,
),
}
} else {
imply_vol_and_greeks(
underlying_price,
interest_rate,
cost_of_carry,
is_call,
strike,
expiry_in_years,
option_price,
)
};
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(
utc_now_ns,
utc_now_ns,
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: 0.0,
},
greeks.itm_prob,
);
if cache_greeks {
let mut cache = self.cache.borrow_mut();
cache.add_greeks(greeks_data.clone()).unwrap_or_default();
}
if publish_greeks {
let topic = format!(
"data.GreeksData.instrument_id={}",
instrument_id.symbol.as_str()
)
.into();
msgbus::publish_greeks(topic, &greeks_data);
}
Ok(greeks_data)
}
#[expect(clippy::too_many_arguments)]
fn apply_option_greeks_shocks(
&self,
greeks_data: &GreeksData,
underlying_instrument_id: InstrumentId,
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(
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,
);
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.ts_event,
greeks_data.ts_event,
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: 0.0,
},
greeks.itm_prob,
))
}
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(ts_event, ts_event, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
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 cache = self.cache.borrow();
let open_positions = cache.positions_open(
venue.as_ref(),
instrument_id.as_ref(),
strategy_id.as_ref(),
None, side,
);
let open_positions: Vec<Position> =
open_positions.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;
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_greeks = quantity * &instrument_greeks;
if greeks_filter.is_none_or(|filter| filter(&position_greeks)) {
portfolio_greeks = portfolio_greeks + PortfolioGreeks::from(position_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 = format!("data.GreeksData.instrument_id={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| {
let mut cache = cache_ref.borrow_mut();
cache.add_greeks(greeks.clone()).unwrap_or_default();
});
msgbus::subscribe_greeks(pattern, typed_handler, None);
}
}
}
#[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, YieldCurveData},
enums::{AssetClass, OmsType, OptionKind, OrderSide, PositionSide},
events::order::spec::OrderFilledSpec,
identifiers::{
ClientOrderId, InstrumentId, PositionId, StrategyId, Symbol, TradeId, Venue,
},
instruments::{Equity, FuturesContract, OptionContract, any::InstrumentAny},
types::{Currency, Price, Quantity},
};
use rstest::rstest;
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}"
);
}
#[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 = GreeksCalculator::resolve_underlying_instrument_id(
&instrument,
InstrumentId::from("AAPL.OPRA"),
)
.unwrap_err();
assert_eq!(
error.to_string(),
"Instrument AAPL.OPRA has no underlying identifier"
);
}
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]
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 = PortfolioGreeks::from(open_position.signed_qty * &expected);
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(now_ns, now_ns, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
);
}
#[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 = PortfolioGreeks::from(long_position.signed_qty * &long_greeks);
let expected_short = PortfolioGreeks::from(short_position.signed_qty * &short_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(),
&PortfolioGreeks::from(long_position.signed_qty * &long_greeks),
);
assert_portfolio_greeks_eq(
&calculate_portfolio_greeks(&calculator, Some(PositionSide::Short)).unwrap(),
&PortfolioGreeks::from(short_position.signed_qty * &short_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(
"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 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(YieldCurveData::new(
now_ns,
now_ns,
"AAPL.OPRA".to_string(),
vec![0.0, 5.0, 10.0],
vec![0.02, 0.02, 0.02],
))
.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_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);
}
}