tastytrade 0.5.0

Library for trading through tastytrade's API
Documentation
// The public API is the crate contract, and an undocumented item is a
// contract nobody can read. Denied at the crate root rather than in a
// Makefile target, so it fails at the same moment as any other compile
// error and cannot be forgotten by running a different command.
#![deny(missing_docs)]
// A doc link that points nowhere is documentation that lies about where to
// look next, which is worse than not linking at all.
#![deny(rustdoc::broken_intra_doc_links)]

//! # tastytrade
//!
//! A Rust client for the tastytrade brokerage API. **Orders placed through it
//! move real money.**
//!
//! This is the API reference. The [README] is the tour: what the crate covers,
//! how to authenticate, and a worked example per area. What follows is the
//! handful of behaviours a caller has to know before reading any individual
//! method, because they are properties of the whole crate rather than of one
//! call.
//!
//! [README]: https://github.com/joaquinbejar/tastytrade#readme
//!
//! ## Certification is the default
//!
//! [`utils::config::TastyTradeConfig::from_env`] selects the **certification**
//! environment (`api.cert.tastyworks.com`). Production is a deliberate opt-in:
//!
//! ```shell
//! TASTYTRADE_USE_DEMO=false   # production — orders placed here are real
//! ```
//!
//! Only a value that parses as `false` selects production. A missing, empty or
//! misspelled variable resolves to certification, so a typo cannot be what
//! points an order at a funded account.
//!
//! A session is bound to the deployment it authenticated against: it will not
//! present a certification token to production, and it will not send the client
//! secret to a host it did not authenticate with.
//!
//! ## Authentication is OAuth2, and only OAuth2
//!
//! tastytrade **decommissioned `POST /sessions` on 2026-02-11**. Username and
//! password authentication, session tokens and remember tokens are gone from
//! the venue and gone from this crate with it.
//!
//! ```rust,no_run
//! use tastytrade::TastyTrade;
//! use tastytrade::utils::config::TastyTradeConfig;
//!
//! # async fn connect() -> Result<(), Box<dyn std::error::Error>> {
//! let config = TastyTradeConfig::from_env();
//! let tasty = TastyTrade::connect(&config).await?;
//!
//! for account in tasty.accounts().await? {
//!     // Redacted: doc examples get copied, and an account number in a log is
//!     // the thing this crate spends most of its care avoiding.
//!     println!("{}", account.number().redacted());
//! }
//! # Ok(())
//! # }
//! ```
//!
//! Access tokens last about fifteen minutes and every request renews the one in
//! hand before it expires, so a long-lived client keeps working. A renewal is
//! never a *retry*: a `POST` that may have placed an order is not replayed on a
//! `401`.
//!
//! [`TastyTrade::connect_with_authorization_code`] is the third-party grant, for
//! an application acting on somebody else's account.
//!
//! ## Nothing that trades happens without a receipt
//!
//! Placement, replacement, editing and complex orders all go through the same
//! shape: dry-run, read what the venue said, then apply the receipt.
//!
//! ```rust,no_run
//! # use tastytrade::prelude::*;
//! # async fn place(account: &Account<'_>, order: &Order)
//! # -> Result<(), Box<dyn std::error::Error>> {
//! let receipt = account.review_order(order).await?;
//!
//! for warning in receipt.warnings() {
//!     // Venue prose, written for a person: it can name the account or the
//!     // buying power, so it belongs on a screen rather than in a log.
//!     println!("{warning}");
//! }
//!
//! // `accept` refuses when there are warnings. That is not a refusal to
//! // proceed — it is a refusal to proceed *silently*.
//! let reviewed = receipt.accept()?;
//! account.place_reviewed_order(reviewed).await?;
//! # Ok(())
//! # }
//! ```
//!
//! A receipt binds the account number **and** the deployment, because
//! certification reuses production account numbering — without the origin, a
//! sandbox dry run would authorise a real order against the same number. No
//! receipt is `Clone`: duplicable proof is not proof.
//!
//! [`accounts::Account::place_order`] still exists for callers managing the
//! review themselves. It carries no evidence that one happened.
//!
//! ## Money is `Decimal`
//!
//! Every price, quantity, balance and ratio is [`rust_decimal::Decimal`].
//! `f64` appears in exactly one place — [`types::dxfeed`], where the streaming
//! feed imposes it — and REST paths never reuse those types even when the field
//! names match.
//!
//! ## An absent field is unknown, never zero
//!
//! A flag the venue did not send is `None`, not `false`; a price it did not send
//! is `None`, not `0`. Certification omits fields production sends, and
//! "we were not told whether this account is frozen" and "this account is not
//! frozen" are different facts — only one of them is safe to act on.
//!
//! ## Secrets never render themselves
//!
//! The client secret, the refresh and access tokens, the DXLink quote token, the
//! AI-search token and the whole customer resource print as `***` or as a field
//! count. Not in `Debug`, not in `Display`, not in a log line, not in an error
//! message — an error is a string the caller prints wherever they like.
//!
//! Account numbers are redacted from every request path that reaches an error,
//! and a response body is never logged at any level: an error document from an
//! endpoint this crate does not control can echo a credential.
//!
//! ## A library does not panic
//!
//! No `unwrap`, no `expect`, no unchecked indexing on any path reachable from a
//! public method. Everything fallible returns [`TastyTradeError`]. A local
//! failure is [`TastyTradeError::Precondition`] and reports `is_retryable()`
//! false, because nothing was sent.
//!
//! ## Unknown values survive
//!
//! [`api::base::Items`] skips an item it cannot decode rather than failing a
//! whole listing, so a strict enum on a response would make a row **disappear**
//! — silently. The response enums therefore keep an `Unknown(String)` arm that
//! round-trips the venue's text: a new order status, transaction kind or
//! instrument classification is visible and matchable instead of missing.
//!
//! Request enums are closed, for the opposite reason: tolerance there would only
//! let a caller send something the venue rejects.
//!
//! ## Cryptocurrency order routing is suspended
//!
//! tastytrade disabled it on 2026-06-29, until further notice. An order with a
//! cryptocurrency leg is refused locally on every routing path. **Instrument
//! discovery and market data are unaffected.** The whole decision is
//! [`prelude::CRYPTOCURRENCY_TRADING_ENABLED`], one constant.
//!
//! ## Streaming
//!
//! Two websockets, and they are different services. Market data is DXLink,
//! reached with a token from `GET /api-quote-tokens`; account notifications are
//! tastytrade's own streamer, authenticated with the access token. Both
//! reconnect under a [`streaming::reconnect::BackoffPolicy`] and expose
//! [`streaming::reconnect::ConnectionState`].
//!
//! Candles are the only route to a price series in this crate, and the only
//! subscription needing more than a symbol — a candle is addressed by a symbol
//! carrying its period, `AAPL{=5m}`.
//!
//! ```rust,no_run
//! # use chrono::{Duration, Utc};
//! # use tastytrade::{Symbol, TastyTrade};
//! # use tastytrade::dxfeed::{CandlePeriod, EventData, EventKind};
//! # use tastytrade::prelude::InstrumentType;
//! # async fn bars(tasty: &TastyTrade) -> Result<(), Box<dyn std::error::Error>> {
//! let mut streamer = tasty.create_quote_streamer().await?;
//! let mut bars = streamer.create_sub([EventKind::Candle]).await?;
//!
//! // The streaming name, which is not always the instrument name. For an
//! // equity the two coincide; for a future they do not, and the feed simply
//! // never answers the wrong one.
//! let aapl = tasty
//!     .get_streamer_symbol(&InstrumentType::Equity, &Symbol("AAPL".to_string()))
//!     .await?;
//!
//! // `from_time` is required, not optional: without one a candle subscription
//! // replays an unbounded history.
//! let period = CandlePeriod::minutes(5)?;
//! bars.add_candles(&[aapl], period, Utc::now() - Duration::days(2))
//!     .await?;
//!
//! // Each symbol and period replays its history as a snapshot, and the crate
//! // says when one is over: no flag arithmetic, no snapshot constants.
//! while let Ok(event) = bars.get_event().await {
//!     match event.data {
//!         EventData::Candle(candle) => {
//!             println!("{}: o {} c {}", event.sym, candle.open, candle.close);
//!         }
//!         EventData::SnapshotEnd(end) => {
//!             println!("{} history in, complete: {}", event.sym, end.lossless);
//!             // Done with it? Stop paying for a live feed nobody reads. The
//!             // marker names the series with its period suffix; this takes it
//!             // without, and the period converts between the two.
//!             if let Some(base) = period.base_symbol(&event.sym) {
//!                 bars.remove_candles(&[base], period).await?;
//!             }
//!             break;
//!         }
//!         _ => {}
//!     }
//! }
//! # Ok(())
//! # }
//! ```
//!
//! **Two symbol namespaces, and the compiler keeps them apart.** The REST API
//! names an instrument with a [`Symbol`]; the feed names it with a
//! [`DxFeedSymbol`](api::quote_streaming::DxFeedSymbol). They are the same
//! string for an equity and differ for futures, options, cryptocurrencies and
//! warrants: a futures contract the REST API calls `/ESU3` streams as
//! `/ESU23:XCME`. There is no rule to apply here, and this crate does not
//! invent one — ask
//! [`TastyTrade::get_streamer_symbol`](TastyTrade::get_streamer_symbol), or
//! read the `streamer_symbol` an instrument already carries, and pass what
//! comes back unchanged.
//!
//! Subscribing with the instrument symbol instead is silent: the venue does
//! not recognise the target, so it sends nothing, forever, with no error. The
//! subscription methods therefore take
//! [`AsStreamerSymbol`](api::quote_streaming::AsStreamerSymbol), which only
//! [`DxFeedSymbol`](api::quote_streaming::DxFeedSymbol) implements. That stops
//! a [`Symbol`], a `String` or a `&str` reaching the feed by accident; it is
//! not validation, because the newtype's field is public and a wrong string
//! can still be wrapped by hand. The compiler catches the mix-up, not the typo.
//!
//! Candle symbols carry their period on the wire, and
//! [`dxfeed::CandlePeriod::base_symbol`] is the way back from a bar or a
//! marker to the base name the subscription methods take.
//!
//! **Historical replay is a phase, not a guess.** A candle subscription's
//! history arrives as a snapshot per streamer symbol, and this crate turns the
//! feed's flags into two events of its own:
//! [`EventData::SnapshotBegin`](dxfeed::EventData::SnapshotBegin) ahead of a
//! replay's bars and
//! [`EventData::SnapshotEnd`](dxfeed::EventData::SnapshotEnd) after the last
//! of them, before any live update that follows. Each carries a **generation**,
//! which increments on every new snapshot and on every reconnect, so an ending
//! that was still queued when a connection dropped is recognisable rather than
//! mistaken for the next replay's. A marker is never dropped for a full queue,
//! and nothing newer overtakes it.
//! [`QuoteSubscription::history_loaded`](streaming::quote_streamer::QuoteSubscription::history_loaded)
//! and
//! [`await_history`](streaming::quote_streamer::QuoteSubscription::await_history)
//! answer the same question for a caller who would rather ask than watch, and
//! [`remove_candles`](streaming::quote_streamer::QuoteSubscription::remove_candles)
//! drops one finished series without touching the rest of the subscription.
//!
//! A subscription's buffer is bounded, so a slow consumer loses events rather
//! than stalling every other subscription.
//! [`streaming::quote_streamer::QuoteSubscription::lagged`] makes that
//! observable, and for candles it is recoverable across a reconnect: a dropped
//! bar stops the resume point advancing, so the next connection asks for it
//! again. That is also why a replay finishing and a history being complete are
//! two answers rather than one:
//! [`DxfSnapshotEndT::lossless`](dxfeed::DxfSnapshotEndT::lossless) is false
//! when bars of that generation were shed, by this consumer or by the feed
//! client above it, so a chart knows it has holes.
//!
//! The account websocket publishes a **full object** on every change — never a
//! diff. The fills inside an order's legs are the only place an executed price
//! reaches this crate; no REST endpoint returns one. Anything that is JSON
//! reaches the caller, including a `type` nobody here recognises.
//!
//! ## Where to look
//!
//! [`prelude`] re-exports the advertised surface in one import. The endpoint
//! groups hang off [`TastyTrade`] and [`accounts::Account`]; the filters that
//! narrow them are `*Filter` types taking a [`api::query::PageRequest`].

/// Compiles every Rust block in `README.md` as a doc test.
///
/// The README is hand-written now, which means nothing else checks its
/// examples — and a README whose code does not compile is worse than one with
/// no code at all. This couples the *code* to the crate without coupling the
/// *prose*: the file is still authored by hand, and `src/lib.rs` no longer
/// generates it.
///
/// `#[cfg(doctest)]` keeps the item out of every build except the doc-test
/// pass, so it costs nothing at compile time and appears in no documentation.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeExamples;

/// REST surface: authentication, accounts, instruments and option chains.
pub mod api;
mod error;
/// Real-time transports: DXLink quotes and the account websocket.
pub mod streaming;
mod types;

/// The commonly used types in one import.
pub mod prelude;
/// Configuration, logging, bulk downloads and parsing helpers.
pub mod utils;

pub use api::accounts;
pub use api::base::TastyResult;
pub use api::client::TastyTrade;

pub use error::{ApiError, DxFeedError, Environment, RequestContext, TastyTradeError};
pub use types::dxfeed;
pub use types::instrument::InstrumentType;
pub use types::oauth;
pub use types::order::{
    Action, Order, OrderBuilder, OrderLeg, OrderLegBuilder, OrderType, PriceEffect, TimeInForce,
};
pub use types::order::{AsSymbol, LiveOrderRecord, Symbol};
pub use types::position::{BriefPosition, FullPosition, QuantityDirection};
pub use types::wire::{ExerciseStyle, ExpirationType, SettlementType};