tvdata-rs 0.1.2

Async Rust client for TradingView screener queries, search, calendars, quote snapshots, and OHLCV history.
Documentation
use std::time::Duration;

use reqwest::StatusCode;
use thiserror::Error;

pub type Result<T> = std::result::Result<T, Error>;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
    RateLimited,
    AuthRequired,
    SymbolNotFound,
    Transport,
    Protocol,
    Unsupported,
    Api,
}

#[derive(Debug, Error)]
pub enum Error {
    #[error("http request failed: {0}")]
    Http(#[source] Box<reqwest_middleware::Error>),

    #[error("websocket request failed: {0}")]
    WebSocket(#[source] Box<tokio_tungstenite::tungstenite::Error>),

    #[error("failed to deserialize tradingview payload: {0}")]
    Json(#[from] serde_json::Error),

    #[error("failed to format time value: {0}")]
    TimeFormat(#[from] time::error::Format),

    #[error("invalid endpoint url: {0}")]
    UrlParse(#[from] url::ParseError),

    #[error("tradingview returned an API error: {0}")]
    ApiMessage(String),

    #[error("tradingview returned HTTP {status}: {body}")]
    ApiStatus { status: StatusCode, body: String },

    #[error("search query cannot be empty")]
    EmptySearchQuery,

    #[error("scan page limit must be greater than zero")]
    InvalidPageLimit,

    #[error("history request returned no bars for {symbol}")]
    HistoryEmpty { symbol: String },

    #[error("scan returned no rows for {symbol}")]
    SymbolNotFound { symbol: String },

    #[error("scan validation is unavailable: {reason}")]
    ScanValidationUnavailable { reason: String },

    #[error("scan query uses fields unsupported for {route}: {fields:?}")]
    UnsupportedScanFields { route: String, fields: Vec<String> },

    #[error("quote session returned no data for {symbol}")]
    QuoteEmpty { symbol: String },

    #[error("quote session returned status {status} for {symbol}")]
    QuoteSymbolFailed { symbol: String, status: String },

    #[error("history batch concurrency must be greater than zero")]
    InvalidBatchConcurrency,

    #[error("history pagination exceeded safe limit for {symbol} after {rounds} rounds")]
    HistoryPaginationLimitExceeded { symbol: String, rounds: usize },

    #[error("history download failed for {symbol}: {source}")]
    HistoryDownloadFailed {
        symbol: String,
        #[source]
        source: Box<Error>,
    },

    #[error("retry min interval {min:?} cannot exceed max interval {max:?}")]
    InvalidRetryBounds { min: Duration, max: Duration },

    #[error("request budget field {field} must be greater than zero")]
    InvalidRequestBudget { field: &'static str },

    #[error("snapshot batch config field {field} must be greater than zero")]
    InvalidSnapshotBatchConfig { field: &'static str },

    #[error("invalid websocket frame: {0}")]
    Protocol(&'static str),
}

impl From<reqwest_middleware::Error> for Error {
    fn from(value: reqwest_middleware::Error) -> Self {
        Self::Http(Box::new(value))
    }
}

impl From<reqwest::Error> for Error {
    fn from(value: reqwest::Error) -> Self {
        let error: reqwest_middleware::Error = value.into();
        Self::Http(Box::new(error))
    }
}

impl From<tokio_tungstenite::tungstenite::Error> for Error {
    fn from(value: tokio_tungstenite::tungstenite::Error) -> Self {
        Self::WebSocket(Box::new(value))
    }
}

impl Error {
    pub fn kind(&self) -> ErrorKind {
        match self {
            Self::Http(_) | Self::WebSocket(_) => ErrorKind::Transport,
            Self::Json(_)
            | Self::TimeFormat(_)
            | Self::UrlParse(_)
            | Self::InvalidPageLimit
            | Self::InvalidBatchConcurrency
            | Self::InvalidRetryBounds { .. }
            | Self::InvalidRequestBudget { .. }
            | Self::InvalidSnapshotBatchConfig { .. }
            | Self::Protocol(_) => ErrorKind::Protocol,
            Self::EmptySearchQuery | Self::ApiMessage(_) => ErrorKind::Api,
            Self::ApiStatus { status, .. } if *status == StatusCode::TOO_MANY_REQUESTS => {
                ErrorKind::RateLimited
            }
            Self::ApiStatus { status, .. }
                if *status == StatusCode::UNAUTHORIZED || *status == StatusCode::FORBIDDEN =>
            {
                ErrorKind::AuthRequired
            }
            Self::ApiStatus { .. } => ErrorKind::Api,
            Self::HistoryEmpty { .. }
            | Self::SymbolNotFound { .. }
            | Self::QuoteEmpty { .. }
            | Self::QuoteSymbolFailed { .. } => ErrorKind::SymbolNotFound,
            Self::ScanValidationUnavailable { .. } | Self::UnsupportedScanFields { .. } => {
                ErrorKind::Unsupported
            }
            Self::HistoryPaginationLimitExceeded { .. } => ErrorKind::Protocol,
            Self::HistoryDownloadFailed { source, .. } => source.kind(),
        }
    }

    pub fn is_retryable(&self) -> bool {
        match self.kind() {
            ErrorKind::RateLimited | ErrorKind::Transport => true,
            ErrorKind::AuthRequired
            | ErrorKind::SymbolNotFound
            | ErrorKind::Protocol
            | ErrorKind::Unsupported => false,
            ErrorKind::Api => matches!(
                self,
                Self::ApiStatus { status, .. } if status.is_server_error()
            ),
        }
    }

    pub fn is_auth_error(&self) -> bool {
        self.kind() == ErrorKind::AuthRequired
    }

    pub fn is_rate_limited(&self) -> bool {
        self.kind() == ErrorKind::RateLimited
    }

    pub fn is_symbol_error(&self) -> bool {
        self.kind() == ErrorKind::SymbolNotFound
    }

    pub fn is_transport_error(&self) -> bool {
        self.kind() == ErrorKind::Transport
    }

    pub fn is_protocol_error(&self) -> bool {
        self.kind() == ErrorKind::Protocol
    }

    pub fn is_unsupported_error(&self) -> bool {
        self.kind() == ErrorKind::Unsupported
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn helper_methods_follow_error_kind_classification() {
        let auth = Error::ApiStatus {
            status: StatusCode::UNAUTHORIZED,
            body: String::from("unauthorized"),
        };
        assert!(auth.is_auth_error());
        assert!(!auth.is_retryable());

        let rate_limited = Error::ApiStatus {
            status: StatusCode::TOO_MANY_REQUESTS,
            body: String::from("rate limited"),
        };
        assert!(rate_limited.is_rate_limited());
        assert!(rate_limited.is_retryable());

        let symbol = Error::SymbolNotFound {
            symbol: String::from("NASDAQ:AAPL"),
        };
        assert!(symbol.is_symbol_error());
        assert!(!symbol.is_retryable());

        let transport = Error::ApiStatus {
            status: StatusCode::BAD_GATEWAY,
            body: String::from("upstream failed"),
        };
        assert_eq!(transport.kind(), ErrorKind::Api);
        assert!(!transport.is_transport_error());
        assert!(transport.is_retryable());

        let protocol = Error::Protocol("bad frame");
        assert!(protocol.is_protocol_error());
        assert!(!protocol.is_retryable());

        let unsupported = Error::UnsupportedScanFields {
            route: String::from("america/scan"),
            fields: vec![String::from("bad_field")],
        };
        assert!(unsupported.is_unsupported_error());
        assert!(!unsupported.is_retryable());
    }

    #[test]
    fn wrapped_history_download_failures_preserve_helper_behavior() {
        let wrapped = Error::HistoryDownloadFailed {
            symbol: String::from("NASDAQ:AAPL"),
            source: Box::new(Error::ApiStatus {
                status: StatusCode::TOO_MANY_REQUESTS,
                body: String::from("rate limited"),
            }),
        };

        assert!(wrapped.is_rate_limited());
        assert!(wrapped.is_retryable());
    }
}