use std::fmt::Display;
use std::sync::Arc;
use crate::accounts::AccountDetails;
use crate::accounts::{Account, AccountInner, AccountNumber};
use crate::api::base::Items;
use crate::api::base::Paginated;
use crate::api::base::Response;
use crate::api::base::TastyApiResponse;
use crate::api::base::TastyResult;
use crate::api::oauth::{OAuthSession, authorization_code_grant, default_headers, refresh_grant};
use crate::api::query::{PageRequest, QueryBuilder};
use crate::api::url::encode_path_segment;
use crate::error::{ApiError, InnerApiError};
use crate::streaming::quote_streamer::QuoteStreamer;
use crate::types::customer::Customer;
use crate::types::margin::{MarginConfiguration, SpanExchange, SpanRow};
use crate::types::market_data::{MarketDataRequest, MarketDataSnapshot};
use crate::types::market_metrics::{
DividendReport, EarningsRange, EarningsReport, MarketMetric, symbols_query,
};
use crate::types::oauth::{AccessToken, AuthorizationCode, RefreshToken};
use crate::types::order::LiveOrderRecord;
use crate::types::order_filter::{CustomerLiveOrderFilter, CustomerOrderFilter};
use crate::types::quote_alert::{NewQuoteAlert, QuoteAlert};
use crate::types::watchlist::{NewWatchlist, PairsWatchlist, Watchlist};
use crate::utils::config::TastyTradeConfig;
use chrono::NaiveDate;
use reqwest::ClientBuilder;
use reqwest::header;
use reqwest::header::HeaderValue;
use serde::Serialize;
use serde::de::DeserializeOwned;
use tracing::debug;
#[derive(Clone)]
pub struct TastyTrade {
pub(crate) client: reqwest::Client,
pub(crate) session: Arc<OAuthSession>,
pub(crate) config: TastyTradeConfig,
}
impl std::fmt::Debug for TastyTrade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TastyTrade")
.field("session", &self.session)
.field("config", &self.config)
.finish()
}
}
impl Display for TastyTrade {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "TastyTrade")
}
}
fn redact_account_path(url: &str) -> String {
let (path, query) = match url.find('?') {
Some(at) => (&url[..at], Some(&url[at + 1..])),
None => (url, None),
};
let mut out = redact_path_identifiers(path);
if let Some(query) = query {
out.push('?');
out.push_str(&redact_query_identifiers(query));
}
out
}
fn redact_path_identifiers(path: &str) -> String {
enum Next {
Nothing,
Account,
Customer,
}
let mut out = String::with_capacity(path.len());
let mut next = Next::Nothing;
for (index, segment) in path.split('/').enumerate() {
if index > 0 {
out.push('/');
}
let (identifier, tail) = match segment.find('#') {
Some(at) => segment.split_at(at),
None => (segment, ""),
};
match next {
Next::Account if !identifier.is_empty() => {
out.push_str("{account}");
out.push_str(tail);
next = Next::Nothing;
continue;
}
Next::Customer if !identifier.is_empty() && identifier != "me" => {
out.push_str("{customer}");
out.push_str(tail);
next = Next::Nothing;
continue;
}
_ => {}
}
out.push_str(segment);
next = match identifier {
"accounts" => Next::Account,
"customers" => Next::Customer,
_ => Next::Nothing,
};
}
out
}
fn redact_query_identifiers(query: &str) -> String {
query
.split('&')
.map(|pair| match pair.split_once('=') {
Some((key, _)) if crate::types::wire::names_an_account(key) => {
format!("{key}={{account}}")
}
_ => pair.to_string(),
})
.collect::<Vec<_>>()
.join("&")
}
fn endpoint_url(base_url: &str, path: &str) -> TastyResult<String> {
let lowered = path.trim_start().to_ascii_lowercase();
if lowered.starts_with("http://") || lowered.starts_with("https://") {
return Err(crate::TastyTradeError::Precondition(format!(
"expected a path such as \"/accounts\", got an absolute URL; \
the base URL comes from the configuration and decides which \
deployment the request reaches (redacted path: {})",
redact_account_path(path)
)));
}
if let Some(segment) = path
.split(['?', '#'])
.next()
.unwrap_or(path)
.split('/')
.find(|segment| is_dot_segment(segment))
{
return Err(crate::TastyTradeError::Precondition(format!(
"path segment {segment:?} is a relative-reference marker rather than a value; \
URL resolution removes it before the request is sent, so the request would \
reach a different endpoint than the one asked for (redacted path: {})",
redact_account_path(path)
)));
}
Ok(format!("{base_url}{path}"))
}
fn is_dot_segment(segment: &str) -> bool {
if segment.len() > 6 {
return false;
}
let decoded = segment.replace("%2e", ".").replace("%2E", ".");
decoded == "." || decoded == ".."
}
async fn decode_raw_response<R>(
report: &RequestReport,
response: reqwest::Response,
) -> TastyResult<R>
where
R: DeserializeOwned,
{
let status = response.status();
let body = match response.text().await {
Ok(body) => body,
Err(e) => {
debug!(
"{} {}: reading the body failed after {}: {}",
report.method,
report.operation,
status.as_u16(),
e.without_url()
);
return Err(crate::TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: None,
});
}
};
if !status.is_success() {
let parsed = serde_json::from_str::<TastyApiResponse<serde_json::Value>>(&body);
debug!(
"{} {} -> {} ({} bytes in {:?})",
report.method,
report.operation,
status.as_u16(),
body.len(),
report.started.elapsed()
);
return Err(crate::TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: match parsed {
Ok(TastyApiResponse::Error { error }) => Some(sanitize_api_error(error)),
_ => None,
},
});
}
debug!(
"{} {} -> {} ({} bytes in {:?})",
report.method,
report.operation,
status.as_u16(),
body.len(),
report.started.elapsed()
);
let body = if body.trim().is_empty() {
"null"
} else {
&body
};
serde_json::from_str::<R>(body).map_err(|e| {
debug!(
"{} {}: the body did not match the expected shape: {:?} at line {}, column {}",
report.method,
report.operation,
e.classify(),
e.line(),
e.column()
);
crate::TastyTradeError::Unknown(format!(
"{} {} answered with a body this crate could not decode ({:?} at line {}, \
column {}); raise the log level for details",
report.method,
report.operation,
e.classify(),
e.line(),
e.column()
))
})
}
pub(crate) struct RequestReport {
method: &'static str,
operation: String,
environment: crate::error::Environment,
started: std::time::Instant,
}
impl RequestReport {
pub(crate) fn new(
method: &'static str,
operation: String,
environment: crate::error::Environment,
) -> Self {
Self {
method,
operation,
environment,
started: std::time::Instant::now(),
}
}
pub(crate) fn context(&self, status: Option<u16>) -> crate::error::RequestContext {
crate::error::RequestContext {
method: self.method,
operation: self.operation.clone(),
environment: self.environment,
status,
}
}
pub(crate) fn elapsed(&self) -> std::time::Duration {
self.started.elapsed()
}
}
async fn decode_response<T, R>(
report: &RequestReport,
response: reqwest::Response,
) -> TastyResult<R>
where
T: DeserializeOwned + Serialize + std::fmt::Debug,
R: FromTastyResponse<T>,
{
let status = response.status();
let body = match response.text().await {
Ok(body) => body,
Err(e) => {
debug!(
"{} {}: reading the body failed after {}: {}",
report.method,
report.operation,
status.as_u16(),
e.without_url()
);
return Err(crate::TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: None,
});
}
};
if !status.is_success() {
let parsed = serde_json::from_str::<TastyApiResponse<serde_json::Value>>(&body);
debug!(
"{} {} -> {} ({} bytes in {:?}, {})",
report.method,
report.operation,
status.as_u16(),
body.len(),
report.started.elapsed(),
match &parsed {
Ok(TastyApiResponse::Error { .. }) => "broker error document",
Ok(TastyApiResponse::Success(_)) => "success envelope on a failure status",
Err(_) => "unrecognised body",
}
);
return Err(crate::TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: match parsed {
Ok(TastyApiResponse::Error { error }) => Some(sanitize_api_error(error)),
_ => None,
},
});
}
debug!(
"{} {} -> {} ({} bytes in {:?})",
report.method,
report.operation,
status.as_u16(),
body.len(),
report.started.elapsed()
);
match serde_json::from_str::<TastyApiResponse<T>>(&body) {
Ok(TastyApiResponse::Success(s)) => R::from_tasty(s),
Ok(TastyApiResponse::Error { error }) => Err(crate::TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: Some(sanitize_api_error(error)),
}),
Err(e) => {
debug!(
"{} {}: decode failed ({:?} at line {}, column {})",
report.method,
report.operation,
e.classify(),
e.line(),
e.column()
);
Err(crate::TastyTradeError::Request {
context: report.context(Some(status.as_u16())),
api: None,
})
}
}
}
pub(crate) fn transport_failure(
report: &RequestReport,
error: reqwest::Error,
) -> crate::TastyTradeError {
debug!(
"transport failure on {} {}: {}",
report.method,
report.operation,
error.without_url()
);
crate::TastyTradeError::Request {
context: report.context(None),
api: None,
}
}
fn sanitize_api_error(error: ApiError) -> ApiError {
ApiError {
code: error.code,
message: error.message,
errors: error.errors.map(|inner| {
inner
.into_iter()
.map(|entry| InnerApiError {
code: entry.code,
message: "<redacted: enable DEBUG on the caller side>".to_string(),
})
.collect()
}),
}
}
pub trait FromTastyResponse<T: DeserializeOwned + Serialize + std::fmt::Debug>: Sized {
fn from_tasty(resp: Response<T>) -> TastyResult<Self>;
}
impl<T: DeserializeOwned + Serialize + std::fmt::Debug> FromTastyResponse<T> for T {
fn from_tasty(resp: Response<T>) -> TastyResult<Self> {
Ok(resp.data)
}
}
impl<T: DeserializeOwned + Serialize + std::fmt::Debug> FromTastyResponse<Items<T>>
for Paginated<T>
{
fn from_tasty(resp: Response<Items<T>>) -> TastyResult<Self> {
let Some(pagination) = resp.pagination else {
return Err(crate::TastyTradeError::Unknown(format!(
"response for {} carried no pagination block; \
request this endpoint as a plain listing instead",
redact_account_path(&resp.context)
)));
};
debug!(
"paginated page: {} items decoded, {} in page, {} total, offset {}",
resp.data.items.len(),
pagination.current_item_count,
pagination.total_items,
pagination.page_offset
);
Ok(Paginated {
items: resp.data.into_items()?,
pagination,
})
}
}
impl TastyTrade {
pub async fn from_env() -> TastyResult<Self> {
let config = TastyTradeConfig::from_env();
Self::connect(&config).await
}
pub async fn connect(config: &TastyTradeConfig) -> TastyResult<Self> {
if !config.has_valid_credentials() {
return Err(crate::TastyTradeError::ConfigError(
"Missing tastytrade OAuth credentials: set TASTYTRADE_CLIENT_SECRET and \
TASTYTRADE_REFRESH_TOKEN, or load a configuration file that provides both. \
Create them under Manage > My Profile > API on my.tastytrade.com"
.to_string(),
));
}
Self::establish(
config,
refresh_grant(config.client_secret.clone(), config.refresh_token.clone()),
)
.await
}
pub async fn connect_with_authorization_code(
config: &TastyTradeConfig,
code: impl Into<AuthorizationCode>,
) -> TastyResult<Self> {
if config.client_id.trim().is_empty()
|| config.client_secret.is_blank()
|| config.redirect_uri.trim().is_empty()
{
return Err(crate::TastyTradeError::ConfigError(
"the authorization-code grant needs TASTYTRADE_CLIENT_ID, \
TASTYTRADE_CLIENT_SECRET and TASTYTRADE_REDIRECT_URI, and the redirect URI must \
be the same one the authorization request used"
.to_string(),
));
}
Self::establish(
config,
authorization_code_grant(
code.into(),
config.client_id.clone(),
config.client_secret.clone(),
config.redirect_uri.clone(),
),
)
.await
}
async fn establish(
config: &TastyTradeConfig,
grant: crate::oauth::OAuthGrant,
) -> TastyResult<Self> {
let client = ClientBuilder::new()
.default_headers(default_headers())
.build()
.map_err(|e| {
crate::TastyTradeError::Connection(format!("could not build the HTTP client: {e}"))
})?;
let session = OAuthSession::establish(
client.clone(),
&config.base_url,
config.environment(),
grant,
)
.await?;
debug!("Authenticated against {}", config.environment());
Ok(Self {
client,
session,
config: config.clone(),
})
}
pub fn session(&self) -> &Arc<OAuthSession> {
&self.session
}
pub async fn refresh_token(&self) -> Option<RefreshToken> {
self.session.refresh_token().await
}
pub async fn access_token(&self) -> TastyResult<AccessToken> {
self.session.ensure_same_deployment(&self.config.base_url)?;
self.session.access_token().await
}
async fn authorization(&self) -> TastyResult<HeaderValue> {
let token = self.access_token().await?;
HeaderValue::from_str(&token.bearer()).map_err(|_| {
crate::TastyTradeError::Auth(
"the access token returned by the venue is not a valid header value".to_string(),
)
})
}
fn request_url(&self, path: &str) -> TastyResult<String> {
self.url_at(&self.config.base_url, path)
}
pub(crate) fn url_at(&self, base: &str, path: &str) -> TastyResult<String> {
self.session.ensure_same_deployment(&self.config.base_url)?;
endpoint_url(base, path)
}
pub async fn get_with_query<T, R, U>(&self, url: U, query: &[(&str, &str)]) -> TastyResult<R>
where
T: DeserializeOwned + Serialize + std::fmt::Debug,
R: FromTastyResponse<T>,
U: AsRef<str>,
{
let base = self.config.base_url.clone();
self.get_with_query_at(&base, url, query).await
}
pub(crate) async fn get_with_query_at<T, R, U>(
&self,
base: &str,
url: U,
query: &[(&str, &str)],
) -> TastyResult<R>
where
T: DeserializeOwned + Serialize + std::fmt::Debug,
R: FromTastyResponse<T>,
U: AsRef<str>,
{
let full_url = self.url_at(base, url.as_ref())?;
let query_string = query
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let full_request = if query_string.is_empty() {
full_url.clone()
} else {
format!("{}?{}", full_url, query_string)
};
let report = RequestReport::new(
"GET",
redact_account_path(&full_request),
self.config.environment(),
);
let authorization = self.authorization().await?;
let response: reqwest::Response = if query.is_empty() {
self.client
.get(&full_url)
.header(header::AUTHORIZATION, authorization)
.send()
.await
.map_err(|e| transport_failure(&report, e))?
} else {
let mut url_with_query = reqwest::Url::parse(&full_url).map_err(|e| {
crate::TastyTradeError::Unknown(format!("Failed to parse URL: {}", e))
})?;
{
let mut query_pairs = url_with_query.query_pairs_mut();
for (k, v) in query {
query_pairs.append_pair(k, v);
}
}
self.client
.get(url_with_query)
.header(header::AUTHORIZATION, authorization)
.send()
.await
.map_err(|e| transport_failure(&report, e))?
};
decode_response::<T, R>(&report, response).await
}
pub(crate) async fn get_raw_at<R, U>(&self, base: &str, url: U) -> TastyResult<R>
where
R: DeserializeOwned,
U: AsRef<str>,
{
let full_url = self.url_at(base, url.as_ref())?;
let report = RequestReport::new(
"GET",
redact_account_path(&full_url),
self.config.environment(),
);
let authorization = self.authorization().await?;
let response = self
.client
.get(&full_url)
.header(header::AUTHORIZATION, authorization)
.send()
.await
.map_err(|e| transport_failure(&report, e))?;
decode_raw_response::<R>(&report, response).await
}
pub(crate) async fn post_raw_at<R, P, U>(
&self,
base: &str,
url: U,
payload: P,
) -> TastyResult<R>
where
R: DeserializeOwned,
P: Serialize,
U: AsRef<str>,
{
let full_url = self.url_at(base, url.as_ref())?;
let report = RequestReport::new(
"POST",
redact_account_path(&full_url),
self.config.environment(),
);
let authorization = self.authorization().await?;
let response = self
.client
.post(&full_url)
.header(header::AUTHORIZATION, authorization)
.header(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)
.body(serde_json::to_string(&payload)?)
.send()
.await
.map_err(|e| transport_failure(&report, e))?;
decode_raw_response::<R>(&report, response).await
}
pub async fn get<T: DeserializeOwned + Serialize + std::fmt::Debug, U: AsRef<str>>(
&self,
url: U,
) -> TastyResult<T> {
self.get_with_query(url, &[]).await
}
pub async fn post<R, P, U>(&self, url: U, payload: P) -> TastyResult<R>
where
R: DeserializeOwned + Serialize + std::fmt::Debug,
P: Serialize,
U: AsRef<str>,
{
let base = self.config.base_url.clone();
self.post_at(&base, url, payload).await
}
pub(crate) async fn post_at<R, P, U>(&self, base: &str, url: U, payload: P) -> TastyResult<R>
where
R: DeserializeOwned + Serialize + std::fmt::Debug,
P: Serialize,
U: AsRef<str>,
{
let full_url = self.url_at(base, url.as_ref())?;
let report = RequestReport::new(
"POST",
redact_account_path(&full_url),
self.config.environment(),
);
let authorization = self.authorization().await?;
let response = self
.client
.post(&full_url)
.header(header::AUTHORIZATION, authorization)
.header(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)
.body(serde_json::to_string(&payload)?)
.send()
.await
.map_err(|e| transport_failure(&report, e))?;
decode_response::<R, R>(&report, response).await
}
pub async fn put<R, P, U>(&self, url: U, payload: P) -> TastyResult<R>
where
R: DeserializeOwned + Serialize + std::fmt::Debug,
P: Serialize,
U: AsRef<str>,
{
self.mutate("PUT", url, payload, reqwest::Method::PUT).await
}
pub async fn patch<R, P, U>(&self, url: U, payload: P) -> TastyResult<R>
where
R: DeserializeOwned + Serialize + std::fmt::Debug,
P: Serialize,
U: AsRef<str>,
{
self.mutate("PATCH", url, payload, reqwest::Method::PATCH)
.await
}
async fn mutate<R, P, U>(
&self,
method: &'static str,
url: U,
payload: P,
verb: reqwest::Method,
) -> TastyResult<R>
where
R: DeserializeOwned + Serialize + std::fmt::Debug,
P: Serialize,
U: AsRef<str>,
{
let full_url = self.request_url(url.as_ref())?;
let report = RequestReport::new(
method,
redact_account_path(&full_url),
self.config.environment(),
);
let authorization = self.authorization().await?;
let response = self
.client
.request(verb, &full_url)
.header(header::AUTHORIZATION, authorization)
.header(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
)
.body(serde_json::to_string(&payload)?)
.send()
.await
.map_err(|e| transport_failure(&report, e))?;
decode_response::<R, R>(&report, response).await
}
pub async fn delete<R, U>(&self, url: U) -> TastyResult<R>
where
R: DeserializeOwned + Serialize + std::fmt::Debug,
U: AsRef<str>,
{
let full_url = self.request_url(url.as_ref())?;
let report = RequestReport::new(
"DELETE",
redact_account_path(&full_url),
self.config.environment(),
);
let authorization = self.authorization().await?;
let response = self
.client
.delete(&full_url)
.header(header::AUTHORIZATION, authorization)
.send()
.await
.map_err(|e| transport_failure(&report, e))?;
decode_response::<R, R>(&report, response).await
}
pub(crate) async fn delete_no_content<U: AsRef<str>>(&self, url: U) -> TastyResult<()> {
let full_url = self.request_url(url.as_ref())?;
let report = RequestReport::new(
"DELETE",
redact_account_path(&full_url),
self.config.environment(),
);
let authorization = self.authorization().await?;
let response = self
.client
.delete(&full_url)
.header(header::AUTHORIZATION, authorization)
.send()
.await
.map_err(|e| transport_failure(&report, e))?;
if response.status().is_success() {
debug!(
"{} {} -> {} in {:?}",
report.method,
report.operation,
response.status().as_u16(),
report.started.elapsed()
);
return Ok(());
}
decode_response::<serde_json::Value, serde_json::Value>(&report, response)
.await
.map(|_| ())
}
pub async fn accounts(&self) -> TastyResult<Vec<Account<'_>>> {
let resp: Items<AccountInner> = self.get("/customers/me/accounts").await?;
Ok(resp
.into_items()?
.into_iter()
.map(|inner| Account { inner, tasty: self })
.collect())
}
pub async fn account(
&self,
account_number: impl Into<AccountNumber>,
) -> TastyResult<Option<Account<'_>>> {
self.account_by_number(account_number).await
}
pub async fn account_by_number(
&self,
account_number: impl Into<AccountNumber>,
) -> TastyResult<Option<Account<'_>>> {
let account_number = account_number.into();
let path = format!(
"/customers/me/accounts/{}",
encode_path_segment(&account_number.0)
);
match self.get::<AccountDetails, _>(&path).await {
Ok(account) => Ok(Some(Account {
inner: AccountInner {
account,
authority_level: None,
},
tasty: self,
})),
Err(crate::TastyTradeError::Request { context, .. }) if context.status == Some(404) => {
Ok(None)
}
Err(other) => Err(other),
}
}
pub async fn margin_requirements_configuration(&self) -> TastyResult<MarginConfiguration> {
self.get("/margin-requirements-public-configuration").await
}
pub async fn market_data_by_type(
&self,
request: &MarketDataRequest,
) -> TastyResult<Vec<MarketDataSnapshot>> {
request.validate()?;
let query = request.to_query();
let resp: Items<MarketDataSnapshot> = self
.get_with_query("/market-data/by-type", &query.pairs())
.await?;
resp.into_items()
}
pub async fn market_metrics(
&self,
symbols: &[impl AsRef<str>],
) -> TastyResult<Vec<MarketMetric>> {
let query = symbols_query(symbols)?;
let resp: Items<MarketMetric> = self
.get_with_query("/market-metrics", &query.pairs())
.await?;
resp.into_items()
}
pub async fn historic_dividends(&self, symbol: &str) -> TastyResult<Vec<DividendReport>> {
let resp: Items<DividendReport> = self
.get(format!(
"/market-metrics/historic-corporate-events/dividends/{}",
encode_path_segment(symbol)
))
.await?;
resp.into_items()
}
pub async fn historic_earnings(
&self,
symbol: &str,
range: &EarningsRange,
) -> TastyResult<Vec<EarningsReport>> {
let query = range.to_query();
let resp: Items<EarningsReport> = self
.get_with_query(
format!(
"/market-metrics/historic-corporate-events/earnings-reports/{}",
encode_path_segment(symbol)
),
&query.pairs(),
)
.await?;
resp.into_items()
}
pub async fn public_watchlists(&self, counts_only: bool) -> TastyResult<Vec<Watchlist>> {
let mut query = QueryBuilder::new();
if counts_only {
query.push_flag("counts-only", Some(true));
}
let resp: Items<Watchlist> = self
.get_with_query("/public-watchlists", &query.pairs())
.await?;
resp.into_items()
}
pub async fn public_watchlist_counts(&self) -> TastyResult<Vec<Watchlist>> {
self.public_watchlists(true).await
}
pub async fn public_watchlist(&self, name: &str) -> TastyResult<Watchlist> {
self.get(format!("/public-watchlists/{}", encode_path_segment(name)))
.await
}
pub async fn watchlists(&self) -> TastyResult<Vec<Watchlist>> {
let resp: Items<Watchlist> = self.get("/watchlists").await?;
resp.into_items()
}
pub async fn watchlist(&self, name: &str) -> TastyResult<Watchlist> {
self.get(format!("/watchlists/{}", encode_path_segment(name)))
.await
}
pub async fn create_watchlist(&self, watchlist: &NewWatchlist) -> TastyResult<Watchlist> {
watchlist.validate()?;
self.post("/watchlists", watchlist).await
}
pub async fn replace_watchlist(
&self,
name: &str,
watchlist: &NewWatchlist,
) -> TastyResult<Watchlist> {
watchlist.validate()?;
self.put(
format!("/watchlists/{}", encode_path_segment(name)),
watchlist,
)
.await
}
pub async fn delete_watchlist(&self, name: &str) -> TastyResult<Watchlist> {
crate::types::watchlist::validate_watchlist_name(name)?;
self.delete(format!("/watchlists/{}", encode_path_segment(name)))
.await
}
pub async fn pairs_watchlists(&self) -> TastyResult<Vec<PairsWatchlist>> {
let resp: Items<PairsWatchlist> = self.get("/pairs-watchlists").await?;
resp.into_items()
}
pub async fn pairs_watchlist(&self, name: &str) -> TastyResult<PairsWatchlist> {
self.get(format!("/pairs-watchlists/{}", encode_path_segment(name)))
.await
}
pub async fn quote_alerts(&self) -> TastyResult<Vec<QuoteAlert>> {
let resp: Items<QuoteAlert> = self.get("/quote-alerts").await?;
resp.into_items()
}
pub async fn create_quote_alert(&self, alert: &NewQuoteAlert) -> TastyResult<QuoteAlert> {
alert.validate()?;
self.post("/quote-alerts", alert).await
}
pub async fn cancel_quote_alert(&self, alert_external_id: &str) -> TastyResult<()> {
self.delete_no_content(format!(
"/quote-alerts/{}",
encode_path_segment(alert_external_id)
))
.await
}
pub async fn span_rows(
&self,
date: NaiveDate,
exchange: SpanExchange,
page: &PageRequest,
) -> TastyResult<Paginated<SpanRow>> {
let mut query = QueryBuilder::new();
query.push("date", date);
query.push("exchange", exchange.as_wire());
page.write_into(&mut query);
self.get_with_query::<Items<SpanRow>, _, _>("/span/rows", &query.pairs())
.await
}
pub async fn customer_orders(
&self,
filter: &CustomerOrderFilter,
) -> TastyResult<Paginated<LiveOrderRecord>> {
let query = filter.to_query();
self.get_with_query::<Items<LiveOrderRecord>, _, _>("/customers/me/orders", &query.pairs())
.await
}
pub async fn customer_live_orders(
&self,
filter: &CustomerLiveOrderFilter,
) -> TastyResult<Paginated<LiveOrderRecord>> {
let query = filter.to_query();
self.get_with_query::<Items<LiveOrderRecord>, _, _>(
"/customers/me/orders/live",
&query.pairs(),
)
.await
}
pub async fn customer(&self) -> TastyResult<Customer> {
self.customer_by_id("me").await
}
pub async fn customer_by_id(&self, customer_id: &str) -> TastyResult<Customer> {
self.get(format!("/customers/{}", encode_path_segment(customer_id)))
.await
}
pub async fn find_customer(&self, customer_id: &str) -> TastyResult<Option<Customer>> {
let customer: Option<Customer> = self
.get_with_query::<Option<Customer>, _, _>(
format!("/customers/{}", encode_path_segment(customer_id)),
&[("allow-missing", "true")],
)
.await?;
Ok(customer.filter(|customer| customer.id.is_some()))
}
pub async fn create_quote_streamer(&self) -> TastyResult<QuoteStreamer> {
QuoteStreamer::connect(self).await
}
}
#[cfg(test)]
mod second_host_tests {
use super::endpoint_url;
use crate::types::backtest::BACKTESTER_BASE_URL;
#[test]
fn a_second_host_joins_to_itself_and_not_to_the_configured_one() {
let joined =
endpoint_url(BACKTESTER_BASE_URL, "/backtests").expect("a relative path joins cleanly");
assert_eq!(joined, "https://backtester.vast.tastyworks.com/backtests");
assert!(
!joined.contains("tastyworks.com/tastyworks.com"),
"the bases must not be concatenated: {joined}"
);
}
#[test]
fn an_absolute_path_is_still_refused_against_the_second_host() {
assert!(endpoint_url(BACKTESTER_BASE_URL, "https://elsewhere/backtests").is_err());
}
#[test]
fn the_backtester_shapes_decode_raw_and_not_through_the_envelope() {
use crate::api::base::TastyApiResponse;
use crate::types::backtest::{AvailableDates, Backtest};
let listing = r#"["run-1","run-2"]"#;
let ids: Vec<String> = serde_json::from_str(listing).expect("a raw array of strings");
assert_eq!(ids, vec!["run-1".to_string(), "run-2".to_string()]);
assert!(
serde_json::from_str::<TastyApiResponse<Vec<String>>>(listing).is_err(),
"the enveloped decoder must not be able to read this"
);
let run = r#"{"id":"run-1","symbol":"SPY","status":"running","progress":42}"#;
let decoded: Backtest = serde_json::from_str(run).expect("a raw object");
assert_eq!(decoded.id.as_deref(), Some("run-1"));
assert_eq!(decoded.status.as_deref(), Some("running"));
let dates = r#"[{"symbol":"SPY","startDate":"2019-01-01","endDate":"2024-12-31"}]"#;
let decoded: Vec<AvailableDates> = serde_json::from_str(dates).expect("a raw array");
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].symbol.as_deref(), Some("SPY"));
assert!(
serde_json::from_str::<crate::api::base::Items<AvailableDates>>(dates).is_err(),
"an Items listing expects an object with an items key"
);
serde_json::from_str::<()>("null").expect("an empty body is the unit value");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_dot_only_segment_is_refused_and_a_dot_inside_one_is_not() {
for marker in [".", "..", "%2e", "%2E", "%2e%2e", "%2E%2E", ".%2e", "%2E."] {
let path = format!("/instruments/equities/{marker}");
assert!(
matches!(
endpoint_url("https://api.example.com", &path),
Err(crate::TastyTradeError::Precondition(_))
),
"{marker:?} was accepted, and URL resolution would have removed it"
);
}
for value in ["BRK.B", ".%2FESZ4", "...", "%252E", "a.", ".a", "%2ex"] {
let path = format!("/instruments/equities/{value}");
assert_eq!(
endpoint_url("https://api.example.com", &path).expect("an ordinary value"),
format!("https://api.example.com{path}")
);
}
}
#[test]
fn a_dot_segment_in_the_query_string_is_not_a_path_segment() {
let path = "/accounts/5WX12345/transactions?sort=..&start-date=2026-01-01";
let url = endpoint_url("https://api.example.com", path).expect("a query value");
assert!(url.ends_with(path), "{url}");
}
#[test]
fn the_refusal_carries_no_account_number() {
let error = endpoint_url("https://api.example.com", "/accounts/5WX12345/orders/..")
.expect_err("a dot segment must be refused");
let rendered = error.to_string();
assert!(!rendered.contains("5WX12345"), "{rendered}");
assert!(rendered.contains("{account}"), "{rendered}");
}
#[test]
fn redacts_the_account_number_from_an_account_scoped_path() {
assert_eq!(
redact_account_path("https://api.tastyworks.com/accounts/5WX12345/balances"),
"https://api.tastyworks.com/accounts/{account}/balances"
);
}
#[test]
fn redacts_the_account_number_ahead_of_a_query_string() {
let redacted = redact_account_path(
"https://api.tastyworks.com/accounts/5WX12345/balance-snapshots?start-date=2026-01-01",
);
assert!(!redacted.contains("5WX12345"), "{redacted}");
assert!(redacted.contains("start-date=2026-01-01"), "{redacted}");
}
#[test]
fn redacts_every_segment_that_follows_accounts() {
assert_eq!(
redact_account_path("https://api.tastyworks.com/accounts/5WX12345/orders/187"),
"https://api.tastyworks.com/accounts/{account}/orders/187"
);
}
#[test]
fn leaves_paths_without_an_account_segment_alone() {
for url in [
"https://api.tastyworks.com/customers/me/accounts",
"https://api.tastyworks.com/instruments/equities/AAPL",
"https://api.tastyworks.com/option-chains/SPY/nested",
] {
assert_eq!(redact_account_path(url), url);
}
}
#[test]
fn redacts_the_customer_identifier() {
assert_eq!(
redact_account_path("https://api.tastyworks.com/customers/78a1f0c2-4d31"),
"https://api.tastyworks.com/customers/{customer}"
);
assert_eq!(
redact_account_path("https://api.tastyworks.com/customers/78a1f0c2/accounts/5WX12345"),
"https://api.tastyworks.com/customers/{customer}/accounts/{account}"
);
let redacted =
redact_account_path("https://api.tastyworks.com/customers/78a1f0c2?per-page=100");
assert!(!redacted.contains("78a1f0c2"), "{redacted}");
assert!(redacted.contains("per-page=100"), "{redacted}");
}
#[test]
fn redacts_an_account_number_carried_in_the_query() {
let redacted = redact_account_path(
"https://api.tastyworks.com/orders?account-numbers[]=5WX12345&\
account-numbers[]=5WX00002&per-page=50",
);
assert!(!redacted.contains("5WX12345"), "{redacted}");
assert!(!redacted.contains("5WX00002"), "{redacted}");
assert_eq!(redacted.matches("{account}").count(), 2, "{redacted}");
assert!(redacted.contains("account-numbers[]="), "{redacted}");
assert!(redacted.contains("per-page=50"), "{redacted}");
let redacted =
redact_account_path("https://api.tastyworks.com/orders?account-numbers%5B%5D=5WX12345");
assert!(!redacted.contains("5WX12345"), "{redacted}");
let redacted = redact_account_path(
"https://api.tastyworks.com/x?account-number=5WX12345&\
clearing-account-number=99887&sort=Desc",
);
assert!(!redacted.contains("5WX12345"), "{redacted}");
assert!(!redacted.contains("99887"), "{redacted}");
assert!(redacted.contains("sort=Desc"), "{redacted}");
}
#[test]
fn leaves_the_me_alias_readable() {
for url in [
"https://api.tastyworks.com/customers/me",
"https://api.tastyworks.com/customers/me/accounts",
] {
assert_eq!(redact_account_path(url), url);
}
assert_eq!(
redact_account_path("https://api.tastyworks.com/customers/me/accounts/5WX12345"),
"https://api.tastyworks.com/customers/me/accounts/{account}"
);
}
}