use std::collections::HashMap;
use std::time::Duration;
use chrono::{DateTime, Utc};
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::greeks::{delta, gamma, rho, theta, vega};
use optionstratlib::prelude::{Decimal, Positive};
use optionstratlib::{ExpirationDate, OptionStyle, OptionType, Options, Side};
use super::events::{GreeksOrigin, GreeksRow, QUOTE_STALE_AFTER};
use super::identity::InstrumentKey;
use crate::error::ChainViewError;
pub const DEFAULT_RISK_FREE_RATE: Decimal = Decimal::ZERO;
pub const DEFAULT_DIVIDEND_YIELD: Positive = Positive::ZERO;
const SECONDS_PER_DAY: i64 = 86_400;
pub const MIN_PLAUSIBLE_LOCAL_IV: Decimal = Decimal::from_parts(5, 0, 0, false, 3);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum PricingModel {
#[default]
European,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum QuoteSelect {
#[default]
Mid,
Last,
BidAsk,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum PremiumNumeraire {
#[default]
QuoteCurrency,
UnderlyingCoin,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum LegStatus {
Computed,
#[default]
NoInput,
Crossed,
StaleQuote,
Stale,
SolverError,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PricingInputs {
pub model: PricingModel,
pub spot: Positive,
pub rate: Decimal,
pub dividend: Positive,
pub as_of: DateTime<Utc>,
pub quote_for_iv: QuoteSelect,
pub premium_numeraire: PremiumNumeraire,
pub input_generation: u64,
}
impl PricingInputs {
#[must_use]
pub fn new(spot: Positive, as_of: DateTime<Utc>, input_generation: u64) -> Self {
Self {
model: PricingModel::European,
spot,
rate: DEFAULT_RISK_FREE_RATE,
dividend: DEFAULT_DIVIDEND_YIELD,
as_of,
quote_for_iv: QuoteSelect::Mid,
premium_numeraire: PremiumNumeraire::QuoteCurrency,
input_generation,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct QuoteClocks {
received: HashMap<InstrumentKey, DateTime<Utc>>,
}
impl QuoteClocks {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, key: InstrumentKey, received: DateTime<Utc>) {
let _ = self.received.insert(key, received);
}
#[must_use]
pub fn received(&self, key: &InstrumentKey) -> Option<DateTime<Utc>> {
self.received.get(key).copied()
}
#[must_use]
pub fn len(&self) -> usize {
self.received.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.received.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LegGreeks {
pub iv: Option<Positive>,
pub iv_origin: GreeksOrigin,
pub delta: Option<Decimal>,
pub delta_origin: GreeksOrigin,
pub gamma: Option<Decimal>,
pub gamma_origin: GreeksOrigin,
pub theta: Option<Decimal>,
pub vega: Option<Decimal>,
pub rho: Option<Decimal>,
pub theta_origin: GreeksOrigin,
pub vega_origin: GreeksOrigin,
pub rho_origin: GreeksOrigin,
pub status: LegStatus,
}
impl Default for LegGreeks {
fn default() -> Self {
Self {
iv: None,
iv_origin: GreeksOrigin::ComputedLocally,
delta: None,
delta_origin: GreeksOrigin::ComputedLocally,
gamma: None,
gamma_origin: GreeksOrigin::ComputedLocally,
theta: None,
vega: None,
rho: None,
theta_origin: GreeksOrigin::ComputedLocally,
vega_origin: GreeksOrigin::ComputedLocally,
rho_origin: GreeksOrigin::ComputedLocally,
status: LegStatus::NoInput,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GreeksSidecar {
by_key: HashMap<InstrumentKey, LegGreeks>,
computed_generation: Option<u64>,
}
impl GreeksSidecar {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn get(&self, key: &InstrumentKey) -> Option<&LegGreeks> {
self.by_key.get(key)
}
#[must_use]
pub fn len(&self) -> usize {
self.by_key.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_key.is_empty()
}
#[must_use]
pub fn computed_generation(&self) -> Option<u64> {
self.computed_generation
}
pub fn apply_venue_greeks(&mut self, row: &GreeksRow) {
let entry = self.by_key.entry(row.instrument.key.clone()).or_default();
if let Some(iv) = row.iv {
entry.iv = Some(iv);
entry.iv_origin = row.origin;
}
if let Some(gamma) = row.gamma {
entry.gamma = Some(gamma);
entry.gamma_origin = row.origin;
}
}
}
pub fn compute_leg_greeks(
chain: &OptionChain,
ctx: &PricingInputs,
quotes: &QuoteClocks,
sink: &mut GreeksSidecar,
) -> Result<(), ChainViewError> {
if sink.computed_generation == Some(ctx.input_generation) {
return Ok(());
}
let Some(expiration_utc) = resolve_chain_expiry(chain, ctx, sink) else {
return Ok(());
};
for od in &chain.options {
for style in [OptionStyle::Call, OptionStyle::Put] {
compute_one_leg(od, style, &chain.symbol, expiration_utc, ctx, quotes, sink);
}
}
sink.computed_generation = Some(ctx.input_generation);
Ok(())
}
pub fn compute_dirty_legs(
chain: &OptionChain,
ctx: &PricingInputs,
quotes: &QuoteClocks,
dirty: &[(Positive, OptionStyle)],
sink: &mut GreeksSidecar,
) -> Result<(), ChainViewError> {
let Some(expiration_utc) = resolve_chain_expiry(chain, ctx, sink) else {
return Ok(());
};
for &(strike, style) in dirty {
if let Some(od) = chain.options.get(&probe_row(strike)) {
compute_one_leg(od, style, &chain.symbol, expiration_utc, ctx, quotes, sink);
}
}
sink.computed_generation = Some(ctx.input_generation);
Ok(())
}
fn resolve_chain_expiry(
chain: &OptionChain,
ctx: &PricingInputs,
sink: &mut GreeksSidecar,
) -> Option<DateTime<Utc>> {
match chain.get_expiration() {
Some(ExpirationDate::DateTime(dt)) => Some(dt),
other => {
debug_assert!(
!matches!(other, Some(ExpirationDate::Days(_))),
"local Greeks require an absolute-UTC chain expiry; a relative \
Days offset must be resolved at the adapter seam"
);
sink.computed_generation = Some(ctx.input_generation);
None
}
}
}
fn compute_one_leg(
od: &OptionData,
style: OptionStyle,
symbol: &str,
expiration_utc: DateTime<Utc>,
ctx: &PricingInputs,
quotes: &QuoteClocks,
sink: &mut GreeksSidecar,
) {
let key = InstrumentKey {
underlying: symbol.to_owned(),
expiration_utc,
strike: od.strike_price,
style,
};
let existing = sink.by_key.get(&key).copied().unwrap_or_default();
let quote_stale = quotes
.received(&key)
.is_some_and(|received| quote_is_stale(received, ctx.as_of));
let Some(days) = days_between(ctx.as_of, expiration_utc) else {
let _ = sink
.by_key
.insert(key, cleared_entry(existing, LegStatus::Stale));
return;
};
let (pricing_iv, iv_origin) = if existing.iv_origin == GreeksOrigin::Provider
&& let Some(iv) = existing.iv
{
(iv, GreeksOrigin::Provider)
} else {
match select_iv_premium(od, style, ctx.quote_for_iv, quote_stale) {
QuotePick::Crossed => {
let _ = sink
.by_key
.insert(key, cleared_entry(existing, LegStatus::Crossed));
return;
}
QuotePick::Stale => {
let _ = sink
.by_key
.insert(key, cleared_entry(existing, LegStatus::StaleQuote));
return;
}
QuotePick::Absent => {
let _ = sink
.by_key
.insert(key, cleared_entry(existing, LegStatus::NoInput));
return;
}
QuotePick::Premium(premium) => {
let Some(priced) = premium_in_quote_currency(premium, ctx) else {
let _ = sink
.by_key
.insert(key, cleared_entry(existing, LegStatus::SolverError));
return;
};
match invert_iv(symbol.to_owned(), od.strike_price, days, style, priced, ctx) {
Some(iv) => (iv, GreeksOrigin::ComputedLocally),
None => {
let _ = sink
.by_key
.insert(key, cleared_entry(existing, LegStatus::SolverError));
return;
}
}
}
}
};
let option = build_option(
symbol.to_owned(),
od.strike_price,
days,
pricing_iv,
style,
ctx,
);
let (Ok(local_delta), Ok(local_gamma), Ok(local_theta), Ok(local_vega), Ok(local_rho)) = (
delta(&option),
gamma(&option),
theta(&option),
vega(&option),
rho(&option),
) else {
let _ = sink
.by_key
.insert(key, cleared_entry(existing, LegStatus::SolverError));
return;
};
let (gamma_value, gamma_origin) = if existing.gamma_origin == GreeksOrigin::Provider
&& let Some(g) = existing.gamma
{
(Some(g), GreeksOrigin::Provider)
} else {
(Some(local_gamma), GreeksOrigin::ComputedLocally)
};
let entry = LegGreeks {
iv: Some(pricing_iv),
iv_origin,
delta: Some(local_delta),
delta_origin: GreeksOrigin::ComputedLocally,
gamma: gamma_value,
gamma_origin,
theta: Some(local_theta),
vega: Some(local_vega),
rho: Some(local_rho),
theta_origin: GreeksOrigin::ComputedLocally,
vega_origin: GreeksOrigin::ComputedLocally,
rho_origin: GreeksOrigin::ComputedLocally,
status: LegStatus::Computed,
};
let _ = sink.by_key.insert(key, entry);
}
enum QuotePick {
Premium(Positive),
Crossed,
Stale,
Absent,
}
#[must_use]
fn select_iv_premium(
od: &OptionData,
style: OptionStyle,
select: QuoteSelect,
quote_stale: bool,
) -> QuotePick {
let (bid, ask, middle) = match style {
OptionStyle::Call => (od.call_bid, od.call_ask, od.call_middle),
OptionStyle::Put => (od.put_bid, od.put_ask, od.put_middle),
};
let (Some(bid), Some(ask)) = (bid, ask) else {
return QuotePick::Absent;
};
if is_crossed(bid, ask) {
return QuotePick::Crossed;
}
if quote_stale {
return QuotePick::Stale;
}
let recomputed = midpoint(bid, ask);
let premium = match select {
QuoteSelect::Mid | QuoteSelect::Last => middle.unwrap_or(recomputed),
QuoteSelect::BidAsk => recomputed,
};
QuotePick::Premium(premium)
}
#[must_use]
fn premium_in_quote_currency(premium: Positive, ctx: &PricingInputs) -> Option<Positive> {
match ctx.premium_numeraire {
PremiumNumeraire::QuoteCurrency => Some(premium),
PremiumNumeraire::UnderlyingCoin => {
let converted = premium.to_dec().checked_mul(ctx.spot.to_dec())?;
Positive::new_decimal(converted).ok()
}
}
}
#[must_use]
fn invert_iv(
symbol: String,
strike: Positive,
days: Positive,
style: OptionStyle,
premium: Positive,
ctx: &PricingInputs,
) -> Option<Positive> {
let option = build_option(symbol, strike, days, Positive::ONE, style, ctx);
option.calculate_implied_volatility(premium.to_dec()).ok()
}
#[must_use]
fn build_option(
symbol: String,
strike: Positive,
days: Positive,
iv: Positive,
style: OptionStyle,
ctx: &PricingInputs,
) -> Options {
let option_type = match ctx.model {
PricingModel::European => OptionType::European,
};
Options::new(
option_type,
Side::Long,
symbol,
strike,
ExpirationDate::Days(days),
iv,
Positive::ONE,
ctx.spot,
ctx.rate,
style,
ctx.dividend,
None,
)
}
#[must_use]
fn cleared_entry(existing: LegGreeks, status: LegStatus) -> LegGreeks {
let mut cleared = LegGreeks::default();
if existing.iv_origin == GreeksOrigin::Provider && existing.iv.is_some() {
cleared.iv = existing.iv;
cleared.iv_origin = GreeksOrigin::Provider;
}
if existing.gamma_origin == GreeksOrigin::Provider && existing.gamma.is_some() {
cleared.gamma = existing.gamma;
cleared.gamma_origin = GreeksOrigin::Provider;
}
cleared.status = status;
cleared
}
#[must_use]
fn days_between(as_of: DateTime<Utc>, expiration_utc: DateTime<Utc>) -> Option<Positive> {
let seconds = expiration_utc.signed_duration_since(as_of).num_seconds();
if seconds <= 0 {
return None;
}
let days = Decimal::from(seconds).checked_div(Decimal::from(SECONDS_PER_DAY))?;
Positive::new_decimal(days).ok()
}
#[must_use]
pub(crate) fn quote_is_stale(received: DateTime<Utc>, as_of: DateTime<Utc>) -> bool {
let age = as_of
.signed_duration_since(received)
.to_std()
.unwrap_or(Duration::ZERO);
age > QUOTE_STALE_AFTER
}
#[must_use]
fn probe_row(strike: Positive) -> OptionData {
OptionData {
strike_price: strike,
..Default::default()
}
}
#[must_use]
fn is_crossed(bid: Positive, ask: Positive) -> bool {
ask < bid || (ask.is_zero() && !bid.is_zero())
}
#[must_use]
fn midpoint(bid: Positive, ask: Positive) -> Positive {
((bid + ask) / Positive::TWO).round_to(4)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chain::identity::{
ContractSpecFingerprint, ExerciseStyle, Instrument, ProviderId, SettlementStyle,
};
#[track_caller]
fn utc(secs: i64) -> DateTime<Utc> {
match DateTime::<Utc>::from_timestamp(secs, 0) {
Some(t) => t,
None => panic!("invalid test timestamp: {secs}"),
}
}
#[track_caller]
fn pos(value: f64) -> Positive {
match Positive::new(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive `{value}`: {e}"),
}
}
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("expected a valid provider id `{id}`, got: {e}"),
}
}
fn dec(mantissa: i64, scale: u32) -> Decimal {
Decimal::new(mantissa, scale)
}
const EXPIRY_STRING: &str = "2025-06-27";
const AS_OF_BEFORE: i64 = 1_735_689_600;
const AS_OF_AFTER: i64 = 1_767_139_200;
fn od(
strike: f64,
call_bid: Option<f64>,
call_ask: Option<f64>,
put_bid: Option<f64>,
put_ask: Option<f64>,
) -> OptionData {
let mut data = OptionData {
strike_price: pos(strike),
call_bid: call_bid.map(pos),
call_ask: call_ask.map(pos),
put_bid: put_bid.map(pos),
put_ask: put_ask.map(pos),
implied_volatility: pos(0.5),
..Default::default()
};
data.set_mid_prices();
data
}
fn chain_of(rows: &[OptionData]) -> OptionChain {
let mut chain =
OptionChain::new("BTC", pos(60_000.0), EXPIRY_STRING.to_owned(), None, None);
for row in rows {
let _ = chain.options.insert(row.clone());
}
chain
}
fn atm_chain() -> OptionChain {
chain_of(&[od(
60_000.0,
Some(3_000.0),
Some(3_100.0),
Some(2_000.0),
Some(2_100.0),
)])
}
fn inputs(as_of: i64, generation: u64) -> PricingInputs {
PricingInputs::new(pos(60_000.0), utc(as_of), generation)
}
fn key(strike: f64, style: OptionStyle) -> InstrumentKey {
InstrumentKey {
underlying: "BTC".to_owned(),
expiration_utc: resolved_expiry(),
strike: pos(strike),
style,
}
}
#[track_caller]
fn resolved_expiry() -> DateTime<Utc> {
match chain_of(&[]).get_expiration() {
Some(ExpirationDate::DateTime(dt)) => dt,
other => panic!("expected an absolute-UTC chain expiry, got {other:?}"),
}
}
fn instrument(strike: f64, style: OptionStyle) -> Instrument {
Instrument {
key: key(strike, style),
provider: pid("deribit"),
native_symbol: format!("BTC-{strike}"),
stream_symbol: None,
spec: ContractSpecFingerprint {
contract_multiplier: 1,
settlement: SettlementStyle::Cash,
exercise: ExerciseStyle::European,
quote_currency: "USD".to_owned(),
venue_product_code: "BTC".to_owned(),
},
}
}
fn venue_greeks(
strike: f64,
style: OptionStyle,
iv: Option<f64>,
gamma: Option<Decimal>,
) -> GreeksRow {
GreeksRow {
instrument: instrument(strike, style),
iv: iv.map(pos),
delta: Some(dec(-25, 2)),
gamma,
theta: Some(dec(-1, 1)),
vega: Some(dec(2, 1)),
rho: Some(dec(3, 1)),
origin: GreeksOrigin::Provider,
event_time: None,
received_time: utc(AS_OF_BEFORE),
}
}
#[track_caller]
fn leg(sink: &GreeksSidecar, strike: f64, style: OptionStyle) -> LegGreeks {
match sink.get(&key(strike, style)) {
Some(g) => *g,
None => panic!("expected a sidecar entry for strike {strike} {style:?}"),
}
}
#[track_caller]
fn compute(chain: &OptionChain, ctx: &PricingInputs, sink: &mut GreeksSidecar) {
compute_with(chain, ctx, &QuoteClocks::new(), sink);
}
#[track_caller]
fn compute_with(
chain: &OptionChain,
ctx: &PricingInputs,
quotes: &QuoteClocks,
sink: &mut GreeksSidecar,
) {
match compute_leg_greeks(chain, ctx, quotes, sink) {
Ok(()) => {}
Err(e) => panic!("compute_leg_greeks failed: {e}"),
}
}
fn clocks_with(strike: f64, style: OptionStyle, received: i64) -> QuoteClocks {
let mut clocks = QuoteClocks::new();
clocks.insert(key(strike, style), utc(received));
clocks
}
#[test]
fn test_pricing_model_default_is_european() {
assert_eq!(PricingModel::default(), PricingModel::European);
}
#[test]
fn test_quote_select_default_is_mid() {
assert_eq!(QuoteSelect::default(), QuoteSelect::Mid);
}
#[test]
fn test_pricing_inputs_new_applies_documented_defaults() {
let ctx = inputs(AS_OF_BEFORE, 1);
assert_eq!(ctx.model, PricingModel::European);
assert_eq!(ctx.rate, DEFAULT_RISK_FREE_RATE);
assert_eq!(ctx.dividend, DEFAULT_DIVIDEND_YIELD);
assert_eq!(ctx.quote_for_iv, QuoteSelect::Mid);
assert_eq!(ctx.input_generation, 1);
}
#[test]
fn test_compute_leg_greeks_local_inversion_fills_all_analytics() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let mut sink = GreeksSidecar::new();
compute(&chain, &ctx, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.status, LegStatus::Computed);
assert!(call.iv.is_some());
assert_eq!(call.iv_origin, GreeksOrigin::ComputedLocally);
assert!(call.delta.is_some());
assert!(call.gamma.is_some());
assert!(call.theta.is_some());
assert!(call.vega.is_some());
assert!(call.rho.is_some());
assert_eq!(call.delta_origin, GreeksOrigin::ComputedLocally);
assert_eq!(call.gamma_origin, GreeksOrigin::ComputedLocally);
assert_eq!(call.theta_origin, GreeksOrigin::ComputedLocally);
assert_eq!(call.vega_origin, GreeksOrigin::ComputedLocally);
assert_eq!(call.rho_origin, GreeksOrigin::ComputedLocally);
let put = leg(&sink, 60_000.0, OptionStyle::Put);
assert_eq!(put.status, LegStatus::Computed);
assert!(put.theta.is_some());
}
#[test]
fn test_compute_leg_greeks_prefers_venue_iv_over_local_inversion() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let mut sink = GreeksSidecar::new();
sink.apply_venue_greeks(&venue_greeks(60_000.0, OptionStyle::Call, Some(0.55), None));
compute(&chain, &ctx, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.iv, Some(pos(0.55)));
assert_eq!(call.iv_origin, GreeksOrigin::Provider);
assert_eq!(call.theta_origin, GreeksOrigin::ComputedLocally);
assert!(call.theta.is_some());
assert_eq!(call.status, LegStatus::Computed);
}
#[test]
fn test_compute_leg_greeks_prefers_venue_gamma_over_local() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let mut sink = GreeksSidecar::new();
sink.apply_venue_greeks(&venue_greeks(
60_000.0,
OptionStyle::Call,
Some(0.5),
Some(dec(7, 4)),
));
compute(&chain, &ctx, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.gamma, Some(dec(7, 4)));
assert_eq!(call.gamma_origin, GreeksOrigin::Provider);
}
#[test]
fn test_compute_leg_greeks_crossed_quote_leaves_iv_and_greeks_none() {
let chain = chain_of(&[od(
60_000.0,
Some(2_000.0),
Some(1_000.0),
Some(2_000.0),
Some(2_100.0),
)]);
let ctx = inputs(AS_OF_BEFORE, 1);
let mut sink = GreeksSidecar::new();
compute(&chain, &ctx, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.status, LegStatus::Crossed);
assert!(call.iv.is_none());
assert!(call.delta.is_none());
assert!(call.gamma.is_none());
assert!(call.theta.is_none());
assert!(call.vega.is_none());
assert!(call.rho.is_none());
assert_eq!(
leg(&sink, 60_000.0, OptionStyle::Put).status,
LegStatus::Computed
);
}
#[test]
fn test_compute_leg_greeks_stale_input_leaves_none() {
let chain = atm_chain();
let ctx = inputs(AS_OF_AFTER, 1);
let mut sink = GreeksSidecar::new();
compute(&chain, &ctx, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.status, LegStatus::Stale);
assert!(call.iv.is_none());
assert!(call.theta.is_none());
}
#[test]
fn test_compute_leg_greeks_absent_quote_leaves_none() {
let chain = chain_of(&[od(
60_000.0,
Some(3_000.0),
None,
Some(2_000.0),
Some(2_100.0),
)]);
let ctx = inputs(AS_OF_BEFORE, 1);
let mut sink = GreeksSidecar::new();
compute(&chain, &ctx, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.status, LegStatus::NoInput);
assert!(call.iv.is_none());
assert!(call.vega.is_none());
}
#[test]
fn test_compute_leg_greeks_stale_quote_is_not_inverted() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let clocks = clocks_with(60_000.0, OptionStyle::Call, AS_OF_BEFORE - 10);
let mut sink = GreeksSidecar::new();
compute_with(&chain, &ctx, &clocks, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.status, LegStatus::StaleQuote);
assert!(call.iv.is_none());
assert!(call.delta.is_none());
assert!(call.gamma.is_none());
assert!(call.theta.is_none());
assert!(call.vega.is_none());
assert!(call.rho.is_none());
let put = leg(&sink, 60_000.0, OptionStyle::Put);
assert_eq!(put.status, LegStatus::Computed);
assert!(put.iv.is_some());
assert_eq!(put.iv_origin, GreeksOrigin::ComputedLocally);
}
#[test]
fn test_compute_leg_greeks_fresh_quote_is_still_inverted() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let clocks = clocks_with(60_000.0, OptionStyle::Call, AS_OF_BEFORE - 4);
let mut sink = GreeksSidecar::new();
compute_with(&chain, &ctx, &clocks, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.status, LegStatus::Computed);
assert!(call.iv.is_some());
assert_eq!(call.iv_origin, GreeksOrigin::ComputedLocally);
}
#[test]
fn test_compute_leg_greeks_stale_quote_threshold_boundary() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let at_threshold = clocks_with(60_000.0, OptionStyle::Call, AS_OF_BEFORE - 5);
let mut sink_at = GreeksSidecar::new();
compute_with(&chain, &ctx, &at_threshold, &mut sink_at);
assert_eq!(
leg(&sink_at, 60_000.0, OptionStyle::Call).status,
LegStatus::Computed
);
let past_threshold = clocks_with(60_000.0, OptionStyle::Call, AS_OF_BEFORE - 6);
let mut sink_past = GreeksSidecar::new();
compute_with(&chain, &ctx, &past_threshold, &mut sink_past);
assert_eq!(
leg(&sink_past, 60_000.0, OptionStyle::Call).status,
LegStatus::StaleQuote
);
}
#[test]
fn test_compute_leg_greeks_stale_quote_does_not_touch_venue_iv_path() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let mut sink = GreeksSidecar::new();
sink.apply_venue_greeks(&venue_greeks(60_000.0, OptionStyle::Call, Some(0.55), None));
let clocks = clocks_with(60_000.0, OptionStyle::Call, AS_OF_BEFORE - 3_600);
compute_with(&chain, &ctx, &clocks, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.iv, Some(pos(0.55)));
assert_eq!(call.iv_origin, GreeksOrigin::Provider);
assert_eq!(call.status, LegStatus::Computed);
assert!(call.theta.is_some());
}
#[test]
fn test_compute_leg_greeks_stale_quote_decision_is_deterministic() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let clocks = clocks_with(60_000.0, OptionStyle::Call, AS_OF_BEFORE - 10);
let mut a = GreeksSidecar::new();
let mut b = GreeksSidecar::new();
compute_with(&chain, &ctx, &clocks, &mut a);
compute_with(&chain, &ctx, &clocks, &mut b);
assert_eq!(
leg(&a, 60_000.0, OptionStyle::Call),
leg(&b, 60_000.0, OptionStyle::Call)
);
}
#[test]
fn test_compute_leg_greeks_solver_error_clears_leg_keeps_venue_iv() {
let chain = atm_chain();
let mut ctx = inputs(AS_OF_BEFORE, 1);
ctx.spot = Positive::ZERO;
let mut sink = GreeksSidecar::new();
sink.apply_venue_greeks(&venue_greeks(60_000.0, OptionStyle::Call, Some(0.5), None));
compute(&chain, &ctx, &mut sink);
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(call.status, LegStatus::SolverError);
assert!(call.theta.is_none());
assert!(call.vega.is_none());
assert!(call.delta.is_none());
assert_eq!(call.iv, Some(pos(0.5)));
assert_eq!(call.iv_origin, GreeksOrigin::Provider);
}
#[test]
fn test_apply_venue_greeks_call_then_put_retains_both_legs() {
let mut sink = GreeksSidecar::new();
sink.apply_venue_greeks(&venue_greeks(
60_000.0,
OptionStyle::Call,
Some(0.4),
Some(dec(1, 2)),
));
sink.apply_venue_greeks(&venue_greeks(
60_000.0,
OptionStyle::Put,
Some(0.6),
Some(dec(2, 2)),
));
let call = leg(&sink, 60_000.0, OptionStyle::Call);
let put = leg(&sink, 60_000.0, OptionStyle::Put);
assert_eq!(call.iv, Some(pos(0.4)));
assert_eq!(call.gamma, Some(dec(1, 2)));
assert_eq!(put.iv, Some(pos(0.6)));
assert_eq!(put.gamma, Some(dec(2, 2)));
}
#[test]
fn test_apply_venue_greeks_put_then_call_retains_both_legs() {
let mut sink = GreeksSidecar::new();
sink.apply_venue_greeks(&venue_greeks(
60_000.0,
OptionStyle::Put,
Some(0.6),
Some(dec(2, 2)),
));
sink.apply_venue_greeks(&venue_greeks(
60_000.0,
OptionStyle::Call,
Some(0.4),
Some(dec(1, 2)),
));
let call = leg(&sink, 60_000.0, OptionStyle::Call);
let put = leg(&sink, 60_000.0, OptionStyle::Put);
assert_eq!(call.iv, Some(pos(0.4)));
assert_eq!(call.gamma, Some(dec(1, 2)));
assert_eq!(put.iv, Some(pos(0.6)));
assert_eq!(put.gamma, Some(dec(2, 2)));
}
#[test]
fn test_apply_venue_greeks_discards_venue_theta_vega_rho() {
let mut sink = GreeksSidecar::new();
sink.apply_venue_greeks(&venue_greeks(
60_000.0,
OptionStyle::Call,
Some(0.4),
Some(dec(1, 2)),
));
let call = leg(&sink, 60_000.0, OptionStyle::Call);
assert!(call.theta.is_none());
assert!(call.vega.is_none());
assert!(call.rho.is_none());
assert!(call.delta.is_none());
}
#[test]
fn test_compute_leg_greeks_cached_generation_does_no_work() {
let mut sink = GreeksSidecar::new();
compute(&atm_chain(), &inputs(AS_OF_BEFORE, 1), &mut sink);
let before = leg(&sink, 60_000.0, OptionStyle::Call);
assert_eq!(before.status, LegStatus::Computed);
let crossed = chain_of(&[od(
60_000.0,
Some(2_000.0),
Some(1_000.0),
Some(2_000.0),
Some(2_100.0),
)]);
compute(&crossed, &inputs(AS_OF_BEFORE, 1), &mut sink);
assert_eq!(leg(&sink, 60_000.0, OptionStyle::Call), before);
compute(&crossed, &inputs(AS_OF_BEFORE, 2), &mut sink);
assert_eq!(
leg(&sink, 60_000.0, OptionStyle::Call).status,
LegStatus::Crossed
);
}
#[test]
fn test_compute_leg_greeks_records_computed_generation() {
let mut sink = GreeksSidecar::new();
assert_eq!(sink.computed_generation(), None);
compute(&atm_chain(), &inputs(AS_OF_BEFORE, 7), &mut sink);
assert_eq!(sink.computed_generation(), Some(7));
}
fn two_strike_chain() -> OptionChain {
chain_of(&[
od(
60_000.0,
Some(3_000.0),
Some(3_100.0),
Some(2_000.0),
Some(2_100.0),
),
od(
61_000.0,
Some(2_600.0),
Some(2_700.0),
Some(2_400.0),
Some(2_500.0),
),
])
}
#[track_caller]
fn compute_dirty(
chain: &OptionChain,
ctx: &PricingInputs,
dirty: &[(Positive, OptionStyle)],
sink: &mut GreeksSidecar,
) {
match compute_dirty_legs(chain, ctx, &QuoteClocks::new(), dirty, sink) {
Ok(()) => {}
Err(e) => panic!("compute_dirty_legs failed: {e}"),
}
}
#[test]
fn test_compute_dirty_legs_reprices_only_the_named_legs() {
let chain = two_strike_chain();
let mut sink = GreeksSidecar::new();
compute(&chain, &inputs(AS_OF_BEFORE, 1), &mut sink);
let k1_call_before = leg(&sink, 60_000.0, OptionStyle::Call);
let k1_put_before = leg(&sink, 60_000.0, OptionStyle::Put);
let k2_call_before = leg(&sink, 61_000.0, OptionStyle::Call);
compute_dirty(
&chain,
&inputs(AS_OF_BEFORE - 10_000_000, 2),
&[(pos(60_000.0), OptionStyle::Call)],
&mut sink,
);
assert_ne!(leg(&sink, 60_000.0, OptionStyle::Call), k1_call_before);
assert_eq!(leg(&sink, 60_000.0, OptionStyle::Put), k1_put_before);
assert_eq!(leg(&sink, 61_000.0, OptionStyle::Call), k2_call_before);
assert_eq!(sink.computed_generation(), Some(2));
}
#[test]
fn test_compute_dirty_legs_matches_full_pass_for_the_named_leg() {
let chain = two_strike_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let mut full = GreeksSidecar::new();
compute(&chain, &ctx, &mut full);
let mut dirty = GreeksSidecar::new();
compute_dirty(
&chain,
&ctx,
&[(pos(60_000.0), OptionStyle::Call)],
&mut dirty,
);
assert_eq!(
dirty.get(&key(60_000.0, OptionStyle::Call)),
full.get(&key(60_000.0, OptionStyle::Call))
);
assert_eq!(dirty.get(&key(61_000.0, OptionStyle::Call)), None);
}
#[test]
fn test_compute_dirty_legs_absent_strike_is_skipped() {
let chain = two_strike_chain();
let mut sink = GreeksSidecar::new();
compute_dirty(
&chain,
&inputs(AS_OF_BEFORE, 3),
&[(pos(99_999.0), OptionStyle::Call)],
&mut sink,
);
assert_eq!(sink.get(&key(99_999.0, OptionStyle::Call)), None);
assert!(sink.is_empty());
assert_eq!(sink.computed_generation(), Some(3));
}
#[test]
fn test_compute_leg_greeks_is_deterministic_for_equal_inputs() {
let chain = atm_chain();
let ctx = inputs(AS_OF_BEFORE, 1);
let mut a = GreeksSidecar::new();
let mut b = GreeksSidecar::new();
compute(&chain, &ctx, &mut a);
compute(&chain, &ctx, &mut b);
assert_eq!(
leg(&a, 60_000.0, OptionStyle::Call),
leg(&b, 60_000.0, OptionStyle::Call)
);
assert_eq!(
leg(&a, 60_000.0, OptionStyle::Put),
leg(&b, 60_000.0, OptionStyle::Put)
);
}
#[test]
fn test_pricing_inputs_default_numeraire_is_quote_currency() {
let ctx = inputs(AS_OF_BEFORE, 1);
assert_eq!(ctx.premium_numeraire, PremiumNumeraire::QuoteCurrency);
}
#[test]
fn test_premium_numeraire_default_is_quote_currency() {
assert_eq!(PremiumNumeraire::default(), PremiumNumeraire::QuoteCurrency);
}
#[test]
fn test_premium_in_quote_currency_passthrough_when_quote_currency() {
let ctx = inputs(AS_OF_BEFORE, 1); assert_eq!(
premium_in_quote_currency(pos(3_000.0), &ctx),
Some(pos(3_000.0)),
"a vanilla premium is priced as-is (no conversion)",
);
}
#[test]
fn test_premium_in_quote_currency_scales_coin_premium_by_spot() {
let mut ctx = inputs(AS_OF_BEFORE, 1); ctx.premium_numeraire = PremiumNumeraire::UnderlyingCoin;
assert_eq!(
premium_in_quote_currency(pos(0.05), &ctx),
Some(pos(3_000.0)),
);
}
#[test]
fn test_coin_numeraire_inverts_to_a_plausible_iv_not_near_zero() {
let chain = chain_of(&[od(60_000.0, Some(0.05), Some(0.06), Some(0.04), Some(0.05))]);
let mut ctx = PricingInputs::new(pos(60_000.0), utc(1_748_889_000), 1);
ctx.premium_numeraire = PremiumNumeraire::UnderlyingCoin;
let mut sink = GreeksSidecar::new();
compute(&chain, &ctx, &mut sink);
let call = leg_at(
&sink,
60_000.0,
OptionStyle::Call,
utc(1_748_889_000),
&chain,
);
assert_eq!(call.status, LegStatus::Computed);
assert_eq!(call.iv_origin, GreeksOrigin::ComputedLocally);
let iv = match call.iv {
Some(iv) => iv.to_dec(),
None => panic!("expected a locally inverted IV for the inverse contract"),
};
assert!(
iv >= Decimal::new(3, 1) && iv <= Decimal::new(8, 1),
"the coin-aware inversion recovers a plausible IV (~0.3..0.8), got {iv}",
);
}
#[test]
fn test_quote_currency_numeraire_reproduces_the_near_zero_garbage() {
let chain = chain_of(&[od(60_000.0, Some(0.05), Some(0.06), Some(0.04), Some(0.05))]);
let ctx = PricingInputs::new(pos(60_000.0), utc(1_748_889_000), 1);
let mut sink = GreeksSidecar::new();
compute(&chain, &ctx, &mut sink);
let call = leg_at(
&sink,
60_000.0,
OptionStyle::Call,
utc(1_748_889_000),
&chain,
);
if let Some(iv) = call.iv {
assert!(
iv.to_dec() < MIN_PLAUSIBLE_LOCAL_IV,
"priced as USD, the coin premium inverts to sub-floor garbage: {iv}",
);
}
}
#[track_caller]
fn leg_at(
sink: &GreeksSidecar,
strike: f64,
style: OptionStyle,
_as_of: DateTime<Utc>,
chain: &OptionChain,
) -> LegGreeks {
let expiration_utc = match chain.get_expiration() {
Some(ExpirationDate::DateTime(dt)) => dt,
other => panic!("expected an absolute-UTC chain expiry, got {other:?}"),
};
let k = InstrumentKey {
underlying: chain.symbol.clone(),
expiration_utc,
strike: pos(strike),
style,
};
match sink.get(&k) {
Some(g) => *g,
None => panic!("expected a sidecar entry for strike {strike} {style:?}"),
}
}
mod prop {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_greeks_fill_deterministic(
spot_ticks in 40_000u32..80_000,
rate_bp in 0i64..1_000,
call_bid_ticks in 500u32..5_000,
spread in 1u32..500,
) {
let bid = f64::from(call_bid_ticks);
let ask = bid + f64::from(spread);
let chain = chain_of(&[od(
60_000.0,
Some(bid),
Some(ask),
Some(2_000.0),
Some(2_100.0),
)]);
let mut ctx = inputs(AS_OF_BEFORE, 1);
ctx.spot = pos(f64::from(spot_ticks));
ctx.rate = Decimal::new(rate_bp, 4);
let clocks = QuoteClocks::new();
let mut a = GreeksSidecar::new();
let mut b = GreeksSidecar::new();
match (
compute_leg_greeks(&chain, &ctx, &clocks, &mut a),
compute_leg_greeks(&chain, &ctx, &clocks, &mut b),
) {
(Ok(()), Ok(())) => {}
other => panic!("compute failed: {other:?}"),
}
let ka = key(60_000.0, OptionStyle::Call);
prop_assert_eq!(a.get(&ka), b.get(&ka));
let kp = key(60_000.0, OptionStyle::Put);
prop_assert_eq!(a.get(&kp), b.get(&kp));
}
}
}
}