use std::collections::HashMap;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use async_trait::async_trait;
use chrono::{DateTime, Datelike, NaiveDate, TimeDelta, Utc, Weekday};
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::{Decimal, Positive};
use optionstratlib::{ExpirationDate, OptionStyle};
use tokio_util::sync::CancellationToken;
use tastytrade::TastyTrade;
use tastytrade::prelude::{
ClientSecret, DxFeedSymbol, Event, EventData, EventKind, InstrumentType, OptionExpiration,
OptionNestedChain, OptionStrike, QuoteStreamer, QuoteSubscription, RefreshToken,
TastyTradeConfig,
};
use super::dxfeed_decode::{DxGreeksEvent, DxQuoteEvent, decode_greeks, decode_quote};
use super::{
AuthKind, ChainCapability, ChainPollCapability, GreeksCapability, MarketUpdateSink,
OptionStreamCapability, Provider, ProviderCapabilities, SendState, SubscriptionHandle,
SubscriptionRequest, UnderlyingRef,
};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSnapshot, ChainSource, ContractSpecFingerprint, ExerciseStyle,
ExpirySource, Instrument, InstrumentKey, MarketUpdate, ProviderId, SettlementStyle,
StreamHealth,
};
use crate::config::{EnvSource, Secret, require_credentials};
use crate::error::{NormalizeKind, ProviderError, TransportDetail, TransportKind};
const TASTYTRADE_ID: &str = "tastytrade";
const TASTYTRADE_BASE_URL: &str = "https://api.tastyworks.com";
const TASTYTRADE_WS_URL: &str = "wss://streamer.tastyworks.com";
const CREDENTIAL_KEYS: [&str; 2] = ["client_secret", "refresh_token"];
const REFRESH_HINT_SECS: u32 = 2;
const DEFAULT_SHARES_PER_CONTRACT: u32 = 100;
const QUOTE_CURRENCY: &str = "USD";
const NO_VENUE_TIME_MS: i64 = 0;
const BACKOFF_BASE_MS: f64 = 250.0;
const BACKOFF_MAX_MS: f64 = 30_000.0;
const JITTER_MAGNITUDE: f64 = 0.2;
const BACKOFF_MAX_SHIFT: u32 = 20;
#[derive(Clone)]
pub(crate) struct TastytradeAdapter {
id: ProviderId,
client_secret: Secret,
refresh_token: Secret,
base_url: String,
}
impl TastytradeAdapter {
pub(crate) fn from_env(env: &dyn EnvSource) -> Result<Self, crate::error::ConfigError> {
let id = tastytrade_provider_id();
let creds = require_credentials(env, &id, &CREDENTIAL_KEYS)?;
let client_secret = creds
.get("CLIENT_SECRET")
.cloned()
.ok_or_else(|| crate::error::ConfigError::MissingCredential(id.clone()))?;
let refresh_token = creds
.get("REFRESH_TOKEN")
.cloned()
.ok_or_else(|| crate::error::ConfigError::MissingCredential(id.clone()))?;
Ok(Self {
id,
client_secret,
refresh_token,
base_url: TASTYTRADE_BASE_URL.to_owned(),
})
}
async fn connect(&self) -> Result<TastyTrade, ProviderError> {
let config = TastyTradeConfig {
client_secret: ClientSecret::new(self.client_secret.expose()),
refresh_token: RefreshToken::new(self.refresh_token.expose()),
client_id: String::new(),
redirect_uri: String::new(),
use_demo: false,
log_level: "OFF".to_owned(),
base_url: self.base_url.clone(),
websocket_url: TASTYTRADE_WS_URL.to_owned(),
};
TastyTrade::connect(&config).await.map_err(connect_error)
}
}
#[async_trait]
impl Provider for TastytradeAdapter {
fn id(&self) -> ProviderId {
self.id.clone()
}
fn capabilities(&self) -> ProviderCapabilities {
tastytrade_capabilities()
}
async fn discover(&self) -> Result<Vec<UnderlyingRef>, ProviderError> {
Err(ProviderError::Unsupported("underlying discovery"))
}
async fn fetch_chain(
&self,
underlying: &str,
expiration: &ExpirationDate,
) -> Result<ChainFetch, ProviderError> {
let symbol = underlying.to_ascii_uppercase();
let target = expiration
.get_date()
.map_err(|_| ProviderError::Normalize {
kind: NormalizeKind::UnparseableExpiry,
})?;
let target_day = target.date_naive();
let client = self.connect().await?;
let nested = client
.nested_option_chain_for(symbol.clone())
.await
.map_err(|err| transport_error(&err))?;
let Some(chosen) = select_expiration(&nested, target_day) else {
return Err(ProviderError::NoChain {
underlying: symbol,
expiration: target.to_rfc3339(),
});
};
let mut streamers: HashMap<String, String> = HashMap::new();
for strike in &chosen.strikes {
for occ in [&strike.call, &strike.put] {
if !streamers.contains_key(&occ.0)
&& let Ok(dx) = client
.get_streamer_symbol(&InstrumentType::EquityOption, occ)
.await
{
let _ = streamers.insert(occ.0.clone(), dx.0);
}
}
}
assemble_chain(&nested, chosen, &self.id, &|occ| {
streamers.get(occ).cloned()
})
}
async fn subscribe(
&self,
req: SubscriptionRequest,
sink: MarketUpdateSink,
) -> Result<SubscriptionHandle, ProviderError> {
let transport = LiveTransport::new(self.clone());
let id = self.id.clone();
let SubscriptionRequest {
underlying,
expiration_utc,
instruments,
cancel,
} = req;
let loop_cancel = cancel.clone();
let handle = tokio::spawn(run_reconnect_loop(
transport,
id,
underlying,
expiration_utc,
instruments,
sink,
loop_cancel,
));
Ok(SubscriptionHandle::spawned(cancel, handle))
}
}
fn tastytrade_provider_id() -> ProviderId {
match ProviderId::new(TASTYTRADE_ID) {
Ok(id) => id,
Err(_) => unreachable!("`tastytrade` is a valid, reserved provider id literal"),
}
}
#[must_use]
pub(crate) fn tastytrade_capabilities() -> ProviderCapabilities {
ProviderCapabilities::builder()
.chain(ChainCapability::Native)
.depth(false)
.greeks(GreeksCapability::Provided)
.option_stream(OptionStreamCapability::ChainQuotes { verified: false })
.underlying_stream(false)
.chain_poll(ChainPollCapability::Poll {
interval_hint_secs: REFRESH_HINT_SECS,
})
.trades_tape(false)
.auth(AuthKind::OAuth)
.build()
}
fn expiry_to_utc(date: NaiveDate) -> Result<DateTime<Utc>, NormalizeKind> {
let offset_hours = if is_us_eastern_dst(date) { 4 } else { 5 };
let local_close = date
.and_hms_opt(16, 0, 0)
.ok_or(NormalizeKind::UnparseableExpiry)?;
let utc_naive = local_close
.checked_add_signed(TimeDelta::hours(offset_hours))
.ok_or(NormalizeKind::UnparseableExpiry)?;
Ok(DateTime::<Utc>::from_naive_utc_and_offset(utc_naive, Utc))
}
fn is_us_eastern_dst(date: NaiveDate) -> bool {
let year = date.year();
match (
nth_weekday_of_month(year, 3, Weekday::Sun, 2),
nth_weekday_of_month(year, 11, Weekday::Sun, 1),
) {
(Some(start), Some(end)) => date >= start && date < end,
_ => false,
}
}
fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: u32) -> Option<NaiveDate> {
let first = NaiveDate::from_ymd_opt(year, month, 1)?;
let first_dow = first.weekday().num_days_from_sunday();
let target_dow = weekday.num_days_from_sunday();
let offset = (target_dow + 7 - first_dow) % 7;
let day = 1u32
.checked_add(offset)?
.checked_add(n.checked_sub(1)?.checked_mul(7)?)?;
NaiveDate::from_ymd_opt(year, month, day)
}
fn strike_positive(value: Decimal) -> Result<Positive, NormalizeKind> {
let strike = Positive::new_decimal(value).map_err(|_| NormalizeKind::OutOfRange("strike"))?;
if strike == Positive::ZERO {
return Err(NormalizeKind::OutOfRange("strike"));
}
Ok(strike)
}
fn multiplier_of(shares_per_contract: u64) -> u32 {
u32::try_from(shares_per_contract)
.ok()
.filter(|value| *value >= 1)
.unwrap_or(DEFAULT_SHARES_PER_CONTRACT)
}
fn tastytrade_fingerprint(root_symbol: &str, multiplier: u32) -> ContractSpecFingerprint {
ContractSpecFingerprint {
contract_multiplier: multiplier,
settlement: SettlementStyle::Physical,
exercise: ExerciseStyle::American,
quote_currency: QUOTE_CURRENCY.to_owned(),
venue_product_code: root_symbol.to_owned(),
}
}
fn select_expiration(
nested: &OptionNestedChain,
target_day: NaiveDate,
) -> Option<&OptionExpiration> {
nested
.expirations
.iter()
.find(|expiration| expiration.expiration_date == target_day)
}
#[derive(Debug, Clone)]
struct NormalizedLeg {
strike: Positive,
native_symbol: String,
stream_symbol: Option<String>,
style: OptionStyle,
}
fn normalize_strike(
strike: &OptionStrike,
resolve_streamer: &dyn Fn(&str) -> Option<String>,
) -> Vec<NormalizedLeg> {
let Ok(strike_price) = strike_positive(strike.strike_price) else {
return Vec::new();
};
let call = NormalizedLeg {
strike: strike_price,
native_symbol: strike.call.0.clone(),
stream_symbol: resolve_streamer(&strike.call.0),
style: OptionStyle::Call,
};
let put = NormalizedLeg {
strike: strike_price,
native_symbol: strike.put.0.clone(),
stream_symbol: resolve_streamer(&strike.put.0),
style: OptionStyle::Put,
};
vec![call, put]
}
fn assemble_chain(
nested: &OptionNestedChain,
expiration: &OptionExpiration,
provider: &ProviderId,
resolve_streamer: &dyn Fn(&str) -> Option<String>,
) -> Result<ChainFetch, ProviderError> {
let underlying = nested.underlying_symbol.0.to_ascii_uppercase();
let expiration_utc = expiry_to_utc(expiration.expiration_date)
.map_err(|kind| ProviderError::Normalize { kind })?;
let multiplier = multiplier_of(nested.shares_per_contract);
let spec = tastytrade_fingerprint(&nested.root_symbol.0, multiplier);
let legs: Vec<NormalizedLeg> = expiration
.strikes
.iter()
.flat_map(|strike| normalize_strike(strike, resolve_streamer))
.collect();
if legs.is_empty() {
return Err(ProviderError::NoChain {
underlying,
expiration: expiration_utc.to_rfc3339(),
});
}
let mut aliases = AliasCatalog::new();
for leg in &legs {
aliases.insert(Instrument {
key: InstrumentKey {
underlying: underlying.clone(),
expiration_utc,
strike: leg.strike,
style: leg.style,
},
provider: provider.clone(),
native_symbol: leg.native_symbol.clone(),
stream_symbol: leg.stream_symbol.clone(),
spec: spec.clone(),
});
}
let mut by_strike: std::collections::BTreeMap<Positive, StrikePair<'_>> =
std::collections::BTreeMap::new();
for leg in &legs {
let entry = by_strike.entry(leg.strike).or_default();
match leg.style {
OptionStyle::Call => entry.call = Some(leg),
OptionStyle::Put => entry.put = Some(leg),
}
}
let spot = median_strike(&by_strike);
let mut chain = OptionChain::new(&underlying, spot, expiration_utc.to_rfc3339(), None, None);
for strike in by_strike.keys() {
chain.add_option(
*strike,
None,
None,
None,
None,
Positive::ZERO,
None,
None,
None,
None,
None,
None,
);
}
Ok(ChainFetch::new(
chain,
ExpirySource::new(underlying, expiration_utc, provider.clone()),
aliases,
))
}
#[derive(Debug, Default)]
struct StrikePair<'a> {
call: Option<&'a NormalizedLeg>,
put: Option<&'a NormalizedLeg>,
}
fn median_strike(by_strike: &std::collections::BTreeMap<Positive, StrikePair<'_>>) -> Positive {
let strikes: Vec<Positive> = by_strike.keys().copied().collect();
let mid = strikes.len() / 2;
strikes.get(mid).copied().unwrap_or(Positive::ONE)
}
fn connect_error(err: tastytrade::TastyTradeError) -> ProviderError {
transport_error(&err)
}
fn transport_error(err: &tastytrade::TastyTradeError) -> ProviderError {
use tastytrade::TastyTradeError as E;
match err {
E::Auth(_) => ProviderError::Auth,
E::Json(_) => transport(TransportKind::Decode),
E::WebSocket(_) | E::DxFeed(_) | E::Streaming(_) | E::Connection(_) | E::Io(_) => {
transport(TransportKind::Closed)
}
E::Request { context, .. } => ProviderError::Transport(Box::new(TransportDetail::new(
TransportKind::Http,
context.status,
))),
E::Precondition(_) => ProviderError::Unsupported("tastytrade request precondition"),
E::Http(_) | E::Api(_) | E::Unknown(_) | E::ConfigError(_) => {
transport(TransportKind::Http)
}
}
}
fn transport(kind: TransportKind) -> ProviderError {
ProviderError::Transport(Box::new(TransportDetail::new(kind, None)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct TransportGone;
#[derive(Debug, Clone)]
enum RawDxEvent {
Quote {
symbol: String,
bid: f64,
ask: f64,
bid_size: i64,
ask_size: i64,
time_ms: i64,
},
Greeks {
symbol: String,
delta: f64,
gamma: f64,
theta: f64,
vega: f64,
rho: f64,
volatility: f64,
time_ms: i64,
},
Ignored,
}
#[async_trait]
trait TastyTransport: Send {
async fn connect_and_subscribe(&mut self, symbols: Vec<String>) -> Result<(), TransportGone>;
async fn receive(&mut self) -> Result<RawDxEvent, TransportGone>;
async fn refetch(
&mut self,
underlying: &str,
expiration: &ExpirationDate,
) -> Option<ChainFetch>;
}
struct LiveTransport {
adapter: TastytradeAdapter,
streamer: Option<QuoteStreamer>,
subscription: Option<Box<QuoteSubscription>>,
}
impl LiveTransport {
fn new(adapter: TastytradeAdapter) -> Self {
Self {
adapter,
streamer: None,
subscription: None,
}
}
}
#[async_trait]
impl TastyTransport for LiveTransport {
async fn connect_and_subscribe(&mut self, symbols: Vec<String>) -> Result<(), TransportGone> {
let client = self.adapter.connect().await.map_err(|_| TransportGone)?;
let mut streamer = client
.create_quote_streamer()
.await
.map_err(|_| TransportGone)?;
let subscription = streamer
.create_sub([EventKind::Quote, EventKind::Greeks])
.await
.map_err(|_| TransportGone)?;
let dxfeed_symbols: Vec<DxFeedSymbol> = symbols.into_iter().map(DxFeedSymbol).collect();
subscription
.add_symbols(&dxfeed_symbols)
.await
.map_err(|_| TransportGone)?;
self.subscription = Some(subscription);
self.streamer = Some(streamer);
Ok(())
}
async fn receive(&mut self) -> Result<RawDxEvent, TransportGone> {
match self.subscription.as_mut() {
Some(subscription) => {
let event = subscription.get_event().await.map_err(|_| TransportGone)?;
Ok(map_dxfeed_event(event))
}
None => Err(TransportGone),
}
}
async fn refetch(
&mut self,
underlying: &str,
expiration: &ExpirationDate,
) -> Option<ChainFetch> {
self.adapter.fetch_chain(underlying, expiration).await.ok()
}
}
fn map_dxfeed_event(event: Event) -> RawDxEvent {
let symbol = event.sym;
match event.data {
EventData::Quote(quote) => RawDxEvent::Quote {
symbol,
bid: quote.bid_price,
ask: quote.ask_price,
bid_size: quote.bid_size,
ask_size: quote.ask_size,
time_ms: quote.time,
},
EventData::Greeks(greeks) => RawDxEvent::Greeks {
symbol,
delta: greeks.delta,
gamma: greeks.gamma,
theta: greeks.theta,
vega: greeks.vega,
rho: greeks.rho,
volatility: greeks.volatility,
time_ms: greeks.time,
},
_ => RawDxEvent::Ignored,
}
}
enum StreamExit {
Reconnect,
Shutdown,
}
async fn run_reconnect_loop<T: TastyTransport>(
mut transport: T,
id: ProviderId,
underlying: String,
expiration_utc: DateTime<Utc>,
mut instruments: Vec<Instrument>,
mut sink: MarketUpdateSink,
cancel: CancellationToken,
) {
let mut attempt: u32 = 0;
loop {
if cancel.is_cancelled() || sink.is_closed() {
return;
}
let exit = tokio::select! {
biased;
() = cancel.cancelled() => return,
exit = connect_stream_once(&mut transport, &id, &instruments, &mut sink, &cancel, &mut attempt) => exit,
};
if matches!(exit, StreamExit::Shutdown) || cancel.is_cancelled() {
return;
}
attempt = attempt.checked_add(1).unwrap_or(attempt);
let health = MarketUpdate::Health(id.clone(), StreamHealth::Reconnecting { attempt });
let health_sent = tokio::select! {
biased;
() = cancel.cancelled() => return,
outcome = sink.send(health) => outcome,
};
if health_sent == SendState::Closed {
return;
}
let delay = backoff_delay(attempt, sample_jitter());
tokio::select! {
biased;
() = cancel.cancelled() => return,
() = tokio::time::sleep(delay) => {}
}
if let Some(fresh) = refetch(
&mut transport,
&id,
&underlying,
expiration_utc,
&mut sink,
&cancel,
)
.await
&& !fresh.is_empty()
{
instruments = fresh;
}
}
}
async fn connect_stream_once<T: TastyTransport>(
transport: &mut T,
id: &ProviderId,
instruments: &[Instrument],
sink: &mut MarketUpdateSink,
cancel: &CancellationToken,
attempt: &mut u32,
) -> StreamExit {
let symbols = subscription_symbols(instruments);
let subscribed = tokio::select! {
biased;
() = cancel.cancelled() => return StreamExit::Shutdown,
result = transport.connect_and_subscribe(symbols) => result,
};
if subscribed.is_err() {
return StreamExit::Reconnect;
}
*attempt = 0;
let live = MarketUpdate::Health(id.clone(), StreamHealth::Live);
if sink.send(live).await == SendState::Closed {
return StreamExit::Shutdown;
}
let lookup = stream_lookup(instruments);
loop {
let event = tokio::select! {
biased;
() = cancel.cancelled() => return StreamExit::Shutdown,
event = transport.receive() => event,
};
let event = match event {
Ok(event) => event,
Err(_) => return StreamExit::Reconnect,
};
if route_event(&event, &lookup, sink).await == SendState::Closed {
return StreamExit::Shutdown;
}
}
}
async fn refetch<T: TastyTransport>(
transport: &mut T,
id: &ProviderId,
underlying: &str,
expiration_utc: DateTime<Utc>,
sink: &mut MarketUpdateSink,
cancel: &CancellationToken,
) -> Option<Vec<Instrument>> {
let expiration = ExpirationDate::DateTime(expiration_utc);
let fetched = tokio::select! {
biased;
() = cancel.cancelled() => return None,
result = transport.refetch(underlying, &expiration) => result,
};
let fetch = fetched?;
let snapshot = MarketUpdate::Chain(chain_snapshot(&fetch, now_utc()));
let snapshot_sent = tokio::select! {
biased;
() = cancel.cancelled() => return None,
outcome = sink.send(snapshot) => outcome,
};
if snapshot_sent == SendState::Closed {
return None;
}
let instruments: Vec<Instrument> = fetch
.aliases
.instruments()
.filter(|instrument| instrument.provider == *id)
.cloned()
.collect();
Some(instruments)
}
fn chain_snapshot(fetch: &ChainFetch, last_poll: DateTime<Utc>) -> ChainSnapshot {
ChainSnapshot {
chain_key: (
fetch.expiry_source.provider.clone(),
fetch.expiry_source.underlying.clone(),
fetch.expiry_source.expiration_utc,
),
chain: fetch.chain.clone(),
aliases: fetch.aliases.clone(),
source: ChainSource::Merged,
health: StreamHealth::Live,
last_full_poll: Some(last_poll),
}
}
fn subscription_symbols(instruments: &[Instrument]) -> Vec<String> {
instruments
.iter()
.filter_map(|instrument| instrument.stream_symbol.clone())
.collect()
}
fn stream_lookup(instruments: &[Instrument]) -> HashMap<String, Instrument> {
instruments
.iter()
.filter_map(|instrument| {
instrument
.stream_symbol
.clone()
.map(|symbol| (symbol, instrument.clone()))
})
.collect()
}
async fn route_event(
event: &RawDxEvent,
lookup: &HashMap<String, Instrument>,
sink: &mut MarketUpdateSink,
) -> SendState {
let received = now_utc();
match event {
RawDxEvent::Quote {
symbol,
bid,
ask,
bid_size,
ask_size,
time_ms,
} => {
let Some(instrument) = lookup.get(symbol) else {
return SendState::Open;
};
let (Some(bid_size), Some(ask_size)) = (size_to_f64(*bid_size), size_to_f64(*ask_size))
else {
return SendState::Open;
};
let view = DxQuoteEvent {
symbol: symbol.clone(),
bid: *bid,
ask: *ask,
bid_size,
ask_size,
last: None,
event_time: ms_to_event_time(*time_ms),
received_time: received,
};
match decode_quote(&view, instrument) {
Ok(quote) => sink.send(MarketUpdate::Quote(quote)).await,
Err(_) => SendState::Open,
}
}
RawDxEvent::Greeks {
symbol,
delta,
gamma,
theta,
vega,
rho,
volatility,
time_ms,
} => {
let Some(instrument) = lookup.get(symbol) else {
return SendState::Open;
};
let view = DxGreeksEvent {
symbol: symbol.clone(),
delta: *delta,
gamma: *gamma,
theta: *theta,
vega: *vega,
rho: *rho,
volatility: *volatility,
event_time: ms_to_event_time(*time_ms),
received_time: received,
};
match decode_greeks(&view, instrument) {
Ok(greeks) => sink.send(MarketUpdate::Greeks(greeks)).await,
Err(_) => SendState::Open,
}
}
RawDxEvent::Ignored => SendState::Open,
}
}
#[allow(clippy::cast_precision_loss)]
fn size_to_f64(size: i64) -> Option<f64> {
if size.unsigned_abs() < (1u64 << 53) {
Some(size as f64)
} else {
None
}
}
fn ms_to_event_time(time_ms: i64) -> Option<DateTime<Utc>> {
if time_ms == NO_VENUE_TIME_MS {
return None;
}
DateTime::<Utc>::from_timestamp_millis(time_ms)
}
#[must_use]
fn backoff_delay(attempt: u32, jitter: f64) -> Duration {
let exponent = attempt.min(BACKOFF_MAX_SHIFT);
let uncapped = BACKOFF_BASE_MS * 2.0_f64.powi(exponent as i32);
let capped = uncapped.min(BACKOFF_MAX_MS);
let jitter = jitter.clamp(-JITTER_MAGNITUDE, JITTER_MAGNITUDE);
let millis = capped * (1.0 + jitter);
Duration::from_secs_f64(millis / 1000.0)
}
fn sample_jitter() -> f64 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.subsec_nanos());
let unit = f64::from(nanos) / 1_000_000_000.0;
(unit * 2.0 - 1.0) * JITTER_MAGNITUDE
}
fn now_utc() -> DateTime<Utc> {
let since = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO);
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
DateTime::<Utc>::from_timestamp(secs, since.subsec_nanos()).unwrap_or(DateTime::<Utc>::MIN_UTC)
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use chrono::Timelike;
use proptest::prelude::*;
use tokio::sync::mpsc;
use super::*;
use crate::chain::{GreeksOrigin, MarketUpdate};
#[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 pos(value: f64) -> Positive {
match Positive::new(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive `{value}`: {e}"),
}
}
#[track_caller]
fn date(s: &str) -> NaiveDate {
match NaiveDate::parse_from_str(s, "%Y-%m-%d") {
Ok(d) => d,
Err(e) => panic!("invalid test date `{s}`: {e}"),
}
}
struct MapEnv(HashMap<String, String>);
impl EnvSource for MapEnv {
fn get(&self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
}
fn creds_env() -> MapEnv {
let mut env = HashMap::new();
let _ = env.insert(
"CHAINVIEW_TASTYTRADE_CLIENT_SECRET".to_owned(),
"do-not-log-this-client-secret".to_owned(),
);
let _ = env.insert(
"CHAINVIEW_TASTYTRADE_REFRESH_TOKEN".to_owned(),
"do-not-log-this-refresh-token".to_owned(),
);
MapEnv(env)
}
#[track_caller]
fn sample_adapter() -> TastytradeAdapter {
match TastytradeAdapter::from_env(&creds_env()) {
Ok(adapter) => adapter,
Err(e) => panic!("from_env should succeed with both creds present: {e}"),
}
}
const NESTED_SPY_JSON: &str =
include_str!("../../tests/fixtures/tastytrade/nested_option_chain_spy.json");
#[track_caller]
fn nested_spy() -> OptionNestedChain {
match serde_json::from_str::<OptionNestedChain>(NESTED_SPY_JSON) {
Ok(chain) => chain,
Err(e) => panic!("nested-chain fixture must deserialize: {e}"),
}
}
fn streamer_map() -> HashMap<String, String> {
let mut map = HashMap::new();
for (occ, dx) in [
("SPY 260320C00500000", ".SPY260320C500"),
("SPY 260320P00500000", ".SPY260320P500"),
("SPY 260320C00510000", ".SPY260320C510"),
("SPY 260320P00510000", ".SPY260320P510"),
] {
let _ = map.insert(occ.to_owned(), dx.to_owned());
}
map
}
#[track_caller]
fn assembled_spy() -> ChainFetch {
let nested = nested_spy();
let map = streamer_map();
let target = date("2026-03-20");
let Some(expiration) = select_expiration(&nested, target) else {
panic!("fixture must contain the 2026-03-20 expiration");
};
match assemble_chain(&nested, expiration, &pid("tastytrade"), &|occ| {
map.get(occ).cloned()
}) {
Ok(fetch) => fetch,
Err(e) => panic!("assemble_chain should succeed for the fixture, got: {e}"),
}
}
#[test]
fn test_tastytrade_id_is_valid_and_reserved() {
let id = tastytrade_provider_id();
assert_eq!(id.as_str(), "tastytrade");
assert!(id.is_reserved());
assert!(ProviderId::new(TASTYTRADE_ID).is_ok());
}
#[test]
fn test_tastytrade_capabilities_match_section_8_row() {
let caps = tastytrade_capabilities();
assert_eq!(caps.chain, ChainCapability::Native);
assert!(!caps.depth);
assert_eq!(caps.greeks, GreeksCapability::Provided);
assert_eq!(
caps.option_stream,
OptionStreamCapability::ChainQuotes { verified: false }
);
assert!(
!caps.underlying_stream,
"declared FALSE until a real underlying quote is subscribed AND folded"
);
assert_eq!(
caps.chain_poll,
ChainPollCapability::Poll {
interval_hint_secs: REFRESH_HINT_SECS
}
);
assert!(!caps.trades_tape);
assert_eq!(caps.auth, AuthKind::OAuth);
}
#[test]
fn test_adapter_reports_capabilities_and_id_via_trait() {
let adapter: Box<dyn Provider> = Box::new(sample_adapter());
assert_eq!(adapter.id().as_str(), "tastytrade");
assert_eq!(adapter.capabilities().chain, ChainCapability::Native);
}
#[test]
fn test_credentials_never_appear_in_debug_of_adapter_secrets() {
let adapter = sample_adapter();
for rendered in [
format!("{:?}", adapter.client_secret),
format!("{:?}", adapter.refresh_token),
] {
assert!(!rendered.contains("do-not-log-this-client-secret"));
assert!(!rendered.contains("do-not-log-this-refresh-token"));
assert!(rendered.contains("redacted"));
}
}
#[test]
fn test_upstream_config_debug_redacts_both_oauth_secrets() {
let adapter = sample_adapter();
let config = TastyTradeConfig {
client_secret: ClientSecret::new(adapter.client_secret.expose()),
refresh_token: RefreshToken::new(adapter.refresh_token.expose()),
client_id: String::new(),
redirect_uri: String::new(),
use_demo: false,
log_level: "OFF".to_owned(),
base_url: adapter.base_url.clone(),
websocket_url: TASTYTRADE_WS_URL.to_owned(),
};
for rendered in [format!("{config:?}"), format!("{config}")] {
assert!(
!rendered.contains("do-not-log-this-client-secret"),
"the client secret leaked: {rendered}"
);
assert!(
!rendered.contains("do-not-log-this-refresh-token"),
"the refresh token leaked: {rendered}"
);
}
}
#[test]
fn test_from_env_reads_chainview_namespace_only() {
let mut env = HashMap::new();
let _ = env.insert(
"CHAINVIEW_TASTYTRADE_CLIENT_SECRET".to_owned(),
"cs-abc".to_owned(),
);
let _ = env.insert(
"CHAINVIEW_TASTYTRADE_REFRESH_TOKEN".to_owned(),
"rt-xyz".to_owned(),
);
let _ = env.insert("TASTYTRADE_CLIENT_SECRET".to_owned(), "foreign".to_owned());
let adapter = match TastytradeAdapter::from_env(&MapEnv(env)) {
Ok(adapter) => adapter,
Err(e) => panic!("from_env should succeed: {e}"),
};
assert_eq!(adapter.client_secret.expose(), "cs-abc");
assert_eq!(adapter.refresh_token.expose(), "rt-xyz");
assert_eq!(adapter.base_url, TASTYTRADE_BASE_URL);
}
#[test]
fn test_from_env_missing_credential_is_error() {
let mut env = HashMap::new();
let _ = env.insert(
"CHAINVIEW_TASTYTRADE_CLIENT_SECRET".to_owned(),
"cs-abc".to_owned(),
);
match TastytradeAdapter::from_env(&MapEnv(env)) {
Err(crate::error::ConfigError::MissingCredential(id)) => {
assert_eq!(id.as_str(), "tastytrade");
}
Err(other) => panic!("expected MissingCredential, got a different error: {other}"),
Ok(_) => panic!("expected MissingCredential, got Ok (adapter not Debug by design)"),
}
}
#[test]
fn test_retired_userpass_credentials_no_longer_authenticate() {
let mut env = HashMap::new();
let _ = env.insert(
"CHAINVIEW_TASTYTRADE_USERNAME".to_owned(),
"alice".to_owned(),
);
let _ = env.insert(
"CHAINVIEW_TASTYTRADE_PASSWORD".to_owned(),
"secret-pw".to_owned(),
);
match TastytradeAdapter::from_env(&MapEnv(env)) {
Err(crate::error::ConfigError::MissingCredential(id)) => {
assert_eq!(id.as_str(), "tastytrade");
}
Err(other) => panic!("expected MissingCredential, got a different error: {other}"),
Ok(_) => panic!("the retired username/password pair must not build an adapter"),
}
}
#[test]
fn test_expiry_edt_resolves_to_2000_utc() {
match expiry_to_utc(date("2026-03-20")) {
Ok(utc) => assert_eq!(utc.to_rfc3339(), "2026-03-20T20:00:00+00:00"),
Err(e) => panic!("EDT expiry should resolve, got: {e}"),
}
}
#[test]
fn test_expiry_est_resolves_to_2100_utc() {
match expiry_to_utc(date("2026-11-20")) {
Ok(utc) => assert_eq!(utc.to_rfc3339(), "2026-11-20T21:00:00+00:00"),
Err(e) => panic!("EST expiry should resolve, got: {e}"),
}
}
#[test]
fn test_expiry_dst_start_boundary_is_edt() {
assert!(is_us_eastern_dst(date("2026-03-08")));
match expiry_to_utc(date("2026-03-08")) {
Ok(utc) => assert_eq!(utc.to_rfc3339(), "2026-03-08T20:00:00+00:00"),
Err(e) => panic!("DST-start expiry should resolve, got: {e}"),
}
assert!(!is_us_eastern_dst(date("2026-03-07")));
}
#[test]
fn test_expiry_dst_end_boundary_is_est() {
assert!(!is_us_eastern_dst(date("2026-11-01")));
match expiry_to_utc(date("2026-11-01")) {
Ok(utc) => assert_eq!(utc.to_rfc3339(), "2026-11-01T21:00:00+00:00"),
Err(e) => panic!("DST-end expiry should resolve, got: {e}"),
}
assert!(is_us_eastern_dst(date("2026-10-31")));
}
#[test]
fn test_expiry_fixed_2100_helper_is_not_used_in_summer() {
match expiry_to_utc(date("2026-07-17")) {
Ok(utc) => {
assert_eq!(utc.to_rfc3339(), "2026-07-17T20:00:00+00:00");
assert_ne!(utc.to_rfc3339(), "2026-07-17T21:00:00+00:00");
}
Err(e) => panic!("summer expiry should resolve, got: {e}"),
}
}
#[test]
fn test_malformed_expiration_date_is_rejected_upstream() {
for bad in ["not-a-date", "2026-13-01", "2026-03", "2026-03-20-01"] {
let json = NESTED_SPY_JSON.replace("\"2026-03-20\"", &format!("\"{bad}\""));
assert!(
serde_json::from_str::<OptionNestedChain>(&json).is_err(),
"a malformed expiration-date `{bad}` must be rejected upstream"
);
}
}
#[test]
fn test_nth_weekday_of_month() {
assert_eq!(
nth_weekday_of_month(2026, 3, Weekday::Sun, 2),
Some(date("2026-03-08"))
);
assert_eq!(
nth_weekday_of_month(2026, 11, Weekday::Sun, 1),
Some(date("2026-11-01"))
);
}
#[test]
fn test_strike_positive_rejects_zero_and_negative() {
assert_eq!(
strike_positive(Decimal::ZERO),
Err(NormalizeKind::OutOfRange("strike"))
);
assert_eq!(
strike_positive(Decimal::new(-5, 0)),
Err(NormalizeKind::OutOfRange("strike"))
);
}
#[test]
fn test_strike_positive_accepts_real_strike() {
match strike_positive(Decimal::new(50000, 2)) {
Ok(strike) => assert_eq!(strike, pos(500.0)),
Err(e) => panic!("500.00 strike should normalize, got: {e}"),
}
}
#[test]
fn test_multiplier_of_defaults_and_clamps() {
assert_eq!(multiplier_of(100), 100);
assert_eq!(multiplier_of(0), DEFAULT_SHARES_PER_CONTRACT);
assert_eq!(multiplier_of(u64::MAX), DEFAULT_SHARES_PER_CONTRACT);
}
#[test]
fn test_assemble_chain_from_fixture_seeds_strikes() {
let fetch = assembled_spy();
assert_eq!(fetch.chain.symbol, "SPY");
assert_eq!(
fetch.expiry_source.expiration_utc.to_rfc3339(),
"2026-03-20T20:00:00+00:00"
);
assert_eq!(fetch.aliases.len(), 4);
}
#[test]
fn test_assemble_chain_alias_round_trips_occ_and_dxfeed() {
let fetch = assembled_spy();
let Some(key) = fetch.aliases.resolve_symbol(".SPY260320C500") else {
panic!("dxfeed streamer symbol should resolve to a key");
};
assert_eq!(key.strike, pos(500.0));
assert_eq!(key.style, OptionStyle::Call);
assert_eq!(
fetch.aliases.resolve_symbol("SPY 260320C00500000"),
Some(key)
);
match fetch.aliases.instrument(key, &pid("tastytrade")) {
Some(instrument) => {
assert_eq!(instrument.native_symbol, "SPY 260320C00500000");
assert_eq!(instrument.stream_symbol.as_deref(), Some(".SPY260320C500"));
assert_eq!(instrument.spec.contract_multiplier, 100);
assert_eq!(instrument.spec.settlement, SettlementStyle::Physical);
assert_eq!(instrument.spec.exercise, ExerciseStyle::American);
}
None => panic!("the tastytrade alias for the 500 call is missing"),
}
}
#[test]
fn test_assemble_chain_missing_streamer_leaves_leg_without_stream_symbol() {
let nested = nested_spy();
let target = date("2026-03-20");
let Some(expiration) = select_expiration(&nested, target) else {
panic!("fixture expiration missing");
};
let fetch = match assemble_chain(&nested, expiration, &pid("tastytrade"), &|_| None) {
Ok(fetch) => fetch,
Err(e) => panic!("assemble should still succeed, got: {e}"),
};
let Some(key) = fetch.aliases.resolve_symbol("SPY 260320C00500000") else {
panic!("native symbol should still resolve");
};
match fetch.aliases.instrument(key, &pid("tastytrade")) {
Some(instrument) => assert!(instrument.stream_symbol.is_none()),
None => panic!("instrument missing"),
}
}
#[test]
fn test_assemble_chain_empty_expiration_is_no_chain() {
let nested = nested_spy();
let target = date("2026-06-19"); let Some(expiration) = select_expiration(&nested, target) else {
panic!("fixture must contain the empty 2026-06-19 expiration");
};
match assemble_chain(&nested, expiration, &pid("tastytrade"), &|_| None) {
Err(ProviderError::NoChain { underlying, .. }) => assert_eq!(underlying, "SPY"),
other => panic!("expected NoChain, got {other:?}"),
}
}
#[test]
fn test_select_expiration_absent_is_none() {
let nested = nested_spy();
assert!(select_expiration(&nested, date("2030-01-18")).is_none());
}
#[test]
fn test_quote_event_maps_i64_sizes_and_venue_time() {
let instrument = leg_instrument(".SPY260320C500", OptionStyle::Call);
let mut lookup = HashMap::new();
let _ = lookup.insert(".SPY260320C500".to_owned(), instrument);
let (mut sink, _rx_control, mut rx_coalesced) = test_sink(8);
let event = RawDxEvent::Quote {
symbol: ".SPY260320C500".to_owned(),
bid: 1.5,
ask: 1.7,
bid_size: 10,
ask_size: 20,
time_ms: 1_773_000_000_000, };
block(route_event(&event, &lookup, &mut sink));
match rx_coalesced.try_recv() {
Ok(MarketUpdate::Quote(quote)) => {
assert_eq!(quote.bid, Some(pos(1.5)));
assert_eq!(quote.ask, Some(pos(1.7)));
assert_eq!(quote.bid_size, Some(pos(10.0)));
assert_eq!(quote.ask_size, Some(pos(20.0)));
assert!(quote.event_time.is_some());
}
other => panic!("expected a routed Quote, got {other:?}"),
}
}
#[test]
fn test_quote_event_zero_time_is_absent_event_time() {
let instrument = leg_instrument(".SPY260320C500", OptionStyle::Call);
let mut lookup = HashMap::new();
let _ = lookup.insert(".SPY260320C500".to_owned(), instrument);
let (mut sink, _rx_control, mut rx_coalesced) = test_sink(8);
let event = RawDxEvent::Quote {
symbol: ".SPY260320C500".to_owned(),
bid: 1.5,
ask: 1.7,
bid_size: 1,
ask_size: 1,
time_ms: 0, };
block(route_event(&event, &lookup, &mut sink));
match rx_coalesced.try_recv() {
Ok(MarketUpdate::Quote(quote)) => assert!(quote.event_time.is_none()),
other => panic!("expected a routed Quote, got {other:?}"),
}
}
#[test]
fn test_ms_to_event_time_zero_is_none() {
assert!(ms_to_event_time(0).is_none());
assert!(ms_to_event_time(1_773_000_000_000).is_some());
}
#[test]
fn test_streamed_greeks_iv_survives_to_greeks_row() {
let instrument = leg_instrument(".SPY260320C500", OptionStyle::Call);
let mut lookup = HashMap::new();
let _ = lookup.insert(".SPY260320C500".to_owned(), instrument);
let (mut sink, _rx_control, mut rx_coalesced) = test_sink(8);
let event = RawDxEvent::Greeks {
symbol: ".SPY260320C500".to_owned(),
delta: 0.55,
gamma: 0.01,
theta: -0.05,
vega: 0.20,
rho: 0.03,
volatility: 0.35, time_ms: 1_773_000_000_000,
};
block(route_event(&event, &lookup, &mut sink));
match rx_coalesced.try_recv() {
Ok(MarketUpdate::Greeks(greeks)) => {
assert_eq!(
greeks.iv,
Some(pos(0.35)),
"streamed venue IV must survive to the GreeksRow"
);
assert_eq!(greeks.delta, Some(Decimal::new(55, 2)));
assert_eq!(greeks.origin, GreeksOrigin::Provider);
}
other => panic!("expected a routed Greeks, got {other:?}"),
}
}
#[test]
fn test_crossed_quote_is_benign_drop_not_a_panic() {
let instrument = leg_instrument(".SPY260320C500", OptionStyle::Call);
let mut lookup = HashMap::new();
let _ = lookup.insert(".SPY260320C500".to_owned(), instrument);
let (mut sink, _rx_control, mut rx_coalesced) = test_sink(8);
let event = RawDxEvent::Quote {
symbol: ".SPY260320C500".to_owned(),
bid: 2.0,
ask: 1.0,
bid_size: 1,
ask_size: 1,
time_ms: 0,
};
let outcome = block(route_event(&event, &lookup, &mut sink));
assert_eq!(outcome, SendState::Open, "a benign drop is not Closed");
assert!(
rx_coalesced.try_recv().is_err(),
"a crossed quote publishes nothing"
);
}
#[test]
fn test_unknown_symbol_event_is_dropped() {
let lookup: HashMap<String, Instrument> = HashMap::new();
let (mut sink, _rx_control, mut rx_coalesced) = test_sink(8);
let event = RawDxEvent::Quote {
symbol: ".UNKNOWN".to_owned(),
bid: 1.0,
ask: 1.2,
bid_size: 1,
ask_size: 1,
time_ms: 0,
};
let outcome = block(route_event(&event, &lookup, &mut sink));
assert_eq!(outcome, SendState::Open);
assert!(rx_coalesced.try_recv().is_err());
}
#[test]
fn test_ignored_event_publishes_nothing() {
let lookup: HashMap<String, Instrument> = HashMap::new();
let (mut sink, _rx_control, mut rx_coalesced) = test_sink(8);
let outcome = block(route_event(&RawDxEvent::Ignored, &lookup, &mut sink));
assert_eq!(outcome, SendState::Open);
assert!(rx_coalesced.try_recv().is_err());
}
#[test]
fn test_backoff_delay_is_deterministic_with_injected_jitter() {
assert_eq!(backoff_delay(1, 0.0), Duration::from_millis(500));
assert!(backoff_delay(40, 0.2) <= Duration::from_millis(36_000));
assert!(backoff_delay(1, -0.2) >= Duration::from_millis(400));
}
struct MockTransport {
attempts: Vec<Vec<RawDxEvent>>,
attempt_idx: usize,
cursor: usize,
refetch: Option<Vec<Instrument>>,
subscribed: Arc<StdMutex<Vec<Vec<String>>>>,
cancel: CancellationToken,
}
#[async_trait]
impl TastyTransport for MockTransport {
async fn connect_and_subscribe(
&mut self,
symbols: Vec<String>,
) -> Result<(), TransportGone> {
if let Ok(mut log) = self.subscribed.lock() {
log.push(symbols);
}
self.cursor = 0;
Ok(())
}
async fn receive(&mut self) -> Result<RawDxEvent, TransportGone> {
let Some(events) = self.attempts.get(self.attempt_idx) else {
self.cancel.cancel();
return Err(TransportGone);
};
if let Some(event) = events.get(self.cursor) {
self.cursor = self.cursor.saturating_add(1);
return Ok(event.clone());
}
self.attempt_idx = self.attempt_idx.saturating_add(1);
self.cursor = 0;
if self.attempt_idx >= self.attempts.len() {
self.cancel.cancel();
}
Err(TransportGone)
}
async fn refetch(
&mut self,
_underlying: &str,
_expiration: &ExpirationDate,
) -> Option<ChainFetch> {
let instruments = self.refetch.clone()?;
let mut aliases = AliasCatalog::new();
for instrument in instruments {
aliases.insert(instrument);
}
Some(ChainFetch::new(
OptionChain::new(
"SPY",
pos(500.0),
"2026-03-20T20:00:00+00:00".to_owned(),
None,
None,
),
ExpirySource::new("SPY", expiry_utc(), pid("tastytrade")),
aliases,
))
}
}
struct PendingTransport;
#[async_trait]
impl TastyTransport for PendingTransport {
async fn connect_and_subscribe(
&mut self,
_symbols: Vec<String>,
) -> Result<(), TransportGone> {
Ok(())
}
async fn receive(&mut self) -> Result<RawDxEvent, TransportGone> {
std::future::pending::<()>().await;
Err(TransportGone)
}
async fn refetch(
&mut self,
_underlying: &str,
_expiration: &ExpirationDate,
) -> Option<ChainFetch> {
None
}
}
#[track_caller]
fn expiry_utc() -> DateTime<Utc> {
match DateTime::parse_from_rfc3339("2026-03-20T20:00:00+00:00") {
Ok(dt) => dt.with_timezone(&Utc),
Err(e) => panic!("expiry parse: {e}"),
}
}
fn leg_instrument(stream: &str, style: OptionStyle) -> Instrument {
let strike = match style {
OptionStyle::Call => pos(500.0),
OptionStyle::Put => pos(510.0),
};
Instrument {
key: InstrumentKey {
underlying: "SPY".to_owned(),
expiration_utc: expiry_utc(),
strike,
style,
},
provider: pid("tastytrade"),
native_symbol: format!("OCC:{stream}"),
stream_symbol: Some(stream.to_owned()),
spec: tastytrade_fingerprint("SPY", 100),
}
}
fn test_sink(
capacity: usize,
) -> (
MarketUpdateSink,
mpsc::Receiver<MarketUpdate>,
mpsc::Receiver<MarketUpdate>,
) {
let (tx_control, rx_control) = mpsc::channel::<MarketUpdate>(capacity);
let (tx_coalesced, rx_coalesced) = mpsc::channel::<MarketUpdate>(capacity);
(
MarketUpdateSink::new(tx_control, tx_coalesced),
rx_control,
rx_coalesced,
)
}
#[track_caller]
fn block<F: std::future::Future>(future: F) -> F::Output {
match tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
{
Ok(rt) => rt.block_on(future),
Err(e) => panic!("failed to build a test runtime: {e}"),
}
}
fn quote_event(symbol: &str, bid: f64) -> RawDxEvent {
RawDxEvent::Quote {
symbol: symbol.to_owned(),
bid,
ask: bid + 0.2,
bid_size: 5,
ask_size: 5,
time_ms: 0,
}
}
fn drain(rx: &mut mpsc::Receiver<MarketUpdate>) -> Vec<MarketUpdate> {
let mut out = Vec::new();
while let Ok(update) = rx.try_recv() {
out.push(update);
}
out
}
#[tokio::test(start_paused = true)]
async fn test_reconnect_loop_observes_first_and_later_subscriptions() {
let initial = vec![leg_instrument(".SPY260320C500", OptionStyle::Call)];
let fresh = vec![
leg_instrument(".SPY260320C500", OptionStyle::Call),
leg_instrument(".SPY260320P510", OptionStyle::Put),
];
let subscribed = Arc::new(StdMutex::new(Vec::new()));
let transport = MockTransport {
attempts: vec![
vec![quote_event(".SPY260320C500", 1.0)],
vec![quote_event(".SPY260320P510", 2.0)],
],
attempt_idx: 0,
cursor: 0,
refetch: Some(fresh),
subscribed: Arc::clone(&subscribed),
cancel: CancellationToken::new(),
};
let cancel = transport.cancel.clone();
let (sink, mut rx_control, mut rx_coalesced) = test_sink(64);
run_reconnect_loop(
transport,
pid("tastytrade"),
"SPY".to_owned(),
expiry_utc(),
initial,
sink,
cancel,
)
.await;
let coalesced = drain(&mut rx_coalesced);
let bids: Vec<Positive> = coalesced
.iter()
.filter_map(|update| match update {
MarketUpdate::Quote(q) => q.bid,
_ => None,
})
.collect();
assert!(
bids.contains(&pos(1.0)),
"first subscription's quote observed"
);
assert!(
bids.contains(&pos(2.0)),
"later subscription's quote observed"
);
let control = drain(&mut rx_control);
assert!(
control
.iter()
.any(|update| matches!(update, MarketUpdate::Chain(_))),
"the reconnect refetch emitted a Chain backfill"
);
match subscribed.lock() {
Ok(log) => {
assert_eq!(log.len(), 2, "connected twice");
let second = log.get(1).cloned().unwrap_or_default();
assert!(second.contains(&".SPY260320P510".to_owned()));
}
Err(_) => panic!("subscribed log poisoned"),
}
}
proptest! {
#[test]
fn prop_expiry_to_utc_total_and_dst_shape(
year in 2000i32..2100,
month in 1u32..=12,
day in 1u32..=28,
) {
let Some(day) = NaiveDate::from_ymd_opt(year, month, day) else {
return Ok(());
};
match expiry_to_utc(day) {
Ok(utc) => {
let hour = utc.hour();
prop_assert!(
hour == 20 || hour == 21,
"unexpected UTC hour {hour} for {day}"
);
}
Err(kind) => prop_assert_eq!(kind, NormalizeKind::UnparseableExpiry),
}
}
#[test]
fn prop_strike_positive_total(mantissa in -1_000_000i64..1_000_000, scale in 0u32..4) {
match strike_positive(Decimal::new(mantissa, scale)) {
Ok(strike) => prop_assert!(strike > Positive::ZERO),
Err(kind) => prop_assert_eq!(kind, NormalizeKind::OutOfRange("strike")),
}
}
}
#[tokio::test]
async fn test_reconnect_loop_stops_on_cancel() {
let cancel = CancellationToken::new();
let (sink, _rx_control, _rx_coalesced) = test_sink(8);
let loop_cancel = cancel.clone();
let handle = tokio::spawn(run_reconnect_loop(
PendingTransport,
pid("tastytrade"),
"SPY".to_owned(),
expiry_utc(),
vec![leg_instrument(".SPY260320C500", OptionStyle::Call)],
sink,
loop_cancel,
));
tokio::task::yield_now().await;
cancel.cancel();
match handle.await {
Ok(()) => {}
Err(e) => panic!("the loop task should join cleanly on cancel, got: {e}"),
}
}
}