tradestation-api 0.1.0

Complete TradeStation REST API v3 wrapper for Rust
Documentation
mod helpers;

use futures::StreamExt;
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};

#[tokio::test]
async fn test_stream_quotes() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    // Simulate newline-delimited JSON streaming response
    let streaming_body = concat!(
        r#"{"Symbol":"AAPL","Last":"185.50","Ask":"185.55","Bid":"185.45","Volume":"45000000"}"#,
        "\n",
        r#"{"Symbol":"AAPL","Last":"185.60","Ask":"185.65","Bid":"185.55","Volume":"45010000"}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/stream/quotes/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client.stream_quotes(&["AAPL"]).await.unwrap();
    let quotes: Vec<_> = stream
        .take(3)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(quotes.len(), 3);

    // First quote
    assert_eq!(quotes[0].symbol.as_deref(), Some("AAPL"));
    assert_eq!(quotes[0].last.as_deref(), Some("185.50"));
    assert!(!quotes[0].is_status());

    // Second quote
    assert_eq!(quotes[1].last.as_deref(), Some("185.60"));

    // Status message
    assert!(quotes[2].is_status());
    assert_eq!(quotes[2].status.as_deref(), Some("EndSnapshot"));
}

#[tokio::test]
async fn test_stream_bars() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    let streaming_body = concat!(
        r#"{"High":"185.50","Low":"184.20","Open":"184.50","Close":"185.30","TimeStamp":"2026-03-25T14:30:00Z","TotalVolume":"1250000"}"#,
        "\n",
        r#"{"High":"185.80","Low":"185.10","Open":"185.30","Close":"185.60","TimeStamp":"2026-03-25T14:31:00Z","TotalVolume":"980000"}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/stream/barcharts/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client.stream_bars("AAPL", "1", "Minute").await.unwrap();
    let bars: Vec<_> = stream
        .take(3)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(bars.len(), 3);

    // First bar
    assert_eq!(bars[0].close.as_deref(), Some("185.30"));
    assert_eq!(bars[0].total_volume.as_deref(), Some("1250000"));
    assert!(!bars[0].is_status());

    // Second bar
    assert_eq!(bars[1].open.as_deref(), Some("185.30"));
    assert_eq!(bars[1].close.as_deref(), Some("185.60"));

    // Status message
    assert!(bars[2].is_status());
}

#[tokio::test]
async fn test_stream_market_depth_quotes() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    let streaming_body = concat!(
        r#"{"Symbol":"AAPL","Ask":"185.55","AskSize":"200","Bid":"185.45","BidSize":"300","Side":"Buy"}"#,
        "\n",
        r#"{"Symbol":"AAPL","Ask":"185.60","AskSize":"150","Bid":"185.50","BidSize":"250","Side":"Sell"}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/stream/marketdepth/quotes/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client.stream_market_depth_quotes("AAPL").await.unwrap();
    let items: Vec<_> = stream
        .take(3)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(items.len(), 3);
    assert_eq!(items[0].symbol.as_deref(), Some("AAPL"));
    assert_eq!(items[0].ask.as_deref(), Some("185.55"));
    assert_eq!(items[0].bid.as_deref(), Some("185.45"));
    assert!(!items[0].is_status());
    assert!(items[2].is_status());
}

#[tokio::test]
async fn test_stream_market_depth_aggregates() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    let streaming_body = concat!(
        r#"{"Symbol":"AAPL","TotalAskSize":"5000","TotalBidSize":"6000","Levels":10}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/stream/marketdepth/aggregates/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client.stream_market_depth_aggregates("AAPL").await.unwrap();
    let items: Vec<_> = stream
        .take(2)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(items.len(), 2);
    assert_eq!(items[0].symbol.as_deref(), Some("AAPL"));
    assert_eq!(items[0].total_ask_size.as_deref(), Some("5000"));
    assert_eq!(items[0].total_bid_size.as_deref(), Some("6000"));
    assert_eq!(items[0].levels, Some(10));
    assert!(!items[0].is_status());
    assert!(items[1].is_status());
}

#[tokio::test]
async fn test_stream_option_chains() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    let streaming_body = concat!(
        r#"{"Symbol":"AAPL 260417C185","Underlying":"AAPL","Type":"Call","StrikePrice":"185.00","ExpirationDate":"2026-04-17","Bid":"5.20","Ask":"5.40","Last":"5.30"}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/stream/options/chains/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client.stream_option_chains("AAPL").await.unwrap();
    let items: Vec<_> = stream
        .take(2)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(items.len(), 2);
    assert_eq!(items[0].symbol.as_deref(), Some("AAPL 260417C185"));
    assert_eq!(items[0].underlying.as_deref(), Some("AAPL"));
    assert_eq!(items[0].option_type.as_deref(), Some("Call"));
    assert_eq!(items[0].strike_price.as_deref(), Some("185.00"));
    assert!(!items[0].is_status());
    assert!(items[1].is_status());
}

#[tokio::test]
async fn test_stream_option_quotes() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    let streaming_body = concat!(
        r#"{"Symbol":"AAPL260417C185","Bid":"5.20","Ask":"5.40","Last":"5.30","Volume":"1200","OpenInterest":"5000"}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/stream/options/quotes/AAPL260417C185"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client
        .stream_option_quotes(&["AAPL260417C185"])
        .await
        .unwrap();
    let items: Vec<_> = stream
        .take(2)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(items.len(), 2);
    assert_eq!(items[0].symbol.as_deref(), Some("AAPL260417C185"));
    assert_eq!(items[0].bid.as_deref(), Some("5.20"));
    assert_eq!(items[0].ask.as_deref(), Some("5.40"));
    assert_eq!(items[0].volume.as_deref(), Some("1200"));
    assert_eq!(items[0].open_interest.as_deref(), Some("5000"));
    assert!(!items[0].is_status());
    assert!(items[1].is_status());
}

#[tokio::test]
async fn test_stream_orders() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    let streaming_body = concat!(
        r#"{"OrderId":"ORD001","AccountId":"123","Symbol":"AAPL","Quantity":"100","OrderType":"Limit","OrderStatus":"Open","FilledQuantity":"0"}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/brokerage/stream/accounts/123/orders"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client.stream_orders(&["123"]).await.unwrap();
    let items: Vec<_> = stream
        .take(2)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(items.len(), 2);
    assert_eq!(items[0].order_id.as_deref(), Some("ORD001"));
    assert_eq!(items[0].symbol.as_deref(), Some("AAPL"));
    assert_eq!(items[0].order_type.as_deref(), Some("Limit"));
    assert!(!items[0].is_status());
    assert!(items[1].is_status());
}

#[tokio::test]
async fn test_stream_orders_by_id() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    let streaming_body = concat!(
        r#"{"OrderId":"ORD001","AccountId":"123","Symbol":"AAPL","Quantity":"100","OrderType":"Limit","OrderStatus":"Open","FilledQuantity":"0"}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/brokerage/stream/accounts/123/orders/ORD001"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client
        .stream_orders_by_id(&["123"], &["ORD001"])
        .await
        .unwrap();
    let items: Vec<_> = stream
        .take(2)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(items.len(), 2);
    assert_eq!(items[0].order_id.as_deref(), Some("ORD001"));
    assert!(!items[0].is_status());
    assert!(items[1].is_status());
}

#[tokio::test]
async fn test_stream_positions() {
    let server = helpers::setup_mock_server().await;
    let mut client = helpers::mock_client(&server);

    let streaming_body = concat!(
        r#"{"AccountId":"123","Symbol":"AAPL","Quantity":"100","AveragePrice":"180.50","Last":"185.30","UnrealizedProfitLoss":"480.00"}"#,
        "\n",
        r#"{"Status":"EndSnapshot"}"#,
        "\n",
    );

    Mock::given(method("GET"))
        .and(path("/v3/brokerage/stream/accounts/123/positions"))
        .respond_with(ResponseTemplate::new(200).set_body_raw(
            streaming_body,
            "application/vnd.tradestation.streams.v3+json",
        ))
        .mount(&server)
        .await;

    let stream = client.stream_positions(&["123"]).await.unwrap();
    let items: Vec<_> = stream
        .take(2)
        .collect::<Vec<_>>()
        .await
        .into_iter()
        .collect::<Result<Vec<_>, _>>()
        .unwrap();

    assert_eq!(items.len(), 2);
    assert_eq!(items[0].account_id.as_deref(), Some("123"));
    assert_eq!(items[0].symbol.as_deref(), Some("AAPL"));
    assert_eq!(items[0].quantity.as_deref(), Some("100"));
    assert_eq!(items[0].unrealized_profit_loss.as_deref(), Some("480.00"));
    assert!(!items[0].is_status());
    assert!(items[1].is_status());
}