use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{RwLock, oneshot};
use crate::TastyTradeError;
use crate::accounts::AccountNumber;
use crate::streaming::reconnect::{BackoffPolicy, ConnectionState};
use crate::types::balance::Balance;
use crate::types::quote_alert::QuoteAlert;
use crate::types::watchlist::Watchlist;
use crate::{BriefPosition, LiveOrderRecord, TastyResult, TastyTrade, accounts::Account};
use futures_util::{SinkExt, StreamExt};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::{Deserialize, Serialize};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tracing::{debug, error, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SubRequestAction {
Heartbeat,
Connect,
PublicWatchlistsSubscribe,
QuoteAlertsSubscribe,
UserMessageSubscribe,
}
impl std::fmt::Display for SubRequestAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SubRequestAction::Heartbeat => write!(f, "heartbeat"),
SubRequestAction::Connect => write!(f, "connect"),
SubRequestAction::PublicWatchlistsSubscribe => write!(f, "public-watchlists-subscribe"),
SubRequestAction::QuoteAlertsSubscribe => write!(f, "quote-alerts-subscribe"),
SubRequestAction::UserMessageSubscribe => write!(f, "user-message-subscribe"),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
struct SubRequest<T: Serialize> {
auth_token: String,
action: SubRequestAction,
value: Option<T>,
request_id: u64,
}
impl<T: Serialize> std::fmt::Debug for SubRequest<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SubRequest")
.field("auth_token", &"***")
.field("action", &self.action)
.field("request_id", &self.request_id)
.finish_non_exhaustive()
}
}
pub struct HandlerAction {
action: SubRequestAction,
value: Option<Box<dyn erased_serde::Serialize + Send + Sync>>,
ack: Option<oneshot::Sender<TastyResult<()>>>,
}
#[derive(Debug)]
pub enum NotificationPayload {
Order(Box<LiveOrderRecord>),
AccountBalance(Box<Balance>),
CurrentPosition(Box<BriefPosition>),
QuoteAlert(Box<QuoteAlert>),
PublicWatchlist(Box<Watchlist>),
Unsupported(RawPayload),
}
#[derive(Debug)]
pub struct AccountNotification {
pub kind: String,
pub timestamp: Option<i64>,
pub payload: NotificationPayload,
}
#[derive(Debug)]
pub struct UnknownEvent {
pub kind: Option<String>,
pub action: Option<String>,
pub payload: RawPayload,
}
#[derive(Clone, PartialEq, Eq)]
pub struct RawPayload(String);
impl RawPayload {
pub(crate) fn new(json: impl Into<String>) -> Self {
Self(json.into())
}
pub fn expose(&self) -> &str {
&self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl std::fmt::Debug for RawPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "RawPayload(<redacted, {} bytes>)", self.0.len())
}
}
impl std::fmt::Display for RawPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "<redacted, {} bytes>", self.0.len())
}
}
#[derive(Deserialize, DebugPretty, DisplaySimple, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct StatusMessage {
pub status: String,
pub action: String,
#[serde(default)]
pub web_socket_session_id: Option<String>,
#[serde(default)]
pub request_id: Option<u64>,
#[serde(default)]
pub value: Option<Vec<AccountNumber>>,
}
#[derive(Deserialize, DebugPretty, DisplaySimple, Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct ErrorMessage {
pub status: String,
pub action: String,
#[serde(default)]
pub web_socket_session_id: Option<String>,
#[serde(default)]
pub request_id: Option<u64>,
pub message: String,
}
#[derive(Debug)]
pub enum AccountEvent {
ErrorMessage(ErrorMessage),
StatusMessage(StatusMessage),
Notification(Box<AccountNotification>),
Unknown(UnknownEvent),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountTransport {
Websocket,
}
#[derive(Debug)]
pub struct AccountStreamer {
pub event_receiver: flume::Receiver<AccountEvent>,
pub action_sender: flume::Sender<HandlerAction>,
transport: AccountTransport,
state: Arc<RwLock<ConnectionState>>,
cancel: Option<oneshot::Sender<()>>,
subscribed: Arc<Mutex<BTreeSet<AccountNumber>>>,
}
impl AccountStreamer {
pub async fn connect(tasty: &TastyTrade) -> TastyResult<AccountStreamer> {
Self::connect_with_policy(tasty, BackoffPolicy::default()).await
}
pub async fn connect_with_policy(
tasty: &TastyTrade,
policy: BackoffPolicy,
) -> TastyResult<AccountStreamer> {
let (event_sender, event_receiver) = flume::unbounded();
let (action_sender, action_receiver): (
flume::Sender<HandlerAction>,
flume::Receiver<HandlerAction>,
) = flume::unbounded();
let session = connect_session(&tasty.config.websocket_url).await?;
debug!("Account websocket connected");
let state = Arc::new(RwLock::new(ConnectionState::Connected));
let subscribed: Arc<Mutex<BTreeSet<AccountNumber>>> = Arc::new(Mutex::new(BTreeSet::new()));
let supervisor_state = state.clone();
let supervisor_subscribed = subscribed.clone();
let (cancel_tx, mut cancelled) = oneshot::channel::<()>();
let client = tasty.clone();
let config = tasty.config.clone();
let mut session = Some(session);
tokio::spawn(async move {
let mut attempt = 0u32;
loop {
let live = match session.take() {
Some(live) => live,
None => match connect_session(&config.websocket_url).await {
Ok(live) => live,
Err(e) => {
if !policy.should_retry(&e) {
terminal(&supervisor_state, format!("reconnect refused: {e}"))
.await;
return;
}
match schedule(&policy, &mut attempt, &supervisor_state, &mut cancelled)
.await
{
true => continue,
false => return,
}
}
},
};
let worked = run_session(
live,
&client,
&event_sender,
&action_receiver,
&supervisor_subscribed,
&supervisor_state,
&mut cancelled,
)
.await;
if worked {
attempt = 0;
}
if cancelled.try_recv().is_ok() || event_sender.is_disconnected() {
debug!("Account streamer dropped, ending the supervisor");
return;
}
if !schedule(&policy, &mut attempt, &supervisor_state, &mut cancelled).await {
return;
}
if let Err(e) = client.access_token().await {
if !policy.should_retry(&e) {
terminal(
&supervisor_state,
"the refresh token was refused; authorize again to obtain a new grant"
.to_string(),
)
.await;
return;
}
warn!("Could not refresh the access token before reconnecting: {e}");
}
}
});
Ok(Self {
event_receiver,
action_sender,
transport: AccountTransport::Websocket,
cancel: Some(cancel_tx),
state,
subscribed,
})
}
pub async fn state(&self) -> ConnectionState {
self.state.read().await.clone()
}
pub fn transport(&self) -> AccountTransport {
self.transport
}
pub async fn subscribe_to_account<'a>(&self, account: &'a Account<'a>) -> TastyResult<()> {
let number = account.inner.account.account_number.clone();
self.send(SubRequestAction::Connect, Some(vec![number.clone()]))
.await?;
subscribed_of(&self.subscribed).insert(number);
Ok(())
}
pub async fn send<T: Serialize + Send + Sync + 'static>(
&self,
action: SubRequestAction,
value: Option<T>,
) -> TastyResult<()> {
let (ack, answer) = oneshot::channel();
self.action_sender
.send_async(HandlerAction {
action,
value: value
.map(|inner| Box::new(inner) as Box<dyn erased_serde::Serialize + Send + Sync>),
ack: Some(ack),
})
.await
.map_err(|_| {
TastyTradeError::Streaming(
"the account stream is closed; reconnect before sending again".to_string(),
)
})?;
answer.await.map_err(|_| {
TastyTradeError::Streaming(
"the account stream closed before the action was sent".to_string(),
)
})?
}
pub fn decode_frame(data: &[u8]) -> Option<AccountEvent> {
decode_account_frame(data)
}
pub async fn get_event(&self) -> std::result::Result<AccountEvent, flume::RecvError> {
self.event_receiver.recv_async().await
}
}
type Session = (
futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
Message,
>,
futures_util::stream::SplitStream<
tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
>,
);
async fn connect_session(url: &str) -> TastyResult<Session> {
let (stream, _response) = connect_async(url.to_string()).await?;
Ok(stream.split())
}
async fn terminal(state: &Arc<RwLock<ConnectionState>>, reason: String) {
warn!("Account stream gave up: {reason}");
*state.write().await = ConnectionState::Disconnected { reason };
}
async fn schedule(
policy: &BackoffPolicy,
attempt: &mut u32,
state: &Arc<RwLock<ConnectionState>>,
cancelled: &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!("Account stream reconnecting, attempt {attempt} in {delay:?}");
*state.write().await = ConnectionState::Reconnecting {
attempt: *attempt,
delay,
};
tokio::select! {
_ = &mut *cancelled => false,
_ = tokio::time::sleep(delay) => true,
}
}
async fn write_restoration<S>(
sink: &mut S,
auth_token: &str,
accounts: &[AccountNumber],
next_request_id: &mut u64,
) -> TastyResult<Vec<u64>>
where
S: SinkExt<Message> + Unpin,
{
let mut written = Vec::with_capacity(accounts.len());
for account in accounts {
let request_id = *next_request_id;
*next_request_id += 1;
let message = SubRequest {
auth_token: auth_token.to_string(),
action: SubRequestAction::Connect,
value: Some(vec![account.clone()]),
request_id,
};
let text = serde_json::to_string(&message).map_err(|_| {
TastyTradeError::Streaming("a subscription could not be serialized".to_string())
})?;
sink.send(Message::Text(text.into())).await.map_err(|_| {
TastyTradeError::Streaming(
"the account stream closed before its subscriptions could be restored".to_string(),
)
})?;
written.push(request_id);
}
Ok(written)
}
const ACTION_TIMEOUT: Duration = Duration::from_secs(10);
struct PendingAction {
action: SubRequestAction,
deadline: tokio::time::Instant,
ack: Option<oneshot::Sender<TastyResult<()>>>,
}
fn settle(pending: &mut HashMap<u64, PendingAction>, event: &AccountEvent) -> Option<(u64, bool)> {
let (request_id, action, outcome) = match event {
AccountEvent::StatusMessage(status) => (status.request_id, status.action.as_str(), Ok(())),
AccountEvent::ErrorMessage(error) => (
error.request_id,
error.action.as_str(),
Err(TastyTradeError::Streaming(format!(
"the venue refused {}: {}",
error.action, error.message
))),
),
_ => return None,
};
let accepted = outcome.is_ok();
let matched = match request_id {
Some(id) if pending.contains_key(&id) => Some(id),
Some(id) => {
debug!("Account frame answered request {id}, which nothing is waiting on");
return None;
}
None => pending
.iter()
.filter(|(_, waiting)| waiting.action.to_string() == action)
.min_by_key(|(id, waiting)| (waiting.deadline, **id))
.map(|(id, _)| *id),
};
let id = matched?;
let waiting = pending.remove(&id)?;
report(waiting.ack, outcome);
Some((id, accepted))
}
fn abandon(pending: &mut HashMap<u64, PendingAction>, reason: &str) {
for (_, waiting) in pending.drain() {
report(
waiting.ack,
Err(TastyTradeError::Streaming(reason.to_string())),
);
}
}
#[allow(clippy::too_many_arguments)]
async fn run_session(
session: Session,
client: &TastyTrade,
events: &flume::Sender<AccountEvent>,
actions: &flume::Receiver<HandlerAction>,
subscribed: &Arc<Mutex<BTreeSet<AccountNumber>>>,
state: &Arc<RwLock<ConnectionState>>,
cancelled: &mut oneshot::Receiver<()>,
) -> bool {
let (mut write, mut read) = session;
let mut heartbeat = tokio::time::interval(Duration::from_secs(30));
heartbeat.tick().await; let mut wrote_successfully = false;
let mut pending: HashMap<u64, PendingAction> = HashMap::new();
let mut next_request_id: u64 = 1;
let mut sweep = tokio::time::interval(Duration::from_secs(1));
let accounts: Vec<AccountNumber> = subscribed_of(subscribed).iter().cloned().collect();
let mut restoring: HashSet<u64> = HashSet::new();
let wrote_successfully = 'session: {
if !accounts.is_empty() {
let auth_token = match client.access_token().await {
Ok(token) => token.bearer(),
Err(e) => {
warn!("Cannot restore subscriptions: no usable access token ({e})");
break 'session wrote_successfully;
}
};
match write_restoration(&mut write, &auth_token, &accounts, &mut next_request_id).await
{
Ok(ids) => {
for id in ids {
restoring.insert(id);
pending.insert(
id,
PendingAction {
action: SubRequestAction::Connect,
deadline: tokio::time::Instant::now() + ACTION_TIMEOUT,
ack: None,
},
);
}
debug!(
"Restoring {} subscription(s) on the new session",
restoring.len()
);
}
Err(e) => {
warn!("Could not restore subscriptions: {e}");
break 'session wrote_successfully;
}
}
} else {
*state.write().await = ConnectionState::Connected;
}
loop {
tokio::select! {
_ = &mut *cancelled => {
debug!("Account streamer dropped, ending the session");
break 'session wrote_successfully;
}
_ = sweep.tick() => {
let now = tokio::time::Instant::now();
let expired: Vec<u64> = pending
.iter()
.filter(|(_, waiting)| waiting.deadline <= now)
.map(|(id, _)| *id)
.collect();
let mut restoration_expired = false;
for id in expired {
if let Some(waiting) = pending.remove(&id) {
warn!("The venue did not answer a {} within {ACTION_TIMEOUT:?}", waiting.action);
report(waiting.ack, Err(TastyTradeError::Streaming(format!(
"the venue did not acknowledge the {} within {ACTION_TIMEOUT:?}",
waiting.action
))));
}
restoration_expired |= restoring.remove(&id);
}
if restoration_expired {
warn!("A subscription was never restored; ending the session");
break 'session wrote_successfully;
}
}
_ = heartbeat.tick() => {
let auth_token = match client.access_token().await {
Ok(token) => token.bearer(),
Err(e) => {
warn!("Ending the account session: no usable access token ({e})");
break 'session wrote_successfully;
}
};
let request_id = next_request_id;
next_request_id += 1;
let message = SubRequest::<Box<dyn erased_serde::Serialize + Send + Sync>> {
auth_token,
action: SubRequestAction::Heartbeat,
value: None,
request_id,
};
let Ok(text) = serde_json::to_string(&message) else {
continue;
};
if write.send(Message::Text(text.into())).await.is_err() {
debug!("Account websocket heartbeat failed, ending the session");
break 'session wrote_successfully;
}
wrote_successfully = true;
}
frame = read.next() => {
let Some(message) = frame else {
debug!("Account websocket stream ended");
break 'session wrote_successfully;
};
let frame = match message {
Ok(frame) => frame,
Err(e) => {
error!("Account websocket read failed, ending the session: {e}");
break 'session wrote_successfully;
}
};
let data = match frame {
Message::Text(text) => text.as_bytes().to_vec(),
Message::Binary(bytes) => bytes.to_vec(),
Message::Close(_) => {
debug!("Account websocket closed by the venue");
break 'session wrote_successfully;
}
Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => continue,
};
let Some(event) = decode_account_frame(&data) else {
continue;
};
if let Some((id, accepted)) = settle(&mut pending, &event)
&& restoring.remove(&id)
{
if !accepted {
warn!("The venue refused a connect while restoring; ending the session");
break 'session wrote_successfully;
}
wrote_successfully = true;
if restoring.is_empty() {
*state.write().await = ConnectionState::Connected;
}
}
if events.send_async(event).await.is_err() {
debug!("Account event receiver dropped, ending the session");
break 'session wrote_successfully;
}
}
action = actions.recv_async() => {
let Ok(action) = action else {
debug!("Account action sender dropped, ending the session");
break 'session wrote_successfully;
};
let ack = action.ack;
let auth_token = match client.access_token().await {
Ok(token) => token.bearer(),
Err(e) => {
report(ack, Err(TastyTradeError::Auth(format!(
"the account stream has no usable access token: {e}"
))));
break 'session wrote_successfully;
}
};
let request_id = next_request_id;
next_request_id += 1;
let requested = action.action;
let message = SubRequest::<Box<dyn erased_serde::Serialize + Send + Sync>> {
auth_token,
action: action.action,
value: action.value,
request_id,
};
let text = match serde_json::to_string(&message) {
Ok(text) => text,
Err(e) => {
error!("Dropping an account action that could not be serialized: {e}");
report(ack, Err(TastyTradeError::Streaming(
"the action could not be serialized".to_string(),
)));
continue;
}
};
match write.send(Message::Text(text.into())).await {
Ok(()) => {
wrote_successfully = true;
pending.insert(request_id, PendingAction {
action: requested,
deadline: tokio::time::Instant::now() + ACTION_TIMEOUT,
ack,
});
}
Err(e) => {
debug!("Account websocket write failed: {e}");
report(ack, Err(TastyTradeError::Streaming(
"the account stream closed before the action was sent".to_string(),
)));
break 'session wrote_successfully;
}
}
}
}
}
};
abandon(
&mut pending,
"the account stream ended before the venue answered",
);
wrote_successfully
}
impl Drop for AccountStreamer {
fn drop(&mut self) {
if let Some(cancel) = self.cancel.take() {
let _ = cancel.send(());
}
}
}
fn subscribed_of(
set: &Arc<Mutex<BTreeSet<AccountNumber>>>,
) -> std::sync::MutexGuard<'_, BTreeSet<AccountNumber>> {
set.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn report(ack: Option<oneshot::Sender<TastyResult<()>>>, outcome: TastyResult<()>) {
if let Some(ack) = ack {
let _ = ack.send(outcome);
}
}
const OBSERVED_BUT_UNTYPED: [&str; 6] = [
"UserMessage",
"OrderChain",
"ExternalTransaction",
"ComplexOrder",
"TradingStatus",
"UnderlyingYearGainSummary",
];
fn decode_account_frame(data: &[u8]) -> Option<AccountEvent> {
let frame = match serde_json::from_slice::<serde_json::Value>(data) {
Ok(frame) => frame,
Err(e) => {
warn!(
"Skipping an unreadable account frame ({} bytes): {:?} error at line {}, column {}",
data.len(),
e.classify(),
e.line(),
e.column()
);
debug!("account frame decode error: {e}");
return None;
}
};
let kind = frame
.get("type")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
let action = frame
.get("action")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
if let Some(kind) = kind {
return Some(decode_notification(kind, &frame));
}
if let Some(status) = frame.get("status").and_then(serde_json::Value::as_str) {
let decoded = if status.eq_ignore_ascii_case("error") {
serde_json::from_value::<ErrorMessage>(frame.clone()).map(AccountEvent::ErrorMessage)
} else {
serde_json::from_value::<StatusMessage>(frame.clone()).map(AccountEvent::StatusMessage)
};
return Some(match decoded {
Ok(event) => event,
Err(e) => {
warn!(
"An account status frame did not match its shape ({} bytes, status {:?}): \
{:?} error at line {}, column {}",
data.len(),
status,
e.classify(),
e.line(),
e.column()
);
unknown_event(None, action, data)
}
});
}
debug!(
"An account frame carried neither a type nor a status ({} bytes)",
data.len()
);
Some(unknown_event(None, action, data))
}
fn decode_notification(kind: String, frame: &serde_json::Value) -> AccountEvent {
let timestamp = frame.get("timestamp").and_then(serde_json::Value::as_i64);
let data = frame
.get("data")
.cloned()
.unwrap_or(serde_json::Value::Null);
let payload = match kind.as_str() {
"Order" => typed(&kind, &data, NotificationPayload::Order),
"AccountBalance" => typed(&kind, &data, NotificationPayload::AccountBalance),
"CurrentPosition" => typed(&kind, &data, NotificationPayload::CurrentPosition),
"QuoteAlert" => typed(&kind, &data, NotificationPayload::QuoteAlert),
"PublicWatchlists" => typed(&kind, &data, NotificationPayload::PublicWatchlist),
other if OBSERVED_BUT_UNTYPED.contains(&other) => {
debug!("Delivering an untyped {other} notification without decoding its payload");
NotificationPayload::Unsupported(raw(&data))
}
other => {
debug!("Delivering an unrecognised {other} notification as an untyped payload");
NotificationPayload::Unsupported(raw(&data))
}
};
AccountEvent::Notification(Box::new(AccountNotification {
kind,
timestamp,
payload,
}))
}
fn typed<T, F>(kind: &str, data: &serde_json::Value, wrap: F) -> NotificationPayload
where
T: serde::de::DeserializeOwned,
F: FnOnce(Box<T>) -> NotificationPayload,
{
match serde_json::from_value::<T>(data.clone()) {
Ok(value) => wrap(Box::new(value)),
Err(e) => {
warn!(
"A {kind} notification did not match its model ({:?} error at line {}, column {}); \
delivering the payload untyped",
e.classify(),
e.line(),
e.column()
);
debug!("{kind} payload decode error: {e}");
NotificationPayload::Unsupported(raw(data))
}
}
}
fn raw(data: &serde_json::Value) -> RawPayload {
RawPayload::new(serde_json::to_string(data).unwrap_or_default())
}
fn unknown_event(kind: Option<String>, action: Option<String>, data: &[u8]) -> AccountEvent {
AccountEvent::Unknown(UnknownEvent {
kind,
action,
payload: RawPayload::new(String::from_utf8_lossy(data).into_owned()),
})
}
impl TastyTrade {
pub async fn create_account_streamer(&self) -> TastyResult<AccountStreamer> {
AccountStreamer::connect(self).await
}
}
#[cfg(test)]
mod restoration_tests {
use super::*;
const ACCOUNT_ONE: &str = "SENTINEL-5WX00042";
const ACCOUNT_TWO: &str = "SENTINEL-5WX00043";
const TOKEN: &str = "SENTINEL-access-token-5Nd9";
#[derive(Default)]
struct Recorder(Vec<String>);
impl futures_util::Sink<Message> for Recorder {
type Error = std::convert::Infallible;
fn poll_ready(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn start_send(
mut self: std::pin::Pin<&mut Self>,
item: Message,
) -> Result<(), Self::Error> {
if let Message::Text(text) = item {
self.0.push(text.to_string());
}
Ok(())
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_close(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Ok(()))
}
}
struct Broken;
impl futures_util::Sink<Message> for Broken {
type Error = std::io::Error;
fn poll_ready(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Err(std::io::Error::other("gone")))
}
fn start_send(self: std::pin::Pin<&mut Self>, _: Message) -> Result<(), Self::Error> {
Err(std::io::Error::other("gone"))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Err(std::io::Error::other("gone")))
}
fn poll_close(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
std::task::Poll::Ready(Err(std::io::Error::other("gone")))
}
}
fn accounts(numbers: &[&str]) -> Vec<AccountNumber> {
numbers
.iter()
.map(|number| AccountNumber(number.to_string()))
.collect()
}
#[tokio::test]
async fn every_subscribed_account_is_written_with_its_own_request_id() {
let mut sink = Recorder::default();
let mut next_request_id = 7;
let ids = write_restoration(
&mut sink,
&crate::oauth::AccessToken::new(TOKEN).bearer(),
&accounts(&[ACCOUNT_ONE, ACCOUNT_TWO]),
&mut next_request_id,
)
.await
.expect("a working socket accepts them");
assert_eq!(
ids,
vec![7, 8],
"each account gets its own id to be answered by"
);
assert_eq!(
next_request_id, 9,
"the counter moves on for the loop to use"
);
assert_eq!(sink.0.len(), 2);
for (frame, account) in sink.0.iter().zip([ACCOUNT_ONE, ACCOUNT_TWO]) {
assert!(frame.contains(r#""action":"connect""#), "{frame}");
assert!(frame.contains(account), "{frame}");
assert!(
frame.contains(&format!(r#""auth-token":"Bearer {TOKEN}""#)),
"{frame}"
);
}
}
#[tokio::test]
async fn nothing_is_written_when_nothing_was_subscribed() {
let mut sink = Recorder::default();
let mut next_request_id = 1;
let ids = write_restoration(&mut sink, "Bearer x", &[], &mut next_request_id)
.await
.expect("an empty restoration is a success");
assert!(ids.is_empty());
assert!(sink.0.is_empty());
assert_eq!(next_request_id, 1);
}
#[tokio::test]
async fn a_socket_that_refuses_the_restoration_reports_it() {
let mut sink = Broken;
let mut next_request_id = 1;
let error = write_restoration(
&mut sink,
"Bearer x",
&accounts(&[ACCOUNT_ONE]),
&mut next_request_id,
)
.await
.expect_err("a dead socket cannot restore anything");
assert!(matches!(error, TastyTradeError::Streaming(_)), "{error:?}");
assert!(!format!("{error}").contains(ACCOUNT_ONE), "{error}");
}
#[tokio::test]
async fn settling_reports_which_action_was_answered_and_how() {
let mut pending = HashMap::new();
pending.insert(
4,
PendingAction {
action: SubRequestAction::Connect,
deadline: tokio::time::Instant::now() + ACTION_TIMEOUT,
ack: None,
},
);
let accepted =
decode_account_frame(br#"{"status":"ok","action":"connect","request-id":4}"#)
.expect("valid JSON");
assert_eq!(settle(&mut pending, &accepted), Some((4, true)));
pending.insert(
5,
PendingAction {
action: SubRequestAction::Connect,
deadline: tokio::time::Instant::now() + ACTION_TIMEOUT,
ack: None,
},
);
let refused = decode_account_frame(
br#"{"status":"error","action":"connect","request-id":5,"message":"nope"}"#,
)
.expect("valid JSON");
assert_eq!(settle(&mut pending, &refused), Some((5, false)));
let heartbeat =
decode_account_frame(br#"{"status":"ok","action":"heartbeat","request-id":99}"#)
.expect("valid JSON");
assert_eq!(settle(&mut pending, &heartbeat), None);
}
}
#[cfg(test)]
mod acknowledgement_tests {
use super::*;
const ACCOUNT_NUMBER: &str = "SENTINEL-5WX00042";
fn waiting(action: SubRequestAction) -> (PendingAction, oneshot::Receiver<TastyResult<()>>) {
let (ack, answered) = oneshot::channel();
(
PendingAction {
action,
deadline: tokio::time::Instant::now() + ACTION_TIMEOUT,
ack: Some(ack),
},
answered,
)
}
fn frame(json: &str) -> AccountEvent {
decode_account_frame(json.as_bytes()).expect("valid JSON is an event")
}
#[tokio::test]
async fn a_matching_acknowledgement_resolves_its_own_action() {
let mut pending = HashMap::new();
let (connect, connected) = waiting(SubRequestAction::Connect);
let (alerts, alerted) = waiting(SubRequestAction::QuoteAlertsSubscribe);
pending.insert(1, connect);
pending.insert(2, alerts);
settle(
&mut pending,
&frame(r#"{"status":"ok","action":"connect","request-id":1}"#),
);
assert!(connected.await.expect("answered").is_ok());
assert_eq!(pending.len(), 1, "only the matching action is resolved");
assert!(
tokio::time::timeout(Duration::from_millis(20), alerted)
.await
.is_err(),
"the other action is still in flight"
);
}
#[tokio::test]
async fn a_refusal_reaches_the_caller_who_asked_for_it() {
let mut pending = HashMap::new();
let (connect, connected) = waiting(SubRequestAction::Connect);
pending.insert(7, connect);
settle(
&mut pending,
&frame(
r#"{"status":"error","action":"connect","request-id":7,
"message":"connect-not-completed"}"#,
),
);
let error = connected
.await
.expect("answered")
.expect_err("a refusal is not a success");
assert!(
format!("{error}").contains("connect-not-completed"),
"the venue's own words are what the caller acts on: {error}"
);
assert!(pending.is_empty());
}
#[tokio::test]
async fn an_answer_without_an_id_resolves_the_oldest_action_of_its_kind() {
let mut pending = HashMap::new();
let (first, first_answered) = waiting(SubRequestAction::Connect);
let mut second = waiting(SubRequestAction::Connect);
second.0.deadline += Duration::from_secs(1);
pending.insert(1, first);
pending.insert(2, second.0);
settle(
&mut pending,
&frame(r#"{"status":"ok","action":"connect"}"#),
);
assert!(first_answered.await.expect("answered").is_ok());
assert_eq!(pending.len(), 1);
assert!(pending.contains_key(&2), "the younger one still waits");
}
#[tokio::test]
async fn an_acknowledgement_for_nothing_disturbs_nothing() {
let mut pending = HashMap::new();
let (connect, connected) = waiting(SubRequestAction::Connect);
pending.insert(1, connect);
settle(
&mut pending,
&frame(r#"{"status":"ok","action":"heartbeat","request-id":99}"#),
);
assert_eq!(pending.len(), 1, "nothing was resolved");
assert!(
tokio::time::timeout(Duration::from_millis(20), connected)
.await
.is_err(),
"the connect is untouched"
);
}
#[tokio::test]
async fn a_notification_never_resolves_an_action() {
let mut pending = HashMap::new();
let (connect, _connected) = waiting(SubRequestAction::Connect);
pending.insert(1, connect);
settle(
&mut pending,
&frame(&format!(
r#"{{"type":"Order","data":{{"account-number":"{ACCOUNT_NUMBER}"}}}}"#
)),
);
assert_eq!(pending.len(), 1);
}
#[tokio::test]
async fn an_action_the_venue_never_answers_times_out() {
let mut pending = HashMap::new();
let (mut connect, connected) = waiting(SubRequestAction::Connect);
connect.deadline = tokio::time::Instant::now();
pending.insert(1, connect);
let now = tokio::time::Instant::now();
let expired: Vec<u64> = pending
.iter()
.filter(|(_, waiting)| waiting.deadline <= now)
.map(|(id, _)| *id)
.collect();
for id in expired {
if let Some(waiting) = pending.remove(&id) {
report(
waiting.ack,
Err(TastyTradeError::Streaming(format!(
"the venue did not acknowledge the {} within {ACTION_TIMEOUT:?}",
waiting.action
))),
);
}
}
let error = connected
.await
.expect("answered")
.expect_err("an unanswered action is not a success");
assert!(
format!("{error}").contains("did not acknowledge"),
"{error}"
);
assert!(pending.is_empty());
}
#[tokio::test]
async fn ending_a_session_fails_everything_still_waiting() {
let mut pending = HashMap::new();
let (connect, connected) = waiting(SubRequestAction::Connect);
pending.insert(1, connect);
abandon(&mut pending, "the account stream dropped");
let error = connected
.await
.expect("answered")
.expect_err("a dropped stream cannot have accepted it");
assert!(format!("{error}").contains("dropped"), "{error}");
assert!(pending.is_empty());
}
}
#[cfg(test)]
mod credential_tests {
use super::*;
use crate::accounts::AccountNumber;
#[test]
fn formatting_a_subscription_request_never_shows_the_token() {
const TOKEN: &str = "SENTINEL-access-token-5Nd9";
let message = SubRequest::<Vec<AccountNumber>> {
auth_token: crate::oauth::AccessToken::new(TOKEN).bearer(),
action: SubRequestAction::Connect,
value: Some(vec![AccountNumber("SENTINEL-5WX00042".to_string())]),
request_id: 1,
};
let sent = serde_json::to_string(&message).expect("the request serializes");
assert!(
sent.contains(&format!(r#""auth-token":"Bearer {TOKEN}""#)),
"the prefix is part of the credential: {sent}"
);
let rendered = format!("{message:?}");
assert!(
!rendered.contains(TOKEN),
"the token reached Debug: {rendered}"
);
assert!(rendered.contains("***"), "{rendered}");
assert!(
rendered.contains("Connect"),
"the action is safe: {rendered}"
);
}
}
#[cfg(test)]
mod frame_privacy_tests {
use super::*;
use crate::utils::log_capture::capture_at;
use tracing::Level;
const ACCOUNT_NUMBER: &str = "SENTINEL-5WX00042";
fn decode_capturing(data: &[u8], level: Level) -> (Option<AccountEvent>, String) {
capture_at(level, || decode_account_frame(data))
}
#[test]
fn a_frame_that_does_not_match_its_model_never_logs_its_contents_at_warn() {
let frame = format!(
r#"{{"type":"Order","data":{{"account-number":"{ACCOUNT_NUMBER}","status":12345}}}}"#
);
let (event, logs) = decode_capturing(frame.as_bytes(), Level::WARN);
let Some(AccountEvent::Notification(notification)) = event else {
panic!("a typed frame that does not decode is still a notification");
};
assert_eq!(notification.kind, "Order");
let NotificationPayload::Unsupported(payload) = ¬ification.payload else {
panic!("the payload could not be modelled, so it travels untyped");
};
assert!(
payload.expose().contains(ACCOUNT_NUMBER),
"the payload must reach the caller intact"
);
assert!(
!logs.contains(ACCOUNT_NUMBER),
"the account number reached the logs:\n{logs}"
);
assert!(
logs.contains("error at line"),
"the failure must still be diagnosable:\n{logs}"
);
}
#[test]
fn a_raw_payload_does_not_render_itself() {
let payload = RawPayload::new(format!(r#"{{"account-number":"{ACCOUNT_NUMBER}"}}"#));
for rendered in [format!("{payload:?}"), format!("{payload}")] {
assert!(!rendered.contains(ACCOUNT_NUMBER), "{rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
assert!(rendered.contains(&payload.len().to_string()), "{rendered}");
}
assert!(payload.expose().contains(ACCOUNT_NUMBER));
assert!(!payload.is_empty());
}
#[test]
fn the_detail_is_available_one_level_down() {
let frame = br#"{ not json at all"#;
let (event, logs) = decode_capturing(frame, Level::DEBUG);
assert!(event.is_none(), "bytes that are not JSON are not an event");
assert!(
logs.contains("decode error"),
"DEBUG keeps the full error:\n{logs}"
);
}
}
#[cfg(test)]
mod frame_routing_tests {
use super::*;
const ACCOUNT_NUMBER: &str = "SENTINEL-5WX00042";
fn decode(frame: &str) -> AccountEvent {
decode_account_frame(frame.as_bytes()).expect("valid JSON is always an event")
}
mod fixture {
pub const ORDER_FILLED: &str =
include_str!("../../Doc/frames/account/order.documented.json");
pub const ORDER_MARKET: &str =
include_str!("../../Doc/frames/account/order-market.documented.json");
pub const ACCOUNT_BALANCE: &str =
include_str!("../../Doc/frames/account/account-balance.derived.json");
pub const CURRENT_POSITION: &str =
include_str!("../../Doc/frames/account/current-position.derived.json");
pub const QUOTE_ALERT: &str =
include_str!("../../Doc/frames/account/quote-alert.derived.json");
pub const PUBLIC_WATCHLISTS: &str =
include_str!("../../Doc/frames/account/public-watchlists.derived.json");
pub const STATUS_CONNECT: &str =
include_str!("../../Doc/frames/account/status-connect.documented.json");
pub const ERROR_CONNECT: &str =
include_str!("../../Doc/frames/account/error-connect.documented.json");
pub const NOTIFICATIONS: [(&str, &str); 6] = [
("Order", ORDER_FILLED),
("Order", ORDER_MARKET),
("AccountBalance", ACCOUNT_BALANCE),
("CurrentPosition", CURRENT_POSITION),
("QuoteAlert", QUOTE_ALERT),
("PublicWatchlists", PUBLIC_WATCHLISTS),
];
}
#[test]
fn every_notification_fixture_decodes_as_its_own_type() {
for (kind, frame) in fixture::NOTIFICATIONS {
let AccountEvent::Notification(notification) = decode(frame) else {
panic!("{kind} fixture must decode as a notification");
};
assert_eq!(notification.kind, kind);
assert!(
!matches!(notification.payload, NotificationPayload::Unsupported(_)),
"the {kind} fixture no longer matches its model — reconcile the type \
or the fixture, do not delete the assertion"
);
}
}
#[test]
fn the_documented_order_notification_decodes_with_its_fills() {
let AccountEvent::Notification(notification) = decode(fixture::ORDER_FILLED) else {
panic!("a documented order notification must be a notification");
};
assert_eq!(notification.kind, "Order");
assert_eq!(notification.timestamp, Some(1_688_595_114_405));
let NotificationPayload::Order(order) = notification.payload else {
panic!("the Order payload must be typed");
};
assert_eq!(order.legs.len(), 1, "the legs used to be discarded");
let fill = &order.legs[0].fills[0];
assert_eq!(
fill.fill_price,
Some(rust_decimal::Decimal::new(1000, 1)),
"the fill price is the whole point of the frame"
);
assert_eq!(fill.destination_venue.as_deref(), Some("TEST_A"));
assert!(fill.filled_at.is_some());
assert_eq!(order.updated_at.as_deref(), Some("1688584052750"));
assert!(order.received_at.is_some());
assert!(order.reject_reason.is_none());
}
#[test]
fn a_market_order_notification_without_a_price_decodes() {
let AccountEvent::Notification(notification) = decode(fixture::ORDER_MARKET) else {
panic!("a market order is a notification");
};
let NotificationPayload::Order(order) = notification.payload else {
panic!("a market order must be typed, not delivered raw");
};
assert!(order.price.is_none(), "a market order has no price");
assert!(order.price_effect.is_none());
assert_eq!(order.user_id.as_deref(), Some("99"));
assert_eq!(order.leg_count.as_deref(), Some("1"));
}
#[test]
fn a_connect_acknowledgement_without_a_request_id_is_delivered() {
let AccountEvent::StatusMessage(status) = decode(fixture::STATUS_CONNECT) else {
panic!("an acknowledgement must reach the caller");
};
assert_eq!(status.action, "connect");
assert_eq!(status.status, "ok");
assert_eq!(status.request_id, None);
assert_eq!(
status.value.map(|accounts| accounts[0].0.clone()),
Some(ACCOUNT_NUMBER.to_string()),
"connect echoes what it subscribed"
);
}
#[test]
fn a_refusal_is_an_error_message() {
let AccountEvent::ErrorMessage(error) = decode(fixture::ERROR_CONNECT) else {
panic!("a refusal must be an error message");
};
assert_eq!(error.message, "connect-not-completed");
}
#[test]
fn an_unrecognised_type_arrives_as_an_untyped_payload() {
let frame = format!(
r#"{{"type":"SomethingNew","data":{{"account-number":"{ACCOUNT_NUMBER}"}},
"timestamp":1}}"#
);
let AccountEvent::Notification(notification) = decode(&frame) else {
panic!("an unrecognised type is still a notification");
};
assert_eq!(notification.kind, "SomethingNew");
let NotificationPayload::Unsupported(payload) = ¬ification.payload else {
panic!("there is no model for it, so it travels untyped");
};
assert!(payload.expose().contains(ACCOUNT_NUMBER));
}
#[test]
fn an_observed_but_untyped_notification_keeps_its_payload() {
for kind in OBSERVED_BUT_UNTYPED {
let frame =
format!(r#"{{"type":"{kind}","data":{{"account-number":"{ACCOUNT_NUMBER}"}}}}"#);
let AccountEvent::Notification(notification) = decode(&frame) else {
panic!("{kind} must still be a notification");
};
assert_eq!(notification.kind, kind);
let NotificationPayload::Unsupported(payload) = ¬ification.payload else {
panic!("{kind} has no captured frame, so it must not claim a type");
};
assert!(
payload.expose().contains(ACCOUNT_NUMBER),
"{kind} discarded its payload"
);
}
}
#[test]
fn a_frame_that_is_neither_reaches_the_caller_as_unknown() {
let AccountEvent::Unknown(unknown) = decode(r#"{"something":"else"}"#) else {
panic!("an unplaceable frame must still be delivered");
};
assert_eq!(unknown.kind, None);
assert!(unknown.payload.expose().contains("something"));
}
#[test]
fn a_quote_alert_and_a_public_watchlist_are_typed() {
let AccountEvent::Notification(alert) = decode(fixture::QUOTE_ALERT) else {
panic!("a quote alert is a notification");
};
assert!(matches!(alert.payload, NotificationPayload::QuoteAlert(_)));
let AccountEvent::Notification(watchlist) = decode(fixture::PUBLIC_WATCHLISTS) else {
panic!("a watchlist is a notification");
};
let NotificationPayload::PublicWatchlist(list) = watchlist.payload else {
panic!("the watchlist payload must be typed");
};
assert_eq!(list.watchlist_entries.len(), 2);
}
#[test]
fn a_typed_frame_without_a_payload_is_still_delivered() {
let AccountEvent::Notification(notification) = decode(r#"{"type":"OrderChain"}"#) else {
panic!("a bare type is still a notification");
};
assert_eq!(notification.kind, "OrderChain");
assert!(matches!(
notification.payload,
NotificationPayload::Unsupported(_)
));
}
}