use crate::TastyTrade;
use crate::streaming::reconnect::{BackoffPolicy, ConnectionState};
use crate::types::dxfeed;
use crate::types::dxfeed::{CandlePeriod, EventKind};
use crate::{AsSymbol, TastyResult, TastyTradeError};
use chrono::{DateTime, Utc};
use dxlink::{DXLinkClient, EventType, FeedSubscription, MarketEvent};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::Serialize;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{RwLock, mpsc, oneshot};
use tracing::{debug, error, info, warn};
#[derive(DebugPretty, DisplaySimple, Serialize, PartialEq, Eq, Hash, Clone, Copy)]
pub struct SubscriptionId(usize);
#[derive(Clone)]
struct StreamerHandle {
commands: Option<mpsc::Sender<DXLinkCommand>>,
}
pub struct QuoteSubscription {
pub id: SubscriptionId,
streamer: StreamerHandle,
kinds: BTreeSet<EventKind>,
event_receiver: flume::Receiver<dxfeed::Event>, dxlink_receiver: mpsc::Receiver<MarketEvent>, targets: Arc<Mutex<BTreeSet<FeedTarget>>>,
lagged: Arc<AtomicU64>,
}
impl QuoteSubscription {
pub async fn add_symbols<S: AsSymbol>(&self, symbols: &[S]) -> TastyResult<()> {
let kinds: Vec<EventKind> = self
.kinds
.iter()
.copied()
.filter(|kind| !kind.needs_a_period())
.collect();
if kinds.is_empty() {
return Err(TastyTradeError::Precondition(
"this subscription asked for candles only, and a candle needs a period and a \
start time; use add_candles"
.to_string(),
));
}
let requested: Vec<FeedTarget> = symbols
.iter()
.flat_map(|symbol| {
let symbol = symbol.as_symbol();
kinds.iter().map(move |kind| FeedTarget {
kind: *kind,
symbol: symbol.0.clone(),
from_time: None,
})
})
.collect();
self.subscribe_targets(requested).await
}
pub async fn add_candles<S: AsSymbol>(
&self,
symbols: &[S],
period: CandlePeriod,
from_time: DateTime<Utc>,
) -> TastyResult<()> {
if !self.kinds.contains(&EventKind::Candle) {
return Err(TastyTradeError::Precondition(
"this subscription did not ask for candles, so the channel is not configured to \
deliver them; create one with EventKind::Candle"
.to_string(),
));
}
let from_time = from_time.timestamp_millis();
let requested: Vec<FeedTarget> = symbols
.iter()
.map(|symbol| FeedTarget {
kind: EventKind::Candle,
symbol: period.streamer_symbol(&symbol.as_symbol().0),
from_time: Some(from_time),
})
.collect();
self.subscribe_targets(requested).await
}
pub fn lagged(&self) -> u64 {
self.lagged.load(Ordering::Relaxed)
}
pub fn subscribed(&self) -> Vec<(String, EventKind)> {
targets_of(&self.targets)
.iter()
.map(|target| (target.symbol.clone(), target.kind))
.collect()
}
async fn subscribe_targets(&self, requested: Vec<FeedTarget>) -> TastyResult<()> {
let targets: Vec<FeedTarget> = {
let mut known = targets_of(&self.targets);
requested
.into_iter()
.filter(|target| known.insert(target.clone()))
.collect()
};
let subscriptions = feed_subscriptions(&targets);
if subscriptions.is_empty() {
return Ok(());
}
let sub_id = self.id.0 as u32;
let Some(tx) = &self.streamer.commands else {
let mut known = targets_of(&self.targets);
for target in &targets {
known.remove(target);
}
return Err(TastyTradeError::Streaming(
"the quote streamer has no command channel; reconnect before subscribing"
.to_string(),
));
};
let (ack, answered) = oneshot::channel();
let queued = tx
.send(DXLinkCommand::Subscribe(
subscriptions,
targets.iter().map(|target| target.kind).collect(),
sub_id,
Some(ack),
))
.await
.map_err(|_| {
TastyTradeError::Streaming(
"the quote streamer is closed; reconnect before subscribing".to_string(),
)
});
let outcome = match queued {
Ok(()) => answered.await.unwrap_or_else(|_| {
Err(TastyTradeError::Streaming(
"the quote streamer closed before the subscription was confirmed".to_string(),
))
}),
Err(e) => Err(e),
};
if outcome.is_err() {
let mut known = targets_of(&self.targets);
for target in &targets {
known.remove(target);
}
}
outcome
}
pub async fn get_event(&mut self) -> Result<dxfeed::Event, flume::RecvError> {
loop {
let Some(market_event) = self.dxlink_receiver.recv().await else {
return self.event_receiver.recv_async().await;
};
let converted = convert_event(market_event);
if let Some(event) = converted {
return Ok(event);
}
}
}
}
fn convert_event(event: MarketEvent) -> Option<dxfeed::Event> {
let data = match event {
MarketEvent::Quote(quote) => {
return Some(dxfeed::Event {
sym: quote.event_symbol,
data: dxfeed::EventData::Quote(dxfeed::DxfQuoteT {
time: 0,
sequence: 0,
time_nanos: 0,
bid_time: 0,
bid_exchange_code: 0,
bid_price: quote.bid_price,
ask_price: quote.ask_price,
bid_size: quote.bid_size as i64,
ask_time: 0,
ask_size: quote.ask_size as i64,
ask_exchange_code: 0,
scope: 0,
}),
});
}
MarketEvent::Trade(trade) => {
return Some(dxfeed::Event {
sym: trade.event_symbol,
data: dxfeed::EventData::Trade(dxfeed::DxfTradeT {
time: 0,
sequence: 0,
time_nanos: 0,
exchange_code: 0,
price: trade.price,
size: trade.size as i64,
tick: 0,
change: 0.0,
day_id: 0,
day_volume: trade.day_volume,
day_turnover: 0.0,
raw_flags: 0,
direction: 0,
is_eth: 0,
scope: 0,
}),
});
}
MarketEvent::Greeks(greeks) => {
return Some(dxfeed::Event {
sym: greeks.event_symbol,
data: dxfeed::EventData::Greeks(dxfeed::DxfGreeksT {
event_flags: 0,
index: 0,
time: 0,
price: 0.0,
volatility: greeks.volatility,
delta: greeks.delta,
gamma: greeks.gamma,
theta: greeks.theta,
vega: greeks.vega,
rho: greeks.rho,
}),
});
}
MarketEvent::TradeETH(trade) => (
trade.event_symbol.clone(),
dxfeed::EventData::TradeEth(Box::new(dxfeed::DxfTradeEthT {
event_time: trade.event_time,
time: trade.time,
time_nano_part: trade.time_nano_part,
sequence: trade.sequence,
exchange_code: trade.exchange_code,
price: trade.price,
change: trade.change,
size: trade.size,
day_id: trade.day_id,
day_volume: trade.day_volume,
day_turnover: trade.day_turnover,
tick_direction: trade.tick_direction,
extended_trading_hours: trade.extended_trading_hours,
})),
),
MarketEvent::Candle(candle) => (
candle.event_symbol.clone(),
dxfeed::EventData::Candle(Box::new(dxfeed::DxfCandleT {
event_time: candle.event_time,
event_flags: candle.event_flags,
index: candle.index,
time: candle.time,
sequence: candle.sequence,
count: candle.count,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume,
vwap: candle.vwap,
bid_volume: candle.bid_volume,
ask_volume: candle.ask_volume,
imp_volatility: candle.imp_volatility,
open_interest: candle.open_interest,
})),
),
MarketEvent::Summary(summary) => (
summary.event_symbol.clone(),
dxfeed::EventData::Summary(Box::new(dxfeed::DxfSummaryT {
event_time: summary.event_time,
day_id: summary.day_id,
day_open_price: summary.day_open_price,
day_high_price: summary.day_high_price,
day_low_price: summary.day_low_price,
day_close_price: summary.day_close_price,
day_close_price_type: summary.day_close_price_type,
prev_day_id: summary.prev_day_id,
prev_day_close_price: summary.prev_day_close_price,
prev_day_close_price_type: summary.prev_day_close_price_type,
prev_day_volume: summary.prev_day_volume,
open_interest: summary.open_interest,
})),
),
MarketEvent::TimeAndSale(sale) => (
sale.event_symbol.clone(),
dxfeed::EventData::TimeAndSale(Box::new(dxfeed::DxfTimeAndSaleT {
event_time: sale.event_time,
event_flags: sale.event_flags,
index: sale.index,
time: sale.time,
time_nano_part: sale.time_nano_part,
sequence: sale.sequence,
exchange_code: sale.exchange_code,
price: sale.price,
size: sale.size,
bid_price: sale.bid_price,
ask_price: sale.ask_price,
exchange_sale_conditions: sale.exchange_sale_conditions,
trade_through_exempt: sale.trade_through_exempt,
aggressor_side: sale.aggressor_side,
spread_leg: sale.spread_leg,
extended_trading_hours: sale.extended_trading_hours,
valid_tick: sale.valid_tick,
sale_type: sale.sale_type,
buyer: sale.buyer,
seller: sale.seller,
})),
),
MarketEvent::Profile(profile) => (
profile.event_symbol.clone(),
dxfeed::EventData::Profile(Box::new(dxfeed::DxfProfileT {
event_time: profile.event_time,
description: profile.description,
short_sale_restriction: profile.short_sale_restriction,
trading_status: profile.trading_status,
status_reason: profile.status_reason,
halt_start_time: profile.halt_start_time,
halt_end_time: profile.halt_end_time,
high_limit_price: profile.high_limit_price,
low_limit_price: profile.low_limit_price,
high_52_week_price: profile.high_52_week_price,
low_52_week_price: profile.low_52_week_price,
beta: profile.beta,
earnings_per_share: profile.earnings_per_share,
dividend_frequency: profile.dividend_frequency,
ex_dividend_amount: profile.ex_dividend_amount,
ex_dividend_day_id: profile.ex_dividend_day_id,
shares: profile.shares,
free_float: profile.free_float,
})),
),
MarketEvent::Underlying(underlying) => (
underlying.event_symbol.clone(),
dxfeed::EventData::Underlying(Box::new(dxfeed::DxfUnderlyingT {
event_time: underlying.event_time,
event_flags: underlying.event_flags,
index: underlying.index,
time: underlying.time,
sequence: underlying.sequence,
volatility: underlying.volatility,
front_volatility: underlying.front_volatility,
back_volatility: underlying.back_volatility,
call_volume: underlying.call_volume,
put_volume: underlying.put_volume,
put_call_ratio: underlying.put_call_ratio,
})),
),
MarketEvent::TheoPrice(theo) => (
theo.event_symbol.clone(),
dxfeed::EventData::TheoPrice(Box::new(dxfeed::DxfTheoPriceT {
event_time: theo.event_time,
event_flags: theo.event_flags,
index: theo.index,
time: theo.time,
sequence: theo.sequence,
price: theo.price,
underlying_price: theo.underlying_price,
delta: theo.delta,
gamma: theo.gamma,
dividend: theo.dividend,
interest: theo.interest,
})),
),
MarketEvent::Series(series) => (
series.event_symbol.clone(),
dxfeed::EventData::Series(Box::new(dxfeed::DxfSeriesT {
event_time: series.event_time,
event_flags: series.event_flags,
index: series.index,
time: series.time,
sequence: series.sequence,
expiration: series.expiration,
volatility: series.volatility,
call_volume: series.call_volume,
put_volume: series.put_volume,
put_call_ratio: series.put_call_ratio,
forward_price: series.forward_price,
dividend: series.dividend,
interest: series.interest,
})),
),
};
Some(dxfeed::Event {
sym: data.0,
data: data.1,
})
}
fn answer(ack: Option<oneshot::Sender<TastyResult<()>>>, outcome: TastyResult<()>) {
if let Some(ack) = ack {
let _ = ack.send(outcome);
}
}
pub const DEFAULT_EVENT_CHANNEL_CAPACITY: usize = 4096;
#[derive(Clone)]
struct Subscriber {
events: mpsc::Sender<MarketEvent>,
lagged: Arc<AtomicU64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CandleResume {
through: Option<i64>,
gap: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct FeedTarget {
kind: EventKind,
symbol: String,
from_time: Option<i64>,
}
fn feed_subscriptions(targets: &[FeedTarget]) -> Vec<FeedSubscription> {
targets
.iter()
.map(|target| FeedSubscription {
event_type: target.kind.wire_name().to_string(),
symbol: target.symbol.clone(),
from_time: target.from_time,
source: None,
})
.collect()
}
fn targets_of(
set: &Mutex<BTreeSet<FeedTarget>>,
) -> std::sync::MutexGuard<'_, BTreeSet<FeedTarget>> {
set.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
enum DXLinkCommand {
Subscribe(
Vec<FeedSubscription>,
BTreeSet<EventKind>,
u32,
Option<oneshot::Sender<TastyResult<()>>>,
),
Unsubscribe(
Vec<FeedSubscription>,
u32,
Option<oneshot::Sender<TastyResult<()>>>,
),
AddEventSender(u32, Subscriber),
RemoveEventSender(u32),
}
#[derive(Default)]
struct EventRouting {
senders: HashMap<u32, Vec<Subscriber>>,
routes: HashMap<(String, EventKind), HashSet<u32>>,
}
type CandleProgress = Arc<Mutex<HashMap<(u32, String), CandleResume>>>;
pub struct QuoteStreamer {
shutdown: Option<oneshot::Sender<()>>,
next_sub_id: usize,
subscription_map: HashMap<SubscriptionId, QuoteSubscription>,
dxlink_command_tx: Option<mpsc::Sender<DXLinkCommand>>,
registry: Registry,
state: Arc<RwLock<ConnectionState>>,
}
#[derive(Clone)]
struct SubscriptionRecord {
kinds: BTreeSet<EventKind>,
targets: Arc<Mutex<BTreeSet<FeedTarget>>>,
}
type Registry = Arc<Mutex<HashMap<u32, SubscriptionRecord>>>;
impl QuoteStreamer {
pub async fn connect(tasty: &TastyTrade) -> TastyResult<Self> {
Self::connect_with_policy(tasty, BackoffPolicy::default()).await
}
pub async fn connect_with_policy(
tasty: &TastyTrade,
policy: BackoffPolicy,
) -> TastyResult<Self> {
let connection = connect_dxlink(tasty).await?;
let (command_tx, command_rx) = mpsc::channel::<DXLinkCommand>(100);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let registry: Registry = Arc::new(Mutex::new(HashMap::new()));
let state = Arc::new(RwLock::new(ConnectionState::Connected));
let progress: CandleProgress = Arc::new(Mutex::new(HashMap::new()));
tokio::spawn(supervise(
tasty.clone(),
policy,
connection,
command_rx,
shutdown_rx,
routing,
registry.clone(),
state.clone(),
progress,
));
Ok(Self {
shutdown: Some(shutdown_tx),
next_sub_id: 0,
subscription_map: HashMap::new(),
dxlink_command_tx: Some(command_tx),
registry,
state,
})
}
pub async fn state(&self) -> ConnectionState {
self.state.read().await.clone()
}
fn handle(&self) -> StreamerHandle {
StreamerHandle {
commands: self.dxlink_command_tx.clone(),
}
}
pub async fn create_sub(
&mut self,
kinds: impl IntoIterator<Item = EventKind>,
) -> TastyResult<Box<QuoteSubscription>> {
self.create_sub_with_capacity(kinds, DEFAULT_EVENT_CHANNEL_CAPACITY)
.await
}
pub async fn create_sub_with_capacity(
&mut self,
kinds: impl IntoIterator<Item = EventKind>,
capacity: usize,
) -> TastyResult<Box<QuoteSubscription>> {
if capacity == 0 {
return Err(TastyTradeError::Precondition(
"a subscription with no buffer cannot deliver anything; it would drop every \
event and report itself as lagging"
.to_string(),
));
}
let kinds: BTreeSet<EventKind> = kinds.into_iter().collect();
let id = SubscriptionId(self.next_sub_id);
self.next_sub_id += 1;
let sub_id = id.0 as u32;
let (caller_tx, caller_rx) = mpsc::channel(capacity);
let lagged = Arc::new(AtomicU64::new(0));
let (_event_sender, event_receiver) = flume::unbounded();
let Some(commands) = &self.dxlink_command_tx else {
return Err(TastyTradeError::Streaming(
"the quote streamer has no command channel; reconnect before subscribing"
.to_string(),
));
};
commands
.send(DXLinkCommand::AddEventSender(
sub_id,
Subscriber {
events: caller_tx,
lagged: lagged.clone(),
},
))
.await
.map_err(|_| {
TastyTradeError::Streaming(
"the quote streamer is closed; it cannot route events to a new subscription"
.to_string(),
)
})?;
let targets = Arc::new(Mutex::new(BTreeSet::new()));
self.registry
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(
sub_id,
SubscriptionRecord {
kinds: kinds.clone(),
targets: targets.clone(),
},
);
let (_closed, closed_rx) = mpsc::channel(1);
self.subscription_map.insert(
id,
QuoteSubscription {
id,
streamer: self.handle(),
kinds: kinds.clone(),
event_receiver: event_receiver.clone(),
dxlink_receiver: closed_rx,
targets: targets.clone(),
lagged: lagged.clone(),
},
);
Ok(Box::new(QuoteSubscription {
id,
streamer: self.handle(),
kinds,
event_receiver,
dxlink_receiver: caller_rx,
targets,
lagged,
}))
}
pub fn get_sub(&self, id: SubscriptionId) -> Option<&QuoteSubscription> {
self.subscription_map.get(&id)
}
pub async fn close_sub(&mut self, id: SubscriptionId) -> TastyResult<()> {
if let Some(subscription) = self.subscription_map.get(&id) {
let targets: Vec<FeedTarget> =
targets_of(&subscription.targets).iter().cloned().collect();
let unsubscribe_requests = feed_subscriptions(&targets);
if let Some(tx) = &self.dxlink_command_tx {
let sub_id = id.0 as u32;
let closed = |_| {
TastyTradeError::Streaming(
"the quote streamer is closed; the subscription is gone with it"
.to_string(),
)
};
if !unsubscribe_requests.is_empty() {
let (ack, answered) = oneshot::channel();
tx.send(DXLinkCommand::Unsubscribe(
unsubscribe_requests,
sub_id,
Some(ack),
))
.await
.map_err(closed)?;
answered.await.unwrap_or_else(|_| {
Err(TastyTradeError::Streaming(
"the quote streamer closed before the unsubscribe was confirmed"
.to_string(),
))
})?;
}
tx.send(DXLinkCommand::RemoveEventSender(sub_id))
.await
.map_err(closed)?;
}
if let Some(subscription) = self.subscription_map.get(&id) {
targets_of(&subscription.targets).clear();
}
}
self.subscription_map.remove(&id);
self.registry
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&(id.0 as u32));
Ok(())
}
}
struct LiveConnection {
client: DXLinkClient,
channel_id: u32,
events: mpsc::Receiver<MarketEvent>,
}
async fn connect_dxlink(tasty: &TastyTrade) -> TastyResult<LiveConnection> {
let tokens = tasty.quote_streamer_tokens().await?;
debug!(
"Obtained DXLink streamer token ({} bytes)",
tokens.token.len()
);
let mut client = DXLinkClient::new(&tokens.streamer_url, &tokens.token);
info!("Connecting to DXLink server: {}", tokens.streamer_url);
let events = client.connect().await.map_err(|e| {
let error: TastyTradeError = e.into();
error
})?;
let channel_id = client
.create_feed_channel("AUTO")
.await
.map_err(TastyTradeError::from)?;
info!("DXLink channel created: {}", channel_id);
Ok(LiveConnection {
client,
channel_id,
events,
})
}
async fn ensure_configured(
client: &mut DXLinkClient,
channel_id: u32,
configured: &mut BTreeSet<EventKind>,
wanted: &BTreeSet<EventKind>,
) -> TastyResult<()> {
if wanted.is_subset(configured) {
return Ok(());
}
let union: BTreeSet<EventKind> = configured.union(wanted).copied().collect();
let types: Vec<EventType> = union.iter().copied().map(feed_event_type).collect();
debug!(
"Configuring feed channel {channel_id} for {} event type(s)",
types.len()
);
match client.setup_feed(channel_id, &types).await {
Ok(()) => {
*configured = union;
Ok(())
}
Err(e) => {
let lost = is_connection_lost(&e);
let message = format!("the venue refused the feed configuration: {e}");
Err(if lost {
TastyTradeError::Connection(message)
} else {
TastyTradeError::Streaming(message)
})
}
}
}
enum Ended {
Owner,
ConnectionLost,
}
async fn forward_events(
mut events: mpsc::Receiver<MarketEvent>,
routing: Arc<RwLock<EventRouting>>,
progress: CandleProgress,
saw_event: Arc<AtomicBool>,
) {
while let Some(event) = events.recv().await {
saw_event.store(true, Ordering::Relaxed);
let Some(symbol) = event_symbol(&event) else {
continue;
};
let symbol = symbol.to_string();
let kind = event_kind(&event);
let routing = routing.read().await;
let Some(sub_ids) = routing.routes.get(&(symbol.clone(), kind)) else {
debug!("No subscription registered for {kind} on {symbol}");
continue;
};
for sub_id in sub_ids {
let Some(subscribers) = routing.senders.get(sub_id) else {
continue;
};
let mut delivered = false;
let mut dropped = 0usize;
for subscriber in subscribers {
match subscriber.events.try_send(event.clone()) {
Ok(()) => delivered = true,
Err(_) => {
subscriber.lagged.fetch_add(1, Ordering::Relaxed);
dropped += 1;
}
}
}
if dropped > 0 {
warn!(
"A consumer fell behind: dropped {kind} for {symbol} on {dropped} \
channel(s) of subscription {sub_id}"
);
}
if let MarketEvent::Candle(candle) = &event {
record_bar(
&progress,
*sub_id,
&symbol,
candle.time,
delivered && dropped == 0,
);
}
}
}
}
fn record_bar(progress: &CandleProgress, sub_id: u32, symbol: &str, time: i64, complete: bool) {
let mut seen = progress.lock().unwrap_or_else(|p| p.into_inner());
let resume = seen
.entry((sub_id, symbol.to_string()))
.or_insert(CandleResume {
through: None,
gap: false,
});
if !complete {
resume.gap = true;
return;
}
if !resume.gap {
resume.through = Some(match resume.through {
Some(through) => through.max(time),
None => time,
});
}
}
async fn replay(
client: &mut DXLinkClient,
channel_id: u32,
registry: &Registry,
progress: &CandleProgress,
configured: &mut BTreeSet<EventKind>,
) -> bool {
let pending = pending_replay(registry, progress);
if pending.is_empty() {
return true;
}
let wanted: BTreeSet<EventKind> = registry
.lock()
.unwrap_or_else(|p| p.into_inner())
.values()
.flat_map(|record| record.kinds.iter().copied())
.collect();
if let Err(e) = ensure_configured(client, channel_id, configured, &wanted).await {
warn!("Could not configure the feed channel after reconnecting: {e}");
return false;
}
debug!(
"Restoring {} subscription(s) after a reconnect",
pending.len()
);
{
let mut seen = progress.lock().unwrap_or_else(|p| p.into_inner());
for resume in seen.values_mut() {
resume.gap = false;
}
}
for (sub_id, requests) in pending {
if let Err(e) = client.subscribe(channel_id, requests).await {
warn!("Could not restore subscription {sub_id}: {e}");
return false;
}
}
true
}
fn pending_replay(
registry: &Registry,
progress: &CandleProgress,
) -> Vec<(u32, Vec<FeedSubscription>)> {
let seen = progress.lock().unwrap_or_else(|p| p.into_inner()).clone();
let registry = registry.lock().unwrap_or_else(|p| p.into_inner());
registry
.iter()
.map(|(sub_id, record)| {
let targets: Vec<FeedTarget> = targets_of(&record.targets)
.iter()
.cloned()
.map(|target| resume_from(*sub_id, target, &seen))
.collect();
(*sub_id, feed_subscriptions(&targets))
})
.filter(|(_, requests)| !requests.is_empty())
.collect()
}
fn resume_from(
sub_id: u32,
mut target: FeedTarget,
seen: &HashMap<(u32, String), CandleResume>,
) -> FeedTarget {
if target.kind != EventKind::Candle {
return target;
}
if let Some(resume) = seen.get(&(sub_id, target.symbol.clone()))
&& let Some(through) = resume.through
{
target.from_time = Some(match target.from_time {
Some(original) => original.max(through.saturating_add(1)),
None => through.saturating_add(1),
});
}
target
}
fn event_symbol(event: &MarketEvent) -> Option<&str> {
Some(match event {
MarketEvent::Quote(quote) => "e.event_symbol,
MarketEvent::Trade(trade) => &trade.event_symbol,
MarketEvent::TradeETH(trade) => &trade.event_symbol,
MarketEvent::Greeks(greeks) => &greeks.event_symbol,
MarketEvent::Candle(candle) => &candle.event_symbol,
MarketEvent::Summary(summary) => &summary.event_symbol,
MarketEvent::TimeAndSale(sale) => &sale.event_symbol,
MarketEvent::Profile(profile) => &profile.event_symbol,
MarketEvent::Underlying(underlying) => &underlying.event_symbol,
MarketEvent::TheoPrice(theo) => &theo.event_symbol,
MarketEvent::Series(series) => &series.event_symbol,
})
}
fn event_kind(event: &MarketEvent) -> EventKind {
match event {
MarketEvent::Quote(_) => EventKind::Quote,
MarketEvent::Trade(_) => EventKind::Trade,
MarketEvent::TradeETH(_) => EventKind::TradeEth,
MarketEvent::Greeks(_) => EventKind::Greeks,
MarketEvent::Candle(_) => EventKind::Candle,
MarketEvent::Summary(_) => EventKind::Summary,
MarketEvent::TimeAndSale(_) => EventKind::TimeAndSale,
MarketEvent::Profile(_) => EventKind::Profile,
MarketEvent::Underlying(_) => EventKind::Underlying,
MarketEvent::TheoPrice(_) => EventKind::TheoPrice,
MarketEvent::Series(_) => EventKind::Series,
}
}
fn feed_event_type(kind: EventKind) -> EventType {
match kind {
EventKind::Quote => EventType::Quote,
EventKind::Trade => EventType::Trade,
EventKind::TradeEth => EventType::TradeETH,
EventKind::Greeks => EventType::Greeks,
EventKind::Candle => EventType::Candle,
EventKind::Summary => EventType::Summary,
EventKind::TimeAndSale => EventType::TimeAndSale,
EventKind::Profile => EventType::Profile,
EventKind::Underlying => EventType::Underlying,
EventKind::TheoPrice => EventType::TheoPrice,
EventKind::Series => EventType::Series,
}
}
async fn record_routes(
routing: &Arc<RwLock<EventRouting>>,
sub_id: u32,
subscriptions: &[FeedSubscription],
) {
let mut routing = routing.write().await;
for route in routes_of(subscriptions) {
routing.routes.entry(route).or_default().insert(sub_id);
}
}
async fn forget_routes(
routing: &Arc<RwLock<EventRouting>>,
sub_id: u32,
subscriptions: &[FeedSubscription],
) {
let mut routing = routing.write().await;
for route in routes_of(subscriptions) {
if let Some(subs) = routing.routes.get_mut(&route) {
subs.remove(&sub_id);
if subs.is_empty() {
routing.routes.remove(&route);
}
}
}
}
fn routes_of(subscriptions: &[FeedSubscription]) -> Vec<(String, EventKind)> {
subscriptions
.iter()
.filter_map(|sub| {
EventKind::ALL
.iter()
.find(|kind| kind.wire_name() == sub.event_type)
.map(|kind| (sub.symbol.clone(), *kind))
})
.collect()
}
#[allow(clippy::too_many_arguments)]
async fn run_connection(
client: &mut DXLinkClient,
channel_id: u32,
commands: &mut mpsc::Receiver<DXLinkCommand>,
shutdown: &mut oneshot::Receiver<()>,
forwarder: &mut tokio::task::JoinHandle<()>,
routing: &Arc<RwLock<EventRouting>>,
configured: &mut BTreeSet<EventKind>,
) -> Ended {
loop {
let cmd = tokio::select! {
biased;
_ = &mut *shutdown => {
debug!("Quote streamer owner dropped, disconnecting");
return Ended::Owner;
}
joined = &mut *forwarder => {
match joined {
Ok(()) => debug!("The DXLink event stream closed; the session is over"),
Err(e) => warn!(
"The event forwarding task ended abnormally (panicked: {}, cancelled: {})",
e.is_panic(),
e.is_cancelled()
),
}
return Ended::ConnectionLost;
}
cmd = commands.recv() => match cmd {
Some(cmd) => cmd,
None => return Ended::Owner,
},
};
match cmd {
DXLinkCommand::Subscribe(subscriptions, kinds, sub_id, ack) => {
if let Err(e) = ensure_configured(client, channel_id, configured, &kinds).await {
let lost = matches!(&e, TastyTradeError::Connection(_));
answer(ack, Err(e));
if lost {
return Ended::ConnectionLost;
}
continue;
}
record_routes(routing, sub_id, &subscriptions).await;
match client.subscribe(channel_id, subscriptions.clone()).await {
Ok(()) => answer(ack, Ok(())),
Err(e) => {
let lost = is_connection_lost(&e);
error!("Error subscribing to symbols: {}", e);
forget_routes(routing, sub_id, &subscriptions).await;
answer(
ack,
Err(TastyTradeError::Streaming(format!(
"the venue refused the subscription: {e}"
))),
);
if lost {
return Ended::ConnectionLost;
}
}
}
}
DXLinkCommand::Unsubscribe(subscriptions, sub_id, ack) => {
let outcome = client.unsubscribe(channel_id, subscriptions.clone()).await;
if outcome.is_ok() {
forget_routes(routing, sub_id, &subscriptions).await;
}
match outcome {
Ok(()) => answer(ack, Ok(())),
Err(e) => {
let lost = is_connection_lost(&e);
error!("Error unsubscribing from symbols: {}", e);
answer(
ack,
Err(TastyTradeError::Streaming(format!(
"the venue refused the unsubscribe: {e}"
))),
);
if lost {
return Ended::ConnectionLost;
}
}
}
}
DXLinkCommand::AddEventSender(subscription_id, sender) => {
let mut routing = routing.write().await;
routing
.senders
.entry(subscription_id)
.or_default()
.push(sender);
debug!("Added event sender for subscription {}", subscription_id);
}
DXLinkCommand::RemoveEventSender(subscription_id) => {
let mut routing = routing.write().await;
routing.senders.remove(&subscription_id);
routing.routes.retain(|_, subs| {
subs.remove(&subscription_id);
!subs.is_empty()
});
debug!("Removed event senders for subscription {}", subscription_id);
}
}
}
}
fn is_connection_lost(error: &dxlink::DXLinkError) -> bool {
matches!(
error,
dxlink::DXLinkError::Connection(_) | dxlink::DXLinkError::WebSocket(_)
)
}
async fn terminal(state: &Arc<RwLock<ConnectionState>>, reason: String) {
warn!("Quote stream gave up: {reason}");
*state.write().await = ConnectionState::Disconnected { reason };
}
async fn schedule(
policy: &BackoffPolicy,
attempt: &mut u32,
state: &Arc<RwLock<ConnectionState>>,
shutdown: &mut oneshot::Receiver<()>,
) -> bool {
*attempt = attempt.saturating_add(1);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let Some(delay) = policy.delay_for(*attempt, nanos) else {
terminal(state, format!("gave up after {} attempts", *attempt - 1)).await;
return false;
};
debug!("Quote stream reconnecting, attempt {attempt} in {delay:?}");
*state.write().await = ConnectionState::Reconnecting {
attempt: *attempt,
delay,
};
tokio::select! {
_ = &mut *shutdown => false,
_ = tokio::time::sleep(delay) => true,
}
}
#[allow(clippy::too_many_arguments)]
async fn supervise(
tasty: TastyTrade,
policy: BackoffPolicy,
first: LiveConnection,
mut commands: mpsc::Receiver<DXLinkCommand>,
mut shutdown: oneshot::Receiver<()>,
routing: Arc<RwLock<EventRouting>>,
registry: Registry,
state: Arc<RwLock<ConnectionState>>,
progress: CandleProgress,
) {
let mut attempt = 0u32;
let mut next = Some(first);
loop {
let LiveConnection {
mut client,
channel_id,
events,
} = match next.take() {
Some(connection) => connection,
None => match connect_dxlink(&tasty).await {
Ok(connection) => connection,
Err(e) => {
if !policy.should_retry(&e) {
terminal(&state, format!("reconnect refused: {e}")).await;
return;
}
if !schedule(&policy, &mut attempt, &state, &mut shutdown).await {
return;
}
continue;
}
},
};
let saw_event = Arc::new(AtomicBool::new(false));
let mut forwarder = tokio::spawn(forward_events(
events,
routing.clone(),
progress.clone(),
saw_event.clone(),
));
let mut configured: BTreeSet<EventKind> = BTreeSet::new();
let restored = replay(
&mut client,
channel_id,
®istry,
&progress,
&mut configured,
)
.await;
if restored {
*state.write().await = ConnectionState::Connected;
}
let ended = if restored {
run_connection(
&mut client,
channel_id,
&mut commands,
&mut shutdown,
&mut forwarder,
&routing,
&mut configured,
)
.await
} else {
warn!("Could not restore every subscription; reconnecting");
Ended::ConnectionLost
};
forwarder.abort();
if let Some(reason) = client.disconnect_reason() {
debug!("DXLink reported the session ended: {reason}");
}
if let Err(e) = client.disconnect().await {
debug!("Error disconnecting the previous DXLink client: {e}");
}
match ended {
Ended::Owner => {
*state.write().await = ConnectionState::Disconnected {
reason: "the streamer was dropped".to_string(),
};
debug!("DXLink supervisor terminated");
return;
}
Ended::ConnectionLost => {
if saw_event.load(Ordering::Relaxed) {
attempt = 0;
}
if !schedule(&policy, &mut attempt, &state, &mut shutdown).await {
return;
}
}
}
}
}
impl Drop for QuoteStreamer {
fn drop(&mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
}
}
#[cfg(test)]
mod lifecycle_tests {
use super::*;
use crate::Symbol;
#[tokio::test]
async fn both_copies_of_a_subscription_see_the_same_symbols() {
let (tx, rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let loop_handle = spawn_command_loop(rx, || Ok(()));
let sub = streamer
.create_sub([EventKind::Quote])
.await
.expect("the streamer is open");
sub.add_symbols(&[Symbol::from("AAPL"), Symbol::from("MSFT")])
.await
.expect("subscribing succeeds");
{
let stored = targets_of(
&streamer
.subscription_map
.get(&sub.id)
.expect("the streamer kept a copy")
.targets,
);
assert_eq!(stored.len(), 2, "the streamer's copy must see the symbols");
assert!(stored.iter().any(|target| target.symbol == "AAPL"));
}
drop(sub);
drop(streamer);
let sent = loop_handle.await.expect("the stand-in loop finishes");
assert!(sent.contains(&"AAPL".to_string()));
assert!(sent.contains(&"MSFT".to_string()));
}
#[tokio::test]
async fn a_refused_subscription_records_nothing() {
let (tx, rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let _loop_handle = spawn_command_loop(rx, || {
Err(TastyTradeError::Streaming("venue said no".to_string()))
});
let sub = streamer
.create_sub([EventKind::Quote])
.await
.expect("the streamer is open");
let error = sub
.add_symbols(&[Symbol::from("AAPL")])
.await
.expect_err("a refused subscription is not a success");
assert!(format!("{error}").contains("venue said no"), "{error}");
assert!(
targets_of(&sub.targets).is_empty(),
"a refused symbol must not stay reserved"
);
}
#[tokio::test]
async fn a_repeated_symbol_is_not_subscribed_twice() {
let (tx, rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let loop_handle = spawn_command_loop(rx, || Ok(()));
let sub = streamer
.create_sub([EventKind::Quote])
.await
.expect("the streamer is open");
sub.add_symbols(&[Symbol::from("AAPL")]).await.unwrap();
sub.add_symbols(&[Symbol::from("AAPL")]).await.unwrap();
drop(sub);
drop(streamer);
let sent = loop_handle.await.expect("the stand-in loop finishes");
assert_eq!(
sent.len(),
1,
"the second request had nothing new to say: {sent:?}"
);
}
#[tokio::test]
async fn a_failed_subscribe_records_nothing() {
let (tx, rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let sub = streamer
.create_sub([EventKind::Quote])
.await
.expect("the streamer is open");
drop(rx);
sub.add_symbols(&[Symbol::from("AAPL")])
.await
.expect_err("a closed streamer cannot subscribe");
assert!(
targets_of(&sub.targets).is_empty(),
"nothing was subscribed, so nothing may be recorded"
);
}
#[tokio::test]
async fn dropping_a_handle_does_not_disconnect_anyone() {
let (tx, mut rx) = mpsc::channel::<DXLinkCommand>(8);
let handle = StreamerHandle {
commands: Some(tx.clone()),
};
let second = handle.clone();
drop(handle);
drop(second);
assert!(
rx.try_recv().is_err(),
"dropping a handle must not send a command"
);
tx.send(DXLinkCommand::RemoveEventSender(9))
.await
.expect("the owner's channel is still alive after handles are dropped");
assert!(matches!(
rx.recv().await,
Some(DXLinkCommand::RemoveEventSender(9))
));
}
pub(super) fn spawn_command_loop(
mut rx: mpsc::Receiver<DXLinkCommand>,
outcome: fn() -> TastyResult<()>,
) -> tokio::task::JoinHandle<Vec<String>> {
tokio::spawn(async move {
let mut seen = Vec::new();
while let Some(cmd) = rx.recv().await {
match cmd {
DXLinkCommand::Subscribe(requests, _, _, ack) => {
seen.extend(requests.into_iter().map(|r| r.symbol));
answer(ack, outcome());
}
DXLinkCommand::Unsubscribe(_, _, ack) => answer(ack, outcome()),
_ => {}
}
}
seen
})
}
pub(super) fn streamer_with(
commands: mpsc::Sender<DXLinkCommand>,
shutdown: oneshot::Sender<()>,
) -> QuoteStreamer {
QuoteStreamer {
shutdown: Some(shutdown),
next_sub_id: 0,
subscription_map: HashMap::new(),
dxlink_command_tx: Some(commands),
registry: Arc::new(Mutex::new(HashMap::new())),
state: Arc::new(RwLock::new(ConnectionState::Connected)),
}
}
#[test]
fn dropping_the_owner_outside_a_runtime_does_not_panic() {
let (tx, _rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
drop(streamer_with(tx, shutdown_tx));
assert!(
shutdown_rx.try_recv().is_ok(),
"the owner must signal shutdown on drop"
);
}
#[test]
fn shutdown_survives_a_full_command_queue() {
let (tx, _rx) = mpsc::channel::<DXLinkCommand>(1);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
tx.try_send(DXLinkCommand::RemoveEventSender(9))
.expect("the first command fits");
assert!(
tx.try_send(DXLinkCommand::RemoveEventSender(9)).is_err(),
"the queue must actually be full for this test to mean anything"
);
drop(streamer_with(tx, shutdown_tx));
assert!(
shutdown_rx.try_recv().is_ok(),
"a full command queue must not be able to swallow the shutdown"
);
}
}
#[cfg(test)]
mod reconnect_tests {
use super::lifecycle_tests::{spawn_command_loop, streamer_with};
use super::*;
use crate::Symbol;
use dxlink::events::QuoteEvent;
use std::time::Duration;
fn quote(symbol: &str) -> MarketEvent {
MarketEvent::Quote(QuoteEvent {
event_type: "Quote".to_string(),
event_symbol: symbol.to_string(),
bid_price: 1.0,
ask_price: 2.0,
bid_size: 1.0,
ask_size: 1.0,
})
}
fn target(kind: EventKind, symbol: &str) -> FeedTarget {
FeedTarget {
kind,
symbol: symbol.to_string(),
from_time: None,
}
}
fn no_progress() -> CandleProgress {
Arc::new(Mutex::new(HashMap::new()))
}
fn sink(events: mpsc::Sender<MarketEvent>) -> Subscriber {
Subscriber {
events,
lagged: Arc::new(AtomicU64::new(0)),
}
}
fn candle(symbol: &str, time: i64) -> MarketEvent {
let MarketEvent::Candle(mut candle) = every_event_type(symbol)
.into_iter()
.find(|event| matches!(event, MarketEvent::Candle(_)))
.expect("a candle")
else {
unreachable!("filtered on the variant")
};
candle.time = time;
MarketEvent::Candle(candle)
}
fn every_event_type(symbol: &str) -> Vec<MarketEvent> {
use dxlink::events::*;
let sym = || symbol.to_string();
vec![
quote(symbol),
MarketEvent::Trade(TradeEvent {
event_type: "Trade".to_string(),
event_symbol: sym(),
price: 1.0,
size: 1.0,
day_volume: 10.0,
}),
MarketEvent::TradeETH(TradeETHEvent {
event_type: "TradeETH".to_string(),
event_symbol: sym(),
event_time: 0,
time: 0,
time_nano_part: 0,
sequence: 0,
exchange_code: "Q".to_string(),
price: 1.0,
change: 0.0,
size: 1.0,
day_id: 0,
day_volume: 0.0,
day_turnover: 0.0,
tick_direction: "Up".to_string(),
extended_trading_hours: true,
}),
MarketEvent::Greeks(GreeksEvent {
event_type: "Greeks".to_string(),
event_symbol: sym(),
delta: 0.5,
gamma: 0.1,
theta: -0.05,
vega: 0.2,
rho: 0.03,
volatility: 0.25,
}),
MarketEvent::Candle(CandleEvent {
event_type: "Candle".to_string(),
event_symbol: sym(),
event_time: 0,
event_flags: 0,
index: 0,
time: 1_700_000_000_000,
sequence: 0,
count: 1,
open: 1.0,
high: 2.0,
low: 0.5,
close: 1.5,
volume: 100.0,
vwap: 1.4,
bid_volume: 50.0,
ask_volume: 50.0,
imp_volatility: 0.2,
open_interest: 0.0,
}),
MarketEvent::Summary(SummaryEvent {
event_type: "Summary".to_string(),
event_symbol: sym(),
event_time: 0,
day_id: 0,
day_open_price: 0.0,
day_high_price: 0.0,
day_low_price: 0.0,
day_close_price: 0.0,
day_close_price_type: "Final".to_string(),
prev_day_id: 0,
prev_day_close_price: 0.0,
prev_day_close_price_type: "Final".to_string(),
prev_day_volume: 0.0,
open_interest: 0.0,
}),
MarketEvent::TimeAndSale(TimeAndSaleEvent {
event_type: "TimeAndSale".to_string(),
event_symbol: sym(),
event_time: 0,
event_flags: 0,
index: 0,
time: 0,
time_nano_part: 0,
sequence: 0,
exchange_code: "Q".to_string(),
price: 1.0,
size: 1.0,
bid_price: 0.9,
ask_price: 1.1,
exchange_sale_conditions: String::new(),
trade_through_exempt: String::new(),
aggressor_side: "Buy".to_string(),
spread_leg: false,
extended_trading_hours: false,
valid_tick: true,
sale_type: String::new(),
buyer: String::new(),
seller: String::new(),
}),
MarketEvent::Profile(ProfileEvent {
event_type: "Profile".to_string(),
event_symbol: sym(),
event_time: 0,
description: "Apple".to_string(),
short_sale_restriction: "Inactive".to_string(),
trading_status: "Active".to_string(),
status_reason: String::new(),
halt_start_time: 0,
halt_end_time: 0,
high_limit_price: 0.0,
low_limit_price: 0.0,
high_52_week_price: 0.0,
low_52_week_price: 0.0,
beta: 0.0,
earnings_per_share: 0.0,
dividend_frequency: 0.0,
ex_dividend_amount: 0.0,
ex_dividend_day_id: 0,
shares: 0.0,
free_float: 0.0,
}),
MarketEvent::Underlying(UnderlyingEvent {
event_type: "Underlying".to_string(),
event_symbol: sym(),
event_time: 0,
event_flags: 0,
index: 0,
time: 0,
sequence: 0,
volatility: 0.2,
front_volatility: 0.21,
back_volatility: 0.19,
call_volume: 10.0,
put_volume: 8.0,
put_call_ratio: 0.8,
}),
MarketEvent::TheoPrice(TheoPriceEvent {
event_type: "TheoPrice".to_string(),
event_symbol: sym(),
event_time: 0,
event_flags: 0,
index: 0,
time: 0,
sequence: 0,
price: 1.0,
underlying_price: 100.0,
delta: 0.5,
gamma: 0.1,
dividend: 0.0,
interest: 0.0,
}),
MarketEvent::Series(SeriesEvent {
event_type: "Series".to_string(),
event_symbol: sym(),
event_time: 0,
event_flags: 0,
index: 0,
time: 0,
sequence: 0,
expiration: 20_260_918,
volatility: 0.2,
call_volume: 10.0,
put_volume: 8.0,
put_call_ratio: 0.8,
forward_price: 100.0,
dividend: 0.0,
interest: 0.0,
}),
]
}
fn policy() -> BackoffPolicy {
BackoffPolicy {
initial: Duration::from_millis(10),
max_delay: Duration::from_millis(40),
max_attempts: Some(2),
jitter: 0.0,
}
}
#[test]
fn a_replay_asks_for_the_event_types_the_subscription_had() {
let requests = feed_subscriptions(&[
target(EventKind::Quote, "AAPL"),
target(EventKind::Greeks, "AAPL"),
target(EventKind::Quote, "MSFT"),
target(EventKind::Greeks, "MSFT"),
]);
assert_eq!(requests.len(), 4, "two symbols by two event types");
let types: BTreeSet<&str> = requests.iter().map(|r| r.event_type.as_str()).collect();
assert_eq!(
types,
BTreeSet::from(["Greeks", "Quote"]),
"Trade was not asked for: {types:?}"
);
}
#[test]
fn a_subscription_with_no_confirmed_symbols_is_not_replayed() {
let registry: Registry = Arc::new(Mutex::new(HashMap::new()));
registry.lock().unwrap().insert(
7,
SubscriptionRecord {
kinds: BTreeSet::from([EventKind::Quote]),
targets: Arc::new(Mutex::new(BTreeSet::new())),
},
);
assert!(
pending_replay(®istry, &no_progress()).is_empty(),
"a refused subscribe records nothing, so there is nothing to restore"
);
}
#[tokio::test]
async fn a_closed_subscription_is_not_replayed() {
let (tx, rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let _loop_handle = spawn_command_loop(rx, || Ok(()));
let sub = streamer
.create_sub([EventKind::Quote])
.await
.expect("the streamer is open");
sub.add_symbols(&[Symbol::from("AAPL")])
.await
.expect("subscribing succeeds");
let pending = pending_replay(&streamer.registry, &no_progress());
assert_eq!(pending.len(), 1, "a live subscription is restored");
assert_eq!(pending[0].1[0].symbol, "AAPL");
streamer.close_sub(sub.id).await.expect("closing succeeds");
assert!(
pending_replay(&streamer.registry, &no_progress()).is_empty(),
"a closed subscription must not be resubscribed by a reconnect"
);
}
#[tokio::test]
async fn a_forwarded_event_reaches_the_subscription_registered_for_it() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (sub_tx, mut sub_rx) = mpsc::channel::<MarketEvent>(4);
{
let mut routing = routing.write().await;
routing.senders.insert(1, vec![sink(sub_tx)]);
routing
.routes
.insert(("AAPL".to_string(), EventKind::Quote), HashSet::from([1]));
}
let (events_tx, events_rx) = mpsc::channel::<MarketEvent>(4);
let saw_event = Arc::new(AtomicBool::new(false));
let forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
no_progress(),
saw_event.clone(),
));
events_tx
.send(quote("AAPL"))
.await
.expect("the feed accepts");
let received = sub_rx
.recv()
.await
.expect("the subscription is delivered to");
assert!(matches!(received, MarketEvent::Quote(q) if q.event_symbol == "AAPL"));
events_tx
.send(quote("TSLA"))
.await
.expect("the feed accepts");
assert!(
tokio::time::timeout(Duration::from_millis(50), sub_rx.recv())
.await
.is_err(),
"an unrouted symbol must not be delivered"
);
assert!(
saw_event.load(Ordering::Relaxed),
"an event that arrived is the milestone the backoff resets on"
);
forwarder.abort();
}
#[tokio::test]
async fn a_refused_subscribe_takes_its_route_back() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let wanted = feed_subscriptions(&[target(EventKind::Quote, "AAPL")]);
record_routes(&routing, 3, &wanted).await;
record_routes(&routing, 4, &wanted).await;
forget_routes(&routing, 3, &wanted).await;
let routes = routing.read().await;
let subs = routes
.routes
.get(&("AAPL".to_string(), EventKind::Quote))
.expect("the accepted subscription still holds the symbol");
assert!(
!subs.contains(&3),
"a refused subscribe must not leave a route behind"
);
assert!(subs.contains(&4), "the accepted one keeps its route");
}
#[tokio::test]
async fn the_last_route_removed_takes_the_symbol_with_it() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let wanted = feed_subscriptions(&[target(EventKind::Quote, "AAPL")]);
record_routes(&routing, 3, &wanted).await;
forget_routes(&routing, 3, &wanted).await;
assert!(
routing.read().await.routes.is_empty(),
"an empty set must not be left behind as a route"
);
}
#[tokio::test]
async fn a_closed_event_stream_ends_the_connection_without_any_write() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (events_tx, events_rx) = mpsc::channel::<MarketEvent>(4);
let saw_event = Arc::new(AtomicBool::new(false));
let mut forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
no_progress(),
saw_event.clone(),
));
let mut client = DXLinkClient::new("wss://127.0.0.1:1", "unused");
let (_commands_tx, mut commands_rx) = mpsc::channel::<DXLinkCommand>(4);
let (_shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
drop(events_tx);
let ended = tokio::time::timeout(
Duration::from_secs(2),
run_connection(
&mut client,
1,
&mut commands_rx,
&mut shutdown_rx,
&mut forwarder,
&routing,
&mut BTreeSet::new(),
),
)
.await
.expect("the closing stream must end the connection, not be waited on forever");
assert!(
matches!(ended, Ended::ConnectionLost),
"a closed event stream is a lost connection"
);
}
#[tokio::test]
async fn a_forwarder_that_panics_is_reported_rather_than_read_as_a_venue_drop() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let mut forwarder = tokio::spawn(async { panic!("the forwarder died") });
let mut client = DXLinkClient::new("wss://127.0.0.1:1", "unused");
let (_commands_tx, mut commands_rx) = mpsc::channel::<DXLinkCommand>(4);
let (_shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
let ended = tokio::time::timeout(
Duration::from_secs(2),
run_connection(
&mut client,
1,
&mut commands_rx,
&mut shutdown_rx,
&mut forwarder,
&routing,
&mut BTreeSet::new(),
),
)
.await
.expect("a dead forwarder must end the connection, not be waited on");
assert!(matches!(ended, Ended::ConnectionLost));
}
#[tokio::test]
async fn the_owner_still_takes_precedence_over_a_live_stream() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (_events_tx, events_rx) = mpsc::channel::<MarketEvent>(4);
let mut forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
no_progress(),
Arc::new(AtomicBool::new(false)),
));
let mut client = DXLinkClient::new("wss://127.0.0.1:1", "unused");
let (_commands_tx, mut commands_rx) = mpsc::channel::<DXLinkCommand>(4);
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
drop(shutdown_tx);
let ended = tokio::time::timeout(
Duration::from_secs(2),
run_connection(
&mut client,
1,
&mut commands_rx,
&mut shutdown_rx,
&mut forwarder,
&routing,
&mut BTreeSet::new(),
),
)
.await
.expect("a dropped owner ends the connection promptly");
assert!(matches!(ended, Ended::Owner));
}
#[tokio::test]
async fn all_eleven_event_types_convert_and_are_delivered() {
let events = every_event_type("AAPL");
assert_eq!(events.len(), EventKind::ALL.len());
let (tx, rx) = mpsc::channel::<MarketEvent>(32);
let (_unused_tx, event_receiver) = flume::unbounded();
let mut subscription = QuoteSubscription {
id: SubscriptionId(0),
streamer: StreamerHandle { commands: None },
kinds: EventKind::ALL.into_iter().collect(),
event_receiver,
dxlink_receiver: rx,
targets: Arc::new(Mutex::new(BTreeSet::new())),
lagged: Arc::new(AtomicU64::new(0)),
};
for event in &events {
assert_eq!(event_symbol(event), Some("AAPL"), "{:?}", event_kind(event));
tx.send(event.clone()).await.expect("the feed accepts");
}
let mut seen = BTreeSet::new();
for _ in 0..events.len() {
let event = tokio::time::timeout(Duration::from_secs(2), subscription.get_event())
.await
.expect("no modelled event may stall the reader")
.expect("every modelled event is readable");
assert_eq!(event.sym, "AAPL");
seen.insert(event.data.kind());
}
assert_eq!(
seen,
EventKind::ALL.into_iter().collect::<BTreeSet<_>>(),
"every event type must arrive as its own variant"
);
}
#[test]
fn the_kinds_this_crate_routes_cover_every_variant_the_feed_models() {
let kinds: BTreeSet<EventKind> = every_event_type("AAPL").iter().map(event_kind).collect();
assert_eq!(kinds, EventKind::ALL.into_iter().collect::<BTreeSet<_>>());
}
#[tokio::test]
async fn two_candle_periods_of_one_underlying_do_not_cross_deliver() {
let five = CandlePeriod::minutes(5).expect("a period");
let hour = CandlePeriod::hours(1).expect("a period");
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (five_tx, mut five_rx) = mpsc::channel::<MarketEvent>(4);
let (hour_tx, mut hour_rx) = mpsc::channel::<MarketEvent>(4);
{
let mut routing = routing.write().await;
routing.senders.insert(1, vec![sink(five_tx)]);
routing.senders.insert(2, vec![sink(hour_tx)]);
}
record_routes(
&routing,
1,
&feed_subscriptions(&[FeedTarget {
kind: EventKind::Candle,
symbol: five.streamer_symbol("AAPL"),
from_time: Some(0),
}]),
)
.await;
record_routes(
&routing,
2,
&feed_subscriptions(&[FeedTarget {
kind: EventKind::Candle,
symbol: hour.streamer_symbol("AAPL"),
from_time: Some(0),
}]),
)
.await;
let (events_tx, events_rx) = mpsc::channel::<MarketEvent>(4);
let progress = no_progress();
let forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
progress.clone(),
Arc::new(AtomicBool::new(false)),
));
events_tx
.send(candle("AAPL{=h}", 3_600_000))
.await
.expect("the feed accepts");
let delivered = hour_rx.recv().await.expect("the hourly subscription");
assert_eq!(event_symbol(&delivered), Some("AAPL{=h}"));
assert!(
tokio::time::timeout(Duration::from_millis(50), five_rx.recv())
.await
.is_err(),
"the five-minute subscription must not see an hourly bar"
);
events_tx
.send(candle("AAPL{=5m}", 300_000))
.await
.expect("the feed accepts");
let delivered = five_rx.recv().await.expect("the five-minute subscription");
assert_eq!(event_symbol(&delivered), Some("AAPL{=5m}"));
assert!(
tokio::time::timeout(Duration::from_millis(50), hour_rx.recv())
.await
.is_err(),
"the hourly subscription must not see a five-minute bar"
);
events_tx
.send(quote("AAPL"))
.await
.expect("the feed accepts");
assert!(
tokio::time::timeout(Duration::from_millis(50), five_rx.recv())
.await
.is_err()
);
let seen = progress.lock().expect("not poisoned in tests").clone();
assert_eq!(
seen.get(&(1, "AAPL{=5m}".to_string()))
.and_then(|r| r.through),
Some(300_000)
);
assert_eq!(
seen.get(&(2, "AAPL{=h}".to_string()))
.and_then(|r| r.through),
Some(3_600_000)
);
forwarder.abort();
}
#[tokio::test]
async fn a_subscription_only_receives_the_event_types_it_asked_for() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (quotes_tx, mut quotes_rx) = mpsc::channel::<MarketEvent>(4);
routing
.write()
.await
.senders
.insert(1, vec![sink(quotes_tx)]);
record_routes(
&routing,
1,
&feed_subscriptions(&[target(EventKind::Quote, "AAPL")]),
)
.await;
let (events_tx, events_rx) = mpsc::channel::<MarketEvent>(4);
let forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
no_progress(),
Arc::new(AtomicBool::new(false)),
));
let trade = every_event_type("AAPL")
.into_iter()
.find(|event| matches!(event, MarketEvent::Trade(_)))
.expect("a trade");
events_tx.send(trade).await.expect("the feed accepts");
assert!(
tokio::time::timeout(Duration::from_millis(50), quotes_rx.recv())
.await
.is_err(),
"a Quote subscription must not receive Trade prints"
);
events_tx
.send(quote("AAPL"))
.await
.expect("the feed accepts");
assert!(quotes_rx.recv().await.is_some(), "the quote still arrives");
forwarder.abort();
}
#[test]
fn a_candle_replay_resumes_after_the_last_contiguous_bar() {
let original = FeedTarget {
kind: EventKind::Candle,
symbol: "AAPL{=5m}".to_string(),
from_time: Some(1_000),
};
let seen = HashMap::from([(
(1u32, "AAPL{=5m}".to_string()),
CandleResume {
through: Some(5_000),
gap: false,
},
)]);
assert_eq!(
resume_from(1, original.clone(), &seen).from_time,
Some(5_001),
"the replay picks up one millisecond past the last bar delivered"
);
assert_eq!(
resume_from(1, original.clone(), &HashMap::new()).from_time,
Some(1_000)
);
assert_eq!(
resume_from(2, original.clone(), &seen).from_time,
Some(1_000)
);
let other = FeedTarget {
symbol: "AAPL{=h}".to_string(),
..original.clone()
};
assert_eq!(resume_from(1, other, &seen).from_time, Some(1_000));
assert_eq!(
resume_from(1, target(EventKind::Quote, "AAPL"), &seen).from_time,
None
);
}
#[test]
fn a_candle_request_carries_its_history_start() {
let requests = feed_subscriptions(&[FeedTarget {
kind: EventKind::Candle,
symbol: "AAPL{=5m}".to_string(),
from_time: Some(1_700_000_000_000),
}]);
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].event_type, "Candle");
assert_eq!(requests[0].symbol, "AAPL{=5m}");
assert_eq!(requests[0].from_time, Some(1_700_000_000_000));
}
#[tokio::test]
async fn candles_and_bare_symbols_are_not_interchangeable() {
let (tx, rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let _loop_handle = spawn_command_loop(rx, || Ok(()));
let quotes = streamer
.create_sub([EventKind::Quote])
.await
.expect("the streamer is open");
let error = quotes
.add_candles(
&[Symbol::from("AAPL")],
CandlePeriod::minutes(5).expect("a period"),
DateTime::from_timestamp(1_700_000_000, 0).expect("a timestamp"),
)
.await
.expect_err("the channel is not configured for candles");
assert!(
matches!(error, TastyTradeError::Precondition(_)),
"{error:?}"
);
let candles = streamer
.create_sub([EventKind::Candle])
.await
.expect("the streamer is open");
let error = candles
.add_symbols(&[Symbol::from("AAPL")])
.await
.expect_err("a bare symbol has no period and no start time");
assert!(
matches!(error, TastyTradeError::Precondition(_)),
"{error:?}"
);
}
#[tokio::test]
async fn a_candle_subscription_is_recorded_under_its_period_symbol() {
let (tx, rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let loop_handle = spawn_command_loop(rx, || Ok(()));
let sub = streamer
.create_sub([EventKind::Candle])
.await
.expect("the streamer is open");
sub.add_candles(
&[Symbol::from("AAPL")],
CandlePeriod::minutes(5).expect("a period"),
DateTime::from_timestamp(1_700_000_000, 0).expect("a timestamp"),
)
.await
.expect("subscribing succeeds");
assert_eq!(
sub.subscribed(),
vec![("AAPL{=5m}".to_string(), EventKind::Candle)]
);
drop(sub);
drop(streamer);
let sent = loop_handle.await.expect("the stand-in loop finishes");
assert_eq!(sent, vec!["AAPL{=5m}".to_string()]);
}
#[tokio::test]
async fn the_event_route_is_registered_before_a_subscription_can_be_used() {
let (tx, mut rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let sub = streamer
.create_sub([EventKind::Candle])
.await
.expect("the streamer is open");
let mut registrations = 0;
while let Ok(DXLinkCommand::AddEventSender(id, _)) = rx.try_recv() {
assert_eq!(id, sub.id.0 as u32);
registrations += 1;
}
assert_eq!(
registrations, 1,
"exactly one consumer is registered: the caller's. The streamer's own \
copy used to register a second that nothing could ever read, so it \
filled up and then charged a drop for every event afterwards"
);
}
#[tokio::test]
async fn creating_a_subscription_on_a_closed_streamer_fails() {
let (tx, rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
drop(rx);
let Err(error) = streamer.create_sub([EventKind::Quote]).await else {
panic!("a closed streamer cannot register a route");
};
assert!(matches!(error, TastyTradeError::Streaming(_)), "{error:?}");
}
#[tokio::test]
async fn a_dropped_bar_freezes_the_resume_point_rather_than_being_stepped_over() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (full_tx, _never_read) = mpsc::channel::<MarketEvent>(1);
let lagged = Arc::new(AtomicU64::new(0));
routing.write().await.senders.insert(
1,
vec![Subscriber {
events: full_tx,
lagged: lagged.clone(),
}],
);
record_routes(
&routing,
1,
&feed_subscriptions(&[FeedTarget {
kind: EventKind::Candle,
symbol: "AAPL{=5m}".to_string(),
from_time: Some(0),
}]),
)
.await;
let (events_tx, events_rx) = mpsc::channel::<MarketEvent>(8);
let progress = no_progress();
let forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
progress.clone(),
Arc::new(AtomicBool::new(false)),
));
for time in [1_000i64, 2_000, 3_000] {
events_tx
.send(candle("AAPL{=5m}", time))
.await
.expect("the feed accepts");
}
tokio::time::sleep(Duration::from_millis(100)).await;
let resume = progress
.lock()
.expect("not poisoned in tests")
.get(&(1, "AAPL{=5m}".to_string()))
.copied()
.expect("the series was seen");
assert_eq!(
resume.through,
Some(1_000),
"only the bar that was actually delivered may be resumed past"
);
assert!(
resume.gap,
"a drop has to be remembered, or the next delivery steps over it"
);
assert_eq!(lagged.load(Ordering::Relaxed), 2);
forwarder.abort();
}
#[tokio::test]
async fn a_one_minute_candle_is_routed_under_the_symbol_the_venue_echoes() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (sink_tx, mut sink_rx) = mpsc::channel::<MarketEvent>(8);
routing.write().await.senders.insert(1, vec![sink(sink_tx)]);
let period = CandlePeriod::minutes(1).expect("one minute is a period");
record_routes(
&routing,
1,
&feed_subscriptions(&[FeedTarget {
kind: EventKind::Candle,
symbol: period.streamer_symbol("AAPL"),
from_time: Some(0),
}]),
)
.await;
let (events_tx, events_rx) = mpsc::channel::<MarketEvent>(8);
let progress = no_progress();
let forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
progress.clone(),
Arc::new(AtomicBool::new(false)),
));
events_tx
.send(candle("AAPL{=m}", 1_000))
.await
.expect("the feed accepts");
let delivered = tokio::time::timeout(Duration::from_secs(2), sink_rx.recv())
.await
.expect("the bar must be routed, not dropped as unregistered")
.expect("the sink is open");
assert_eq!(event_symbol(&delivered), Some("AAPL{=m}"));
let seen = progress.lock().expect("not poisoned in tests").clone();
let resume = seen
.get(&(1, "AAPL{=m}".to_string()))
.copied()
.expect("the series was seen under its canonical symbol");
assert_eq!(resume.through, Some(1_000));
let replayed = resume_from(
1,
FeedTarget {
kind: EventKind::Candle,
symbol: period.streamer_symbol("AAPL"),
from_time: Some(0),
},
&seen,
);
assert_eq!(
replayed.from_time,
Some(1_001),
"the replay must continue past the delivered bar"
);
forwarder.abort();
}
#[tokio::test]
async fn canonical_candle_routes_are_removed_per_subscription() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let period = CandlePeriod::minutes(1).expect("one minute is a period");
let wanted = feed_subscriptions(&[FeedTarget {
kind: EventKind::Candle,
symbol: period.streamer_symbol("AAPL"),
from_time: Some(0),
}]);
record_routes(&routing, 1, &wanted).await;
record_routes(&routing, 2, &wanted).await;
let key = ("AAPL{=m}".to_string(), EventKind::Candle);
assert_eq!(
routing.read().await.routes.get(&key).map(|s| s.len()),
Some(2),
"both subscriptions share the canonical route"
);
forget_routes(&routing, 1, &wanted).await;
assert_eq!(
routing.read().await.routes.get(&key).cloned(),
Some(HashSet::from([2])),
"the other subscriber keeps the route"
);
forget_routes(&routing, 2, &wanted).await;
assert!(
!routing.read().await.routes.contains_key(&key),
"nobody left, so the key goes"
);
}
#[tokio::test]
async fn a_dropped_one_minute_bar_freezes_the_canonical_resume_point() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (full_tx, _never_read) = mpsc::channel::<MarketEvent>(1);
routing.write().await.senders.insert(1, vec![sink(full_tx)]);
let period = CandlePeriod::minutes(1).expect("one minute is a period");
let target = FeedTarget {
kind: EventKind::Candle,
symbol: period.streamer_symbol("AAPL"),
from_time: Some(0),
};
record_routes(
&routing,
1,
&feed_subscriptions(std::slice::from_ref(&target)),
)
.await;
let (events_tx, events_rx) = mpsc::channel::<MarketEvent>(8);
let progress = no_progress();
let forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
progress.clone(),
Arc::new(AtomicBool::new(false)),
));
for time in [1_000i64, 2_000] {
events_tx
.send(candle("AAPL{=m}", time))
.await
.expect("the feed accepts");
}
tokio::time::sleep(Duration::from_millis(100)).await;
let seen = progress.lock().expect("not poisoned in tests").clone();
let resume = seen
.get(&(1, "AAPL{=m}".to_string()))
.copied()
.expect("the series was seen");
assert_eq!(resume.through, Some(1_000));
assert!(resume.gap, "the drop is remembered");
assert_eq!(
resume_from(1, target, &seen).from_time,
Some(1_001),
"the replay resumes right after the last delivered bar"
);
forwarder.abort();
}
#[tokio::test]
async fn a_series_whose_first_bar_was_dropped_resumes_from_the_beginning() {
let routing: Arc<RwLock<EventRouting>> = Arc::new(RwLock::new(EventRouting::default()));
let (full_tx, _never_read) = mpsc::channel::<MarketEvent>(1);
full_tx
.try_send(quote("filler"))
.expect("the one slot is taken");
routing.write().await.senders.insert(
1,
vec![Subscriber {
events: full_tx,
lagged: Arc::new(AtomicU64::new(0)),
}],
);
record_routes(
&routing,
1,
&feed_subscriptions(&[FeedTarget {
kind: EventKind::Candle,
symbol: "AAPL{=5m}".to_string(),
from_time: Some(1_000),
}]),
)
.await;
let (events_tx, events_rx) = mpsc::channel::<MarketEvent>(8);
let progress = no_progress();
let forwarder = tokio::spawn(forward_events(
events_rx,
routing.clone(),
progress.clone(),
Arc::new(AtomicBool::new(false)),
));
events_tx
.send(candle("AAPL{=5m}", 9_000))
.await
.expect("the feed accepts");
tokio::time::sleep(Duration::from_millis(100)).await;
let seen = progress.lock().expect("not poisoned in tests").clone();
assert_eq!(
seen.get(&(1, "AAPL{=5m}".to_string()))
.and_then(|r| r.through),
None,
"no bar was delivered, so there is nothing to resume past"
);
let target = FeedTarget {
kind: EventKind::Candle,
symbol: "AAPL{=5m}".to_string(),
from_time: Some(1_000),
};
assert_eq!(
resume_from(1, target, &seen).from_time,
Some(1_000),
"a series that has only ever dropped bars asks for the whole thing again"
);
forwarder.abort();
}
#[tokio::test]
async fn the_streamers_copy_reports_the_same_lag_as_the_callers() {
let (tx, mut rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let sub = streamer
.create_sub([EventKind::Quote])
.await
.expect("the streamer is open");
let Some(DXLinkCommand::AddEventSender(_, subscriber)) = rx.try_recv().ok() else {
panic!("the consumer is registered before create_sub returns");
};
subscriber.lagged.fetch_add(3, Ordering::Relaxed);
assert_eq!(sub.lagged(), 3, "the caller sees its own loss");
assert_eq!(
streamer
.get_sub(sub.id)
.expect("the streamer kept a copy")
.lagged(),
3,
"and so does the copy the streamer hands out"
);
}
#[test]
fn a_replay_resumes_from_before_the_gap() {
let seen = HashMap::from([(
(1u32, "AAPL{=5m}".to_string()),
CandleResume {
through: Some(1_000),
gap: true,
},
)]);
let target = FeedTarget {
kind: EventKind::Candle,
symbol: "AAPL{=5m}".to_string(),
from_time: Some(0),
};
assert_eq!(
resume_from(1, target, &seen).from_time,
Some(1_001),
"the replay comes back from before the gap and refills it"
);
}
#[tokio::test]
async fn a_subscription_with_no_buffer_is_refused() {
let (tx, _rx) = mpsc::channel::<DXLinkCommand>(8);
let (shutdown_tx, _shutdown_rx) = oneshot::channel::<()>();
let mut streamer = streamer_with(tx, shutdown_tx);
let Err(error) = streamer
.create_sub_with_capacity([EventKind::Quote], 0)
.await
else {
panic!("a zero-capacity subscription cannot deliver anything");
};
assert!(
matches!(error, TastyTradeError::Precondition(_)),
"{error:?}"
);
}
#[tokio::test]
async fn the_backoff_gives_up_and_says_why() {
let state = Arc::new(RwLock::new(ConnectionState::Connected));
let (_shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
let mut attempt = 0u32;
assert!(schedule(&policy(), &mut attempt, &state, &mut shutdown_rx).await);
assert_eq!(
*state.read().await,
ConnectionState::Reconnecting {
attempt: 1,
delay: Duration::from_millis(10)
}
);
assert!(schedule(&policy(), &mut attempt, &state, &mut shutdown_rx).await);
assert!(
!schedule(&policy(), &mut attempt, &state, &mut shutdown_rx).await,
"one past the limit must stop"
);
let ConnectionState::Disconnected { reason } = state.read().await.clone() else {
panic!("giving up must be terminal, not another retry");
};
assert!(reason.contains("2 attempts"), "{reason}");
}
#[tokio::test]
async fn a_backoff_is_interrupted_by_the_owner_going_away() {
let state = Arc::new(RwLock::new(ConnectionState::Connected));
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
let slow = BackoffPolicy {
initial: Duration::from_secs(30),
max_attempts: None,
..policy()
};
let mut attempt = 0u32;
drop(shutdown_tx);
assert!(
!schedule(&slow, &mut attempt, &state, &mut shutdown_rx).await,
"a dropped owner ends the wait"
);
}
#[test]
fn only_a_dead_socket_counts_as_a_lost_connection() {
assert!(is_connection_lost(&dxlink::DXLinkError::Connection(
"closed".to_string()
)));
assert!(!is_connection_lost(&dxlink::DXLinkError::Protocol(
"unknown symbol".to_string()
)));
assert!(!is_connection_lost(&dxlink::DXLinkError::Authentication(
"token expired".to_string()
)));
}
#[test]
fn a_rejected_session_is_not_worth_retrying() {
let policy = BackoffPolicy::default();
let refused: TastyTradeError = dxlink::DXLinkError::Authentication("nope".into()).into();
assert!(!policy.should_retry(&refused), "{refused:?}");
assert!(policy.should_retry(&TastyTradeError::Connection("dropped".into())));
}
}