use std::{cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc};
use ahash::AHashMap;
use derive_builder::Builder;
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::{cache::Cache, 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, Builder)]
#[builder(setter(into), derive(Debug))]
pub struct InstrumentGreeksParams {
pub instrument_id: InstrumentId,
#[builder(default = "0.0425")]
pub flat_interest_rate: f64,
#[builder(default)]
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)]
pub ts_event: Option<UnixNanos>,
#[builder(default)]
pub position: Option<Position>,
#[builder(default = "false")]
pub percent_greeks: bool,
#[builder(default)]
pub index_instrument_id: Option<InstrumentId>,
#[builder(default)]
pub beta_weights: Option<HashMap<InstrumentId, f64>>,
#[builder(default)]
pub vega_time_weight_base: Option<i32>,
}
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,
)
}
}
#[derive(Builder)]
#[builder(setter(into))]
pub struct PortfolioGreeksParams {
#[builder(default)]
pub underlyings: Option<Vec<String>>,
#[builder(default)]
pub venue: Option<Venue>,
#[builder(default)]
pub instrument_id: Option<InstrumentId>,
#[builder(default)]
pub strategy_id: Option<StrategyId>,
#[builder(default)]
pub side: Option<PositionSide>,
#[builder(default = "0.0425")]
pub flat_interest_rate: f64,
#[builder(default)]
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,
#[builder(default)]
pub index_instrument_id: Option<InstrumentId>,
#[builder(default)]
pub beta_weights: Option<HashMap<InstrumentId, f64>>,
#[builder(default)]
pub greeks_filter: Option<GreeksFilterCallback>,
#[builder(default)]
pub vega_time_weight_base: Option<i32>,
}
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)
.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,
)
}
}
#[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()),
}
}
#[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>,
) -> 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();
match cache.instrument(&instrument_id) {
Some(instrument) => instrument.clone(),
None => anyhow::bail!("Instrument definition for {instrument_id} not found"),
}
};
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.underlying().unwrap();
let underlying_str = format!("{}.{}", underlying, instrument_id.venue);
let underlying_instrument_id = InstrumentId::from(underlying_str);
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,
)?;
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,
);
}
if let Some(pos) = position {
greeks_data.pnl = greeks_data.price - pos.avg_px_open;
}
Ok(greeks_data)
}
#[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,
);
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>,
) -> 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
.format("%Y%m%d")
.to_string()
.parse::<i32>()
.unwrap_or(0);
let raw_days = (expiry_utc - utc_now).num_days();
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)?;
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,
);
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>,
) -> 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,
);
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>,
) -> (f64, f64, f64) {
let mut delta = delta_input;
let mut gamma = gamma_input;
let mut vega = vega_input;
let mut index_price = None;
if let Some(index_id) = index_instrument_id {
let cache = self.cache.borrow();
index_price = Some(
cache
.price(&index_id, PriceType::Last)
.unwrap_or_default()
.as_f64(),
);
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) = index_price {
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 percent_greeks {
if let Some(idx_price) = 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);
}
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;
}
(delta, gamma, vega)
}
#[expect(clippy::too_many_arguments)]
#[expect(clippy::missing_panics_doc)] 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>,
) -> 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 side = side.unwrap_or(PositionSide::NoPositionSide);
let cache = self.cache.borrow();
let open_positions = cache.positions(
venue.as_ref(),
instrument_id.as_ref(),
strategy_id.as_ref(),
None, Some(side),
);
let open_positions: Vec<Position> = open_positions.iter().map(|&p| p.clone()).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,
)?;
let position_greeks = quantity * &instrument_greeks;
if greeks_filter.is_none() || greeks_filter.unwrap()(&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 expiry_in_days = (expiry_utc - self.clock.borrow().timestamp_ns().to_datetime_utc())
.num_days()
.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();
if let Some(instrument) = cache.instrument(instrument_id)
&& instrument.asset_class() == AssetClass::Index
{
if instrument.instrument_class() == InstrumentClass::Future {
let tradable = cache
.price(instrument_id, PriceType::Mid)
.or_else(|| cache.price(instrument_id, PriceType::Last));
if tradable.is_some() {
return tradable;
}
}
if let Some(index_price) = cache.index_price(instrument_id) {
return Some(index_price.value);
}
}
cache
.price(instrument_id, PriceType::Mid)
.or_else(|| cache.price(instrument_id, PriceType::Last))
}
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 chrono::{TimeZone, Utc};
use nautilus_model::{
data::{IndexPriceUpdate, QuoteTick},
enums::{AssetClass, OptionKind, PositionSide},
identifiers::{InstrumentId, StrategyId, Symbol, Venue},
instruments::{Equity, FuturesContract, OptionContract, any::InstrumentAny},
types::{Currency, Price, Quantity},
};
use rstest::rstest;
use ustr::Ustr;
use super::*;
use crate::{cache::Cache, clock::TestClock};
fn create_test_calculator() -> GreeksCalculator {
let cache = Rc::new(RefCell::new(Cache::new(None, None)));
let clock = Rc::new(RefCell::new(TestClock::new()));
GreeksCalculator::new(cache, clock)
}
#[rstest]
fn test_greeks_calculator_creation() {
let calculator = create_test_calculator();
assert!(format!("{calculator:?}").contains("GreeksCalculator"));
}
#[rstest]
fn test_greeks_calculator_debug() {
let calculator = create_test_calculator();
let debug_str = format!("{calculator:?}");
assert!(debug_str.contains("GreeksCalculator"));
}
#[rstest]
fn test_greeks_calculator_has_python_bindings() {
let calculator = create_test_calculator();
assert!(format!("{calculator:?}").contains("GreeksCalculator"));
}
#[rstest]
fn test_instrument_greeks_params_builder_default() {
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let params = InstrumentGreeksParamsBuilder::default()
.instrument_id(instrument_id)
.build()
.expect("Failed to build InstrumentGreeksParams");
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);
}
#[rstest]
fn test_instrument_greeks_params_builder_custom_values() {
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let index_id = InstrumentId::from("SPY.NASDAQ");
let mut beta_weights = HashMap::new();
beta_weights.insert(instrument_id, 1.2);
let params = InstrumentGreeksParamsBuilder::default()
.instrument_id(instrument_id)
.flat_interest_rate(0.05)
.flat_dividend_yield(Some(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(Some(index_id))
.beta_weights(Some(beta_weights.clone()))
.build()
.expect("Failed to build InstrumentGreeksParams");
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));
}
#[rstest]
fn test_instrument_greeks_params_debug() {
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let params = InstrumentGreeksParamsBuilder::default()
.instrument_id(instrument_id)
.build()
.expect("Failed to build InstrumentGreeksParams");
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 = PortfolioGreeksParamsBuilder::default()
.build()
.expect("Failed to build PortfolioGreeksParams");
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);
}
#[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 underlyings = vec!["AAPL".to_string(), "MSFT".to_string()];
let mut beta_weights = HashMap::new();
beta_weights.insert(instrument_id, 1.2);
let params = PortfolioGreeksParamsBuilder::default()
.underlyings(Some(underlyings.clone()))
.venue(Some(venue))
.instrument_id(Some(instrument_id))
.strategy_id(Some(strategy_id))
.side(Some(PositionSide::Long))
.flat_interest_rate(0.05)
.flat_dividend_yield(Some(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(Some(index_id))
.beta_weights(Some(beta_weights.clone()))
.build()
.expect("Failed to build PortfolioGreeksParams");
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));
}
#[rstest]
fn test_portfolio_greeks_params_debug() {
let venue = Venue::from("NASDAQ");
let params = PortfolioGreeksParamsBuilder::default()
.venue(Some(venue))
.build()
.expect("Failed to build PortfolioGreeksParams");
let debug_str = format!("{params:?}");
assert!(debug_str.contains("PortfolioGreeksParams"));
assert!(debug_str.contains("NASDAQ"));
}
#[rstest]
fn test_instrument_greeks_params_builder_missing_required_field() {
let result = InstrumentGreeksParamsBuilder::default().build();
assert!(result.is_err());
}
#[rstest]
fn test_portfolio_greeks_params_builder_fluent_api() {
let instrument_id = InstrumentId::from("AAPL.NASDAQ");
let params = PortfolioGreeksParamsBuilder::default()
.instrument_id(Some(instrument_id))
.flat_interest_rate(0.05)
.spot_shock(0.01)
.percent_greeks(true)
.build()
.expect("Failed to build PortfolioGreeksParams");
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 = InstrumentGreeksParamsBuilder::default()
.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()
.expect("Failed to build InstrumentGreeksParams");
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 = PortfolioGreeksParamsBuilder::default()
.underlyings(Some(underlyings.clone()))
.flat_interest_rate(0.04)
.build()
.expect("Failed to build PortfolioGreeksParams");
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 = InstrumentGreeksParamsBuilder::default()
.instrument_id(instrument_id)
.beta_weights(Some(empty_beta_weights.clone()))
.build()
.expect("Failed to build InstrumentGreeksParams");
let portfolio_params = PortfolioGreeksParamsBuilder::default()
.beta_weights(Some(empty_beta_weights.clone()))
.build()
.expect("Failed to build PortfolioGreeksParams");
assert_eq!(
instrument_params.beta_weights,
Some(empty_beta_weights.clone())
);
assert_eq!(portfolio_params.beta_weights, Some(empty_beta_weights));
}
#[rstest]
fn test_builders_with_all_shocks() {
let instrument_id = InstrumentId::from("AMD.NASDAQ");
let instrument_params = InstrumentGreeksParamsBuilder::default()
.instrument_id(instrument_id)
.spot_shock(0.05)
.vol_shock(0.1)
.time_to_expiry_shock(0.01)
.build()
.expect("Failed to build InstrumentGreeksParams");
let portfolio_params = PortfolioGreeksParamsBuilder::default()
.spot_shock(0.05)
.vol_shock(0.1)
.time_to_expiry_shock(0.01)
.build()
.expect("Failed to build PortfolioGreeksParams");
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 = InstrumentGreeksParamsBuilder::default()
.instrument_id(instrument_id)
.use_cached_greeks(true)
.cache_greeks(true)
.publish_greeks(true)
.percent_greeks(true)
.build()
.expect("Failed to build InstrumentGreeksParams");
let portfolio_params = PortfolioGreeksParamsBuilder::default()
.use_cached_greeks(true)
.cache_greeks(true)
.publish_greeks(true)
.percent_greeks(true)
.build()
.expect("Failed to build PortfolioGreeksParams");
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 = PortfolioGreeksParamsBuilder::default()
.greeks_filter(Some(filter))
.flat_interest_rate(0.05)
.build()
.expect("Failed to build PortfolioGreeksParams");
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 = PortfolioGreeksParamsBuilder::default()
.greeks_filter(Some(filter))
.build()
.expect("Failed to build PortfolioGreeksParams");
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.with_ymd_and_hms(2021, 9, 17, 0, 0, 0).unwrap());
OptionContract::new(
InstrumentId::from(instrument_id),
Symbol::from("AAPL211217C00150000"),
AssetClass::Equity,
Some(Ustr::from("GMNI")),
Ustr::from("AAPL"),
OptionKind::Call,
Price::from("149.0"),
Currency::from("USD"),
activation_ns,
expiration_ns,
2,
Price::from("0.01"),
Quantity::from(100),
Quantity::from(1),
None,
None,
None,
None,
None,
None,
None,
None,
None,
UnixNanos::default(),
UnixNanos::default(),
)
}
fn equity_aapl_opra() -> Equity {
Equity::new(
InstrumentId::from("AAPL.OPRA"),
Symbol::from("AAPL"),
Some(Ustr::from("US0378331005")),
Currency::from("USD"),
2,
Price::from("0.01"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
UnixNanos::default(),
UnixNanos::default(),
)
}
fn future_with_expiration(
instrument_id: &str,
underlying: &str,
expiration_ns: UnixNanos,
) -> FuturesContract {
FuturesContract::new(
InstrumentId::from(instrument_id),
Symbol::from(underlying),
AssetClass::Index,
Some(Ustr::from("XCME")),
Ustr::from(underlying),
UnixNanos::default(),
expiration_ns,
Currency::from("USD"),
2,
Price::from("0.25"),
Quantity::from(1),
Quantity::from(1),
None,
None,
None,
None,
None,
None,
None,
None,
None,
UnixNanos::default(),
UnixNanos::default(),
)
}
fn future_option_with_expiration(
instrument_id: &str,
raw_symbol: &str,
underlying: &str,
option_kind: OptionKind,
strike: &str,
expiration_ns: UnixNanos,
) -> OptionContract {
OptionContract::new(
InstrumentId::from(instrument_id),
Symbol::from(raw_symbol),
AssetClass::Index,
Some(Ustr::from("XCME")),
Ustr::from(underlying),
option_kind,
Price::from(strike),
Currency::from("USD"),
UnixNanos::default(),
expiration_ns,
2,
Price::from("0.01"),
Quantity::from(1),
Quantity::from(1),
None,
None,
None,
None,
None,
None,
None,
None,
None,
UnixNanos::default(),
UnixNanos::default(),
)
}
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
}
#[rstest]
fn test_expiry_in_days_multi_day_unchanged() {
let now = Utc.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
let expiry = now + chrono::Duration::days(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(TestClock::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,
)
.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.with_ymd_and_hms(2025, 3, 8, 12, 0, 0).unwrap();
let expiry_same_day = Utc.with_ymd_and_hms(2025, 3, 8, 18, 0, 0).unwrap();
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(TestClock::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,
)
.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_errors_when_future_underlying_price_missing_without_cached_spread() {
let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
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(TestClock::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,
)
.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.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
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(TestClock::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.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
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(TestClock::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,
)
.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.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
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(TestClock::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,
)
.unwrap();
assert_eq!(greeks.underlying_price, 157.25);
}
#[rstest]
fn test_instrument_greeks_prefers_quote_over_index_price_for_index_future() {
let now = Utc.with_ymd_and_hms(2024, 2, 14, 16, 0, 0).unwrap();
let expiry = Utc.with_ymd_and_hms(2024, 3, 15, 16, 0, 0).unwrap();
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(TestClock::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,
)
.unwrap();
assert_eq!(greeks.underlying_price, 159.0);
}
}