use chrono::{DateTime, Utc};
use optionstratlib::prelude::{Decimal, Positive};
use crate::chain::{GreeksOrigin, GreeksRow, Instrument, QuoteUpdate};
use crate::error::{NormalizeKind, ProviderError};
#[derive(Debug, Clone)]
pub(crate) struct DxQuoteEvent {
#[allow(dead_code)]
pub(crate) symbol: String,
pub(crate) bid: f64,
pub(crate) ask: f64,
pub(crate) bid_size: f64,
pub(crate) ask_size: f64,
pub(crate) last: Option<f64>,
pub(crate) event_time: Option<DateTime<Utc>>,
pub(crate) received_time: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct DxGreeksEvent {
#[allow(dead_code)]
pub(crate) symbol: String,
pub(crate) delta: f64,
pub(crate) gamma: f64,
pub(crate) theta: f64,
pub(crate) vega: f64,
pub(crate) rho: f64,
pub(crate) volatility: f64,
pub(crate) event_time: Option<DateTime<Utc>>,
pub(crate) received_time: DateTime<Utc>,
}
fn positive_or_drop(value: f64) -> Option<Positive> {
Positive::new(value).ok()
}
fn iv_or_drop(value: f64) -> Option<Positive> {
Positive::new(value).ok()
}
fn greek_or_drop(value: f64) -> Option<Decimal> {
if !value.is_finite() {
return None;
}
Decimal::try_from(value).ok()
}
fn checked_bid_ask(
bid: f64,
ask: f64,
) -> Result<(Option<Positive>, Option<Positive>), NormalizeKind> {
let bid = positive_or_drop(bid);
let ask = positive_or_drop(ask);
if let (Some(bid_value), Some(ask_value)) = (bid, ask) {
if ask_value < bid_value {
return Err(NormalizeKind::OutOfRange("ask"));
}
}
Ok((bid, ask))
}
const MAX_SYMBOL_CHARS: usize = 48;
#[allow(dead_code)]
pub(crate) fn clamp_symbol(symbol: &str) -> String {
if symbol.chars().count() <= MAX_SYMBOL_CHARS {
return symbol.to_owned();
}
let mut clamped: String = symbol.chars().take(MAX_SYMBOL_CHARS).collect();
clamped.push('…');
clamped
}
pub(crate) fn decode_quote(
ev: &DxQuoteEvent,
instrument: &Instrument,
) -> Result<QuoteUpdate, ProviderError> {
let (bid, ask) =
checked_bid_ask(ev.bid, ev.ask).map_err(|kind| ProviderError::Normalize { kind })?;
Ok(QuoteUpdate {
instrument: instrument.clone(),
bid,
ask,
last: ev.last.and_then(positive_or_drop),
bid_size: positive_or_drop(ev.bid_size),
ask_size: positive_or_drop(ev.ask_size),
event_time: ev.event_time,
received_time: ev.received_time,
})
}
pub(crate) fn decode_greeks(
ev: &DxGreeksEvent,
instrument: &Instrument,
) -> Result<GreeksRow, ProviderError> {
Ok(GreeksRow {
instrument: instrument.clone(),
iv: iv_or_drop(ev.volatility),
delta: greek_or_drop(ev.delta),
gamma: greek_or_drop(ev.gamma),
theta: greek_or_drop(ev.theta),
vega: greek_or_drop(ev.vega),
rho: greek_or_drop(ev.rho),
origin: GreeksOrigin::Provider,
event_time: ev.event_time,
received_time: ev.received_time,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chain::{
ContractSpecFingerprint, ExerciseStyle, InstrumentKey, ProviderId, SettlementStyle,
};
use optionstratlib::OptionStyle;
use proptest::prelude::*;
#[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}"),
}
}
#[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}"),
}
}
fn sample_instrument() -> Instrument {
Instrument {
key: InstrumentKey {
underlying: "SPY".to_owned(),
expiration_utc: utc(1_700_000_000),
strike: pos(400.0),
style: OptionStyle::Call,
},
provider: pid("tastytrade"),
native_symbol: "SPY 250101C00400000".to_owned(),
stream_symbol: Some(".SPY250101C400".to_owned()),
spec: ContractSpecFingerprint {
contract_multiplier: 100,
settlement: SettlementStyle::Cash,
exercise: ExerciseStyle::European,
quote_currency: "USD".to_owned(),
venue_product_code: "SPY".to_owned(),
},
}
}
fn quote_ev(bid: f64, ask: f64) -> DxQuoteEvent {
DxQuoteEvent {
symbol: ".SPY250101C400".to_owned(),
bid,
ask,
bid_size: 10.0,
ask_size: 20.0,
last: None,
event_time: Some(utc(1_700_000_099)),
received_time: utc(1_700_000_100),
}
}
fn greeks_ev(delta: f64, iv: f64) -> DxGreeksEvent {
DxGreeksEvent {
symbol: ".SPY250101C400".to_owned(),
delta,
gamma: 0.01,
theta: -0.05,
vega: 0.20,
rho: 0.03,
volatility: iv,
event_time: Some(utc(1_700_000_099)),
received_time: utc(1_700_000_100),
}
}
fn tastytrade_quote(
bid: f64,
ask: f64,
bid_size: i32,
ask_size: i32,
time_ms: i64,
) -> DxQuoteEvent {
DxQuoteEvent {
symbol: ".SPY250101C400".to_owned(),
bid,
ask,
bid_size: f64::from(bid_size),
ask_size: f64::from(ask_size),
last: None,
event_time: DateTime::<Utc>::from_timestamp_millis(time_ms),
received_time: utc(1_700_000_100),
}
}
fn dxlink_quote(
bid: f64,
ask: f64,
bid_size: f64,
ask_size: f64,
event_time: Option<DateTime<Utc>>,
) -> DxQuoteEvent {
DxQuoteEvent {
symbol: ".SPY250101C400".to_owned(),
bid,
ask,
bid_size,
ask_size,
last: None,
event_time,
received_time: utc(1_700_000_100),
}
}
fn tastytrade_greeks(delta: f64, iv: f64, time_ms: i64) -> DxGreeksEvent {
DxGreeksEvent {
symbol: ".SPY250101C400".to_owned(),
delta,
gamma: 0.01,
theta: -0.05,
vega: 0.20,
rho: 0.03,
volatility: iv,
event_time: DateTime::<Utc>::from_timestamp_millis(time_ms),
received_time: utc(1_700_000_100),
}
}
fn dxlink_greeks(delta: f64, iv: f64, event_time: Option<DateTime<Utc>>) -> DxGreeksEvent {
DxGreeksEvent {
symbol: ".SPY250101C400".to_owned(),
delta,
gamma: 0.01,
theta: -0.05,
vega: 0.20,
rho: 0.03,
volatility: iv,
event_time,
received_time: utc(1_700_000_100),
}
}
#[track_caller]
fn decoded_quote(ev: &DxQuoteEvent) -> QuoteUpdate {
match decode_quote(ev, &sample_instrument()) {
Ok(q) => q,
Err(e) => panic!("expected a decoded quote, got: {e}"),
}
}
#[track_caller]
fn decoded_greeks(ev: &DxGreeksEvent) -> GreeksRow {
match decode_greeks(ev, &sample_instrument()) {
Ok(g) => g,
Err(e) => panic!("expected decoded greeks, got: {e}"),
}
}
fn quotes_field_equal(a: &QuoteUpdate, b: &QuoteUpdate) -> bool {
a.instrument.key == b.instrument.key
&& a.bid == b.bid
&& a.ask == b.ask
&& a.last == b.last
&& a.bid_size == b.bid_size
&& a.ask_size == b.ask_size
&& a.event_time == b.event_time
&& a.received_time == b.received_time
}
fn greeks_field_equal(a: &GreeksRow, b: &GreeksRow) -> bool {
a.instrument.key == b.instrument.key
&& a.iv == b.iv
&& a.delta == b.delta
&& a.gamma == b.gamma
&& a.theta == b.theta
&& a.vega == b.vega
&& a.rho == b.rho
&& a.origin == b.origin
&& a.event_time == b.event_time
&& a.received_time == b.received_time
}
#[test]
fn test_decode_quote_maps_present_fields() {
let q = decoded_quote(&DxQuoteEvent {
last: Some(1.6),
..quote_ev(1.5, 1.7)
});
assert_eq!(q.bid, Some(pos(1.5)));
assert_eq!(q.ask, Some(pos(1.7)));
assert_eq!(q.last, Some(pos(1.6)));
assert_eq!(q.bid_size, Some(pos(10.0)));
assert_eq!(q.ask_size, Some(pos(20.0)));
assert_eq!(q.event_time, Some(utc(1_700_000_099)));
assert_eq!(q.received_time, utc(1_700_000_100));
assert_eq!(q.instrument.key.strike, pos(400.0));
}
#[test]
fn test_decode_quote_event_time_none_when_absent() {
let q = decoded_quote(&DxQuoteEvent {
event_time: None,
..quote_ev(1.5, 1.7)
});
assert!(q.event_time.is_none());
assert_eq!(q.received_time, utc(1_700_000_100));
}
#[test]
fn test_decode_quote_zero_bid_is_kept_not_absent() {
let q = decoded_quote("e_ev(0.0, 1.0));
assert_eq!(q.bid, Some(Positive::ZERO));
assert_eq!(q.ask, Some(pos(1.0)));
}
#[test]
fn test_decode_quote_zero_bid_and_zero_ask_both_valid() {
let q = decoded_quote("e_ev(0.0, 0.0));
assert_eq!(q.bid, Some(Positive::ZERO));
assert_eq!(q.ask, Some(Positive::ZERO));
}
#[test]
fn test_decode_quote_zero_ask_on_nonzero_bid_is_crossed() {
match decode_quote("e_ev(1.5, 0.0), &sample_instrument()) {
Err(ProviderError::Normalize {
kind: NormalizeKind::OutOfRange(field),
}) => assert_eq!(field, "ask"),
other => panic!("expected crossed OutOfRange(\"ask\"), got {other:?}"),
}
}
#[test]
fn test_decode_quote_ask_below_bid_is_crossed() {
match decode_quote("e_ev(2.0, 1.0), &sample_instrument()) {
Err(ProviderError::Normalize {
kind: NormalizeKind::OutOfRange(field),
}) => assert_eq!(field, "ask"),
other => panic!("expected crossed OutOfRange(\"ask\"), got {other:?}"),
}
}
#[test]
fn test_decode_quote_error_names_field_not_value() {
match decode_quote("e_ev(9.99, 0.01), &sample_instrument()) {
Err(e) => {
let rendered = e.to_string();
assert!(
rendered.contains("ask"),
"should name the ask field: {rendered}"
);
assert!(
!rendered.contains("9.99"),
"must not echo the bid value: {rendered}"
);
assert!(
!rendered.contains("0.01"),
"must not echo the ask value: {rendered}"
);
}
Ok(q) => panic!("expected a crossed rejection, got {q:?}"),
}
}
#[test]
fn test_decode_quote_nan_bid_dropped_keeps_ask() {
let q = decoded_quote("e_ev(f64::NAN, 1.7));
assert!(q.bid.is_none(), "a NaN bid is dropped to None (absent)");
assert_eq!(q.ask, Some(pos(1.7)), "the ask survives the dropped bid");
}
#[test]
fn test_decode_quote_inf_ask_dropped_keeps_bid() {
let q = decoded_quote("e_ev(1.5, f64::INFINITY));
assert_eq!(q.bid, Some(pos(1.5)));
assert!(q.ask.is_none(), "an infinite ask is dropped to None");
}
#[test]
fn test_decode_quote_negative_fields_dropped() {
let q = decoded_quote(&DxQuoteEvent {
bid_size: -1.0,
ask_size: -2.0,
last: Some(-5.0),
..quote_ev(1.5, 1.7)
});
assert!(q.bid_size.is_none());
assert!(q.ask_size.is_none());
assert!(q.last.is_none());
assert_eq!(q.bid, Some(pos(1.5)));
assert_eq!(q.ask, Some(pos(1.7)));
}
#[test]
fn test_decode_quote_absent_last_stays_none() {
let q = decoded_quote("e_ev(1.5, 1.7));
assert!(q.last.is_none());
}
#[test]
fn test_decode_greeks_maps_all_fields() {
let g = decoded_greeks(&greeks_ev(-0.25, 0.35));
assert_eq!(g.delta, Some(Decimal::new(-25, 2)));
assert_eq!(g.gamma, Some(Decimal::new(1, 2)));
assert_eq!(g.theta, Some(Decimal::new(-5, 2)));
assert_eq!(g.vega, Some(Decimal::new(20, 2)));
assert_eq!(g.rho, Some(Decimal::new(3, 2)));
assert_eq!(g.origin, GreeksOrigin::Provider);
assert_eq!(g.event_time, Some(utc(1_700_000_099)));
assert_eq!(g.received_time, utc(1_700_000_100));
}
#[test]
fn test_decode_greeks_iv_carried_as_is_no_division() {
let g = decoded_greeks(&greeks_ev(0.5, 0.35));
assert_eq!(g.iv, Some(pos(0.35)));
}
#[test]
fn test_decode_greeks_zero_iv_is_valid() {
let g = decoded_greeks(&greeks_ev(0.5, 0.0));
assert_eq!(g.iv, Some(Positive::ZERO));
}
#[test]
fn test_decode_greeks_negative_greek_preserved() {
let g = decoded_greeks(&greeks_ev(-0.75, 0.35));
assert_eq!(g.delta, Some(Decimal::new(-75, 2)));
}
#[test]
fn test_decode_greeks_nan_greek_dropped_keeps_others() {
let g = decoded_greeks(&DxGreeksEvent {
gamma: f64::NAN,
..greeks_ev(-0.25, 0.35)
});
assert!(g.gamma.is_none(), "a NaN gamma is dropped to None");
assert_eq!(
g.delta,
Some(Decimal::new(-25, 2)),
"the other Greeks survive"
);
assert_eq!(g.iv, Some(pos(0.35)));
}
#[test]
fn test_decode_greeks_negative_iv_dropped() {
let g = decoded_greeks(&greeks_ev(0.5, -0.1));
assert!(g.iv.is_none(), "a negative IV is a field rejection (None)");
assert_eq!(g.delta, Some(Decimal::new(50, 2)));
}
#[test]
fn test_decode_greeks_all_absent_stays_none_still_provider() {
let g = decoded_greeks(&DxGreeksEvent {
delta: f64::NAN,
gamma: f64::NAN,
theta: f64::NAN,
vega: f64::NAN,
rho: f64::INFINITY,
volatility: f64::NAN,
..greeks_ev(0.0, 0.0)
});
assert!(g.iv.is_none());
assert!(g.delta.is_none());
assert!(g.gamma.is_none());
assert!(g.theta.is_none());
assert!(g.vega.is_none());
assert!(g.rho.is_none());
assert_eq!(g.origin, GreeksOrigin::Provider);
}
#[test]
fn test_tastytrade_and_dxlink_quote_shapes_decode_identically() {
let time_ms = 1_700_000_099_000;
let taste = tastytrade_quote(1.5, 1.7, 10, 20, time_ms);
let dxl = dxlink_quote(
1.5,
1.7,
10.0,
20.0,
DateTime::<Utc>::from_timestamp_millis(time_ms),
);
let a = decoded_quote(&taste);
let b = decoded_quote(&dxl);
assert!(
quotes_field_equal(&a, &b),
"identical data must decode identically: {a:?} vs {b:?}"
);
assert_eq!(a.bid, Some(pos(1.5)));
assert_eq!(a.event_time, Some(utc(1_700_000_099)));
}
#[test]
fn test_tastytrade_and_dxlink_greeks_shapes_decode_identically() {
let time_ms = 1_700_000_099_000;
let taste = tastytrade_greeks(-0.25, 0.35, time_ms);
let dxl = dxlink_greeks(-0.25, 0.35, DateTime::<Utc>::from_timestamp_millis(time_ms));
let a = decoded_greeks(&taste);
let b = decoded_greeks(&dxl);
assert!(
greeks_field_equal(&a, &b),
"identical data must decode identically: {a:?} vs {b:?}"
);
assert_eq!(a.iv, Some(pos(0.35)));
assert_eq!(a.delta, Some(Decimal::new(-25, 2)));
}
#[test]
fn test_clamp_symbol_under_limit_unchanged() {
let sym = ".SPY250101C400";
assert_eq!(clamp_symbol(sym), sym);
}
#[test]
fn test_clamp_symbol_over_limit_bounded_with_marker() {
let sym = "A".repeat(MAX_SYMBOL_CHARS + 20);
let clamped = clamp_symbol(&sym);
assert_eq!(
clamped.chars().count(),
MAX_SYMBOL_CHARS + 1,
"clamped to the cap plus one marker char"
);
assert!(clamped.ends_with('…'));
}
#[test]
fn test_clamp_symbol_multibyte_no_panic() {
let sym = "λ".repeat(MAX_SYMBOL_CHARS + 5);
let clamped = clamp_symbol(&sym);
assert_eq!(clamped.chars().count(), MAX_SYMBOL_CHARS + 1);
assert!(clamped.ends_with('…'));
}
proptest! {
#![proptest_config(ProptestConfig { cases: 512, ..ProptestConfig::default() })]
#[test]
fn prop_decode_quote_is_total(
bid in proptest::num::f64::ANY,
ask in proptest::num::f64::ANY,
bid_size in proptest::num::f64::ANY,
ask_size in proptest::num::f64::ANY,
last in prop_oneof![Just(None), proptest::num::f64::ANY.prop_map(Some)],
) {
let ev = DxQuoteEvent {
symbol: "SYM".to_owned(),
bid,
ask,
bid_size,
ask_size,
last,
event_time: None,
received_time: utc(1_700_000_100),
};
match decode_quote(&ev, &sample_instrument()) {
Ok(q) => {
if let (Some(b), Some(a)) = (q.bid, q.ask) {
prop_assert!(a >= b, "an Ok quote is never crossed");
}
prop_assert_eq!(q.received_time, utc(1_700_000_100));
}
Err(ProviderError::Normalize { kind }) => {
prop_assert_eq!(kind, NormalizeKind::OutOfRange("ask"));
}
Err(other) => prop_assert!(false, "unexpected error variant: {:?}", other),
}
}
#[test]
fn prop_decode_greeks_is_total(
delta in proptest::num::f64::ANY,
gamma in proptest::num::f64::ANY,
theta in proptest::num::f64::ANY,
vega in proptest::num::f64::ANY,
rho in proptest::num::f64::ANY,
volatility in proptest::num::f64::ANY,
) {
let ev = DxGreeksEvent {
symbol: "SYM".to_owned(),
delta,
gamma,
theta,
vega,
rho,
volatility,
event_time: None,
received_time: utc(1_700_000_100),
};
match decode_greeks(&ev, &sample_instrument()) {
Ok(g) => {
if let Some(iv) = g.iv {
prop_assert!(iv >= Positive::ZERO);
}
prop_assert_eq!(g.origin, GreeksOrigin::Provider);
}
Err(other) => prop_assert!(false, "greeks decode should not error: {:?}", other),
}
}
#[test]
fn prop_clamp_symbol_is_bounded(sym in ".{0,256}") {
let clamped = clamp_symbol(&sym);
prop_assert!(clamped.chars().count() <= MAX_SYMBOL_CHARS + 1);
}
}
}