tradestation-api 0.1.0

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

use tradestation_api::BarChartQuery;
use wiremock::matchers::{method, path};
use wiremock::{Mock, ResponseTemplate};

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

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/barcharts/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "Bars": [
                {
                    "High": "185.50",
                    "Low": "184.20",
                    "Open": "184.50",
                    "Close": "185.30",
                    "TimeStamp": "2026-03-25T14:30:00Z",
                    "TotalVolume": "1250000",
                    "DownTicks": 120,
                    "DownVolume": 400000,
                    "TotalTicks": 300,
                    "UpTicks": 180,
                    "UpVolume": 850000
                },
                {
                    "High": "185.80",
                    "Low": "185.10",
                    "Open": "185.30",
                    "Close": "185.60",
                    "TimeStamp": "2026-03-25T14:31:00Z",
                    "TotalVolume": "980000"
                }
            ]
        })))
        .mount(&server)
        .await;

    let query = BarChartQuery::minute_bars("AAPL", 2);
    let bars = client.get_bars(&query).await.unwrap();

    assert_eq!(bars.len(), 2);
    assert_eq!(bars[0].close, "185.30");
    assert_eq!(bars[0].total_volume, "1250000");
    assert_eq!(bars[1].open, "185.30");

    // Test OHLCV parsing
    let (o, h, l, c, v) = bars[0].ohlcv().unwrap();
    assert!((o - 184.50).abs() < 0.01);
    assert!((h - 185.50).abs() < 0.01);
    assert!((l - 184.20).abs() < 0.01);
    assert!((c - 185.30).abs() < 0.01);
    assert_eq!(v, 1250000);
}

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

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/quotes/AAPL,MSFT"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "Quotes": [
                {
                    "Symbol": "AAPL",
                    "Last": "185.50",
                    "Ask": "185.55",
                    "Bid": "185.45",
                    "Volume": "45000000",
                    "Close": "184.80",
                    "High": "186.00",
                    "Low": "184.20",
                    "Open": "184.90",
                    "NetChange": "0.70",
                    "NetChangePct": "0.38",
                    "TradeTime": "2026-03-25T20:00:00Z"
                },
                {
                    "Symbol": "MSFT",
                    "Last": "420.10",
                    "Ask": "420.15",
                    "Bid": "420.05",
                    "Volume": "22000000"
                }
            ]
        })))
        .mount(&server)
        .await;

    let quotes = client.get_quotes(&["AAPL", "MSFT"]).await.unwrap();

    assert_eq!(quotes.len(), 2);
    assert_eq!(quotes[0].symbol, "AAPL");
    assert_eq!(quotes[0].last, "185.50");
    assert_eq!(quotes[0].ask, "185.55");
    assert_eq!(quotes[0].bid, "185.45");
    assert_eq!(quotes[0].net_change.as_deref(), Some("0.70"));
    assert_eq!(quotes[1].symbol, "MSFT");
    assert_eq!(quotes[1].last, "420.10");
}

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

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/symbols/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "Definitions": [
                {
                    "Symbol": "AAPL",
                    "Description": "Apple Inc",
                    "Exchange": "NASDAQ",
                    "Category": "Stock",
                    "Currency": "USD",
                    "PointValue": "1",
                    "MinMove": "0.01"
                }
            ]
        })))
        .mount(&server)
        .await;

    let symbols = client.get_symbol_info(&["AAPL"]).await.unwrap();

    assert_eq!(symbols.len(), 1);
    assert_eq!(symbols[0].symbol, "AAPL");
    assert_eq!(symbols[0].description.as_deref(), Some("Apple Inc"));
    assert_eq!(symbols[0].exchange.as_deref(), Some("NASDAQ"));
    assert_eq!(symbols[0].category.as_deref(), Some("Stock"));
    assert_eq!(symbols[0].currency.as_deref(), Some("USD"));
}

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

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/symbollists/cryptopairs/symbolnames"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "CryptoPairs": ["BTC/USD", "ETH/USD", "LTC/USD"]
        })))
        .mount(&server)
        .await;

    let pairs = client.get_crypto_pairs().await.unwrap();

    assert_eq!(pairs.len(), 3);
    assert_eq!(pairs[0], "BTC/USD");
    assert_eq!(pairs[1], "ETH/USD");
    assert_eq!(pairs[2], "LTC/USD");
}

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

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/options/expirations/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "Expirations": [
                { "Date": "2026-04-17", "Type": "Monthly" },
                { "Date": "2026-04-24", "Type": "Weekly" }
            ]
        })))
        .mount(&server)
        .await;

    let expirations = client.get_option_expirations("AAPL").await.unwrap();

    assert_eq!(expirations.len(), 2);
    assert_eq!(expirations[0].date, "2026-04-17");
    assert_eq!(expirations[0].expiration_type.as_deref(), Some("Monthly"));
    assert_eq!(expirations[1].date, "2026-04-24");
    assert_eq!(expirations[1].expiration_type.as_deref(), Some("Weekly"));
}

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

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/options/strikes/AAPL"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "Strikes": [
                { "StrikePrice": "150.00" },
                { "StrikePrice": "155.00" },
                { "StrikePrice": "160.00" }
            ]
        })))
        .mount(&server)
        .await;

    let strikes = client.get_option_strikes("AAPL").await.unwrap();

    assert_eq!(strikes.len(), 3);
    assert_eq!(strikes[0].strike_price, "150.00");
    assert_eq!(strikes[1].strike_price, "155.00");
    assert_eq!(strikes[2].strike_price, "160.00");
}

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

    Mock::given(method("GET"))
        .and(path("/v3/marketdata/options/spreadtypes"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "SpreadTypes": [
                { "Name": "Vertical", "Description": "Vertical spread" },
                { "Name": "Calendar", "Description": "Calendar spread" }
            ]
        })))
        .mount(&server)
        .await;

    let spread_types = client.get_option_spread_types().await.unwrap();

    assert_eq!(spread_types.len(), 2);
    assert_eq!(spread_types[0].name, "Vertical");
    assert_eq!(
        spread_types[0].description.as_deref(),
        Some("Vertical spread")
    );
    assert_eq!(spread_types[1].name, "Calendar");
}

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

    Mock::given(method("POST"))
        .and(path("/v3/marketdata/options/riskreward"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "MaxReward": "500.00",
            "MaxRisk": "250.00",
            "BreakEven": "185.00"
        })))
        .mount(&server)
        .await;

    let request = tradestation_api::RiskRewardRequest {
        symbol: "AAPL".to_string(),
        trade_action: "Buy".to_string(),
        quantity: "1".to_string(),
        limit_price: Some("185.00".to_string()),
        stop_price: None,
    };

    let result = client.get_option_risk_reward(&request).await.unwrap();

    assert_eq!(result.max_reward.as_deref(), Some("500.00"));
    assert_eq!(result.max_risk.as_deref(), Some("250.00"));
    assert_eq!(result.break_even.as_deref(), Some("185.00"));
}