tastytrade 0.4.0

Library for trading through tastytrade's API
Documentation
use crate::TastyTrade;
use crate::types::instrument::InstrumentType;
use crate::{AsSymbol, Symbol, TastyResult};
use pretty_simple_display::{DebugPretty, DisplaySimple};
use serde::Deserialize;
use serde::Serialize;
use tracing::debug;

impl TastyTrade {
    /// Exchanges the session for a short-lived DXLink streamer token.
    ///
    /// # Errors
    ///
    /// Propagates the venue's error. Neither the response body nor the token
    /// reaches the error or the logs: the body of this particular response
    /// *is* a credential, so a decode failure reports the status and the
    /// endpoint and nothing else.
    pub async fn quote_streamer_tokens(&self) -> TastyResult<QuoteStreamerTokens> {
        debug!("Requesting quote streamer tokens");

        // Through the generic verb, which checks the status and keeps the body
        // out of both the logs and the error. Decoding by hand here logged the
        // serde error and then handed it to the caller inside
        // `TastyTradeError::Json`. A serde_json error quotes the value it
        // rejected, and the value in this particular response is the DXLink
        // credential; the untagged envelope happened to mask it, which is a
        // property of `TastyApiResponse` rather than anything this function
        // arranged. A non-2xx also became a `Connection` error carrying only
        // the status, where every other endpoint reports where and against
        // which deployment.
        self.get("/api-quote-tokens").await
    }
}

/// DXLink quote streamer credentials.
///
/// `Debug` and `Display` are implemented manually so the token is never
/// written to logs.
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct QuoteStreamerTokens {
    /// The DXLink credential. Redacted by this type's `Debug`; keep it that
    /// way.
    pub token: String,
    /// Where to connect with it.
    #[serde(rename = "dxlink-url")]
    pub streamer_url: String,
    /// The market-data entitlement this token carries.
    pub level: String,
}

impl std::fmt::Debug for QuoteStreamerTokens {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("QuoteStreamerTokens")
            .field("token", &"***")
            .field("streamer_url", &self.streamer_url)
            .field("level", &self.level)
            .finish()
    }
}

impl std::fmt::Display for QuoteStreamerTokens {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{self:?}")
    }
}

#[derive(
    DebugPretty, DisplaySimple, Serialize, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(transparent)]
/// A symbol as the streaming feed names it.
///
/// Not always the same string as the instrument symbol, which is why
/// [`TastyTrade::get_streamer_symbol`] exists.
pub struct DxFeedSymbol(pub String);

impl AsSymbol for DxFeedSymbol {
    fn as_symbol(&self) -> Symbol {
        Symbol(self.0.clone())
    }
}

impl AsSymbol for &DxFeedSymbol {
    fn as_symbol(&self) -> Symbol {
        Symbol(self.0.clone())
    }
}

impl TastyTrade {
    /// Looks up the streaming name for an instrument.
    ///
    /// # Errors
    ///
    /// Fails when the instrument is unknown or carries no streamer symbol.
    pub async fn get_streamer_symbol(
        &self,
        instrument_type: &InstrumentType,
        symbol: &Symbol,
    ) -> TastyResult<DxFeedSymbol> {
        use InstrumentType::*;
        let sym = match instrument_type {
            Equity => self.get_equity_info(symbol).await?.streamer_symbol,
            EquityOption => self.get_option_info(symbol).await?.streamer_symbol,
            EquityOffering => self.get_equity_info(symbol).await?.streamer_symbol, // Handle as equity
            Future => self.get_future(symbol).await?.streamer_symbol,
            FutureOption => self
                .get_future_option(symbol)
                .await?
                .streamer_symbol
                .unwrap_or_else(|| DxFeedSymbol(symbol.0.clone())),
            Cryptocurrency => self.get_cryptocurrency(symbol).await?.streamer_symbol,
            Bond => DxFeedSymbol(symbol.0.clone()), // Handle as basic symbol
            FixedIncomeSecurity => DxFeedSymbol(symbol.0.clone()), // Handle as basic symbol
            LiquidityPool => DxFeedSymbol(symbol.0.clone()), // Handle as basic symbol
            Warrant => DxFeedSymbol(self.get_warrant(symbol).await?.symbol.0), // Convert to DxFeedSymbol
        };
        Ok(sym)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::instrument::InstrumentType;

    #[test]
    fn test_quote_streamer_tokens_deserialization() {
        let json = r#"{
            "token": "abc123token",
            "dxlink-url": "wss://streamer.example.com",
            "level": "delayed"
        }"#;

        let tokens: QuoteStreamerTokens = serde_json::from_str(json).unwrap();
        assert_eq!(tokens.token, "abc123token");
        assert_eq!(tokens.streamer_url, "wss://streamer.example.com");
        assert_eq!(tokens.level, "delayed");
    }

    #[test]
    fn test_quote_streamer_tokens_debug_redacts_token() {
        let tokens = QuoteStreamerTokens {
            token: "test_token".to_string(),
            streamer_url: "wss://test.com".to_string(),
            level: "realtime".to_string(),
        };

        for output in [format!("{:?}", tokens), format!("{}", tokens)] {
            assert!(!output.contains("test_token"));
            assert!(output.contains("***"));
            assert!(output.contains("wss://test.com"));
            assert!(output.contains("realtime"));
        }
    }

    #[test]
    fn test_dxfeed_symbol_creation() {
        let symbol = DxFeedSymbol("AAPL".to_string());
        assert_eq!(symbol.0, "AAPL");
    }

    #[test]
    fn test_dxfeed_symbol_as_symbol_trait() {
        let dxfeed_symbol = DxFeedSymbol("MSFT".to_string());
        let symbol = dxfeed_symbol.as_symbol();
        assert_eq!(symbol.0, "MSFT");

        // Test with reference
        let symbol_ref = &dxfeed_symbol;
        let symbol = symbol_ref.as_symbol();
        assert_eq!(symbol.0, "MSFT");
    }

    #[test]
    fn test_dxfeed_symbol_serialization() {
        let symbol = DxFeedSymbol("TSLA".to_string());
        let serialized = serde_json::to_string(&symbol).unwrap();
        assert_eq!(serialized, "\"TSLA\"");

        let deserialized: DxFeedSymbol = serde_json::from_str(&serialized).unwrap();
        assert_eq!(deserialized.0, "TSLA");
    }

    #[test]
    fn test_dxfeed_symbol_traits() {
        let symbol1 = DxFeedSymbol("AAPL".to_string());
        let symbol2 = DxFeedSymbol("AAPL".to_string());
        let symbol3 = DxFeedSymbol("MSFT".to_string());

        // Test Clone
        let cloned = symbol1.clone();
        assert_eq!(cloned.0, "AAPL");

        // Test PartialEq
        assert_eq!(symbol1, symbol2);
        assert_ne!(symbol1, symbol3);

        // Test PartialOrd
        assert!(symbol1 < symbol3); // "AAPL" < "MSFT"
        assert!(symbol3 > symbol1);

        // Test Debug
        let debug_str = format!("{:?}", symbol1);
        assert!(debug_str.contains("AAPL"));
    }

    #[test]
    fn test_dxfeed_symbol_ordering() {
        let mut symbols = [
            DxFeedSymbol("TSLA".to_string()),
            DxFeedSymbol("AAPL".to_string()),
            DxFeedSymbol("MSFT".to_string()),
        ];

        symbols.sort();

        assert_eq!(symbols[0].0, "AAPL");
        assert_eq!(symbols[1].0, "MSFT");
        assert_eq!(symbols[2].0, "TSLA");
    }

    #[test]
    fn test_dxfeed_symbol_hash() {
        use std::collections::HashMap;

        let mut map = HashMap::new();
        let symbol1 = DxFeedSymbol("AAPL".to_string());
        let symbol2 = DxFeedSymbol("AAPL".to_string());

        map.insert(symbol1, "Apple");

        // Should be able to retrieve with equivalent symbol
        assert_eq!(map.get(&symbol2), Some(&"Apple"));
    }

    #[test]
    fn test_instrument_type_matching() {
        // Test that all InstrumentType variants are handled
        // This is a compile-time test - if new variants are added,
        // the match in get_streamer_symbol will need updating
        let instrument_types = [
            InstrumentType::Equity,
            InstrumentType::EquityOption,
            InstrumentType::EquityOffering,
            InstrumentType::Future,
            InstrumentType::FutureOption,
            InstrumentType::Cryptocurrency,
        ];

        // Just verify we can create all variants
        assert_eq!(instrument_types.len(), 6);
    }

    #[test]
    fn test_dxfeed_symbol_transparent_serde() {
        // Test that the transparent attribute works correctly
        let symbol = DxFeedSymbol("TEST123".to_string());
        let json = serde_json::to_string(&symbol).unwrap();

        // Should serialize as just the string, not as an object
        assert_eq!(json, "\"TEST123\"");

        // Should deserialize back correctly
        let deserialized: DxFeedSymbol = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized, symbol);
    }
}