tradestation-api 0.1.0

Complete TradeStation REST API v3 wrapper for Rust
Documentation
//! Integration tests against the LIVE TradeStation API.
//!
//! These tests use real API credentials and hit the actual TradeStation
//! (or sim) API. They require:
//! - TSAPI_CLIENT_ID env var
//! - TSAPI_CLIENT_SECRET env var
//! - A valid OAuth2 token (from `tradestation-rs OAuth2 flow`)
//!
//! Run with: cargo test -p tradestation-rs --test integration_live -- --ignored
//! (All tests are #[ignore] by default to prevent accidental API calls)

use chrono::{Duration, Utc};
use std::env;
use tradestation_api::{Client, Credentials, Token};

/// Create a client from environment variables.
/// Uses the stored OAuth2 token if available, otherwise needs manual token.
async fn live_client() -> Option<Client> {
    let client_id = env::var("TSAPI_CLIENT_ID").ok()?;
    let client_secret = env::var("TSAPI_CLIENT_SECRET").ok()?;

    let creds = Credentials::new(client_id, client_secret);
    let mut client = Client::new(creds);

    // Try refresh token first (access tokens expire in 20 min)
    if let Ok(refresh_tok) = env::var("TRADESTATION_REFRESH_TOKEN") {
        let http = reqwest::Client::new();
        let creds_clone = Credentials::new(
            env::var("TSAPI_CLIENT_ID").unwrap(),
            env::var("TSAPI_CLIENT_SECRET").unwrap(),
        );
        match tradestation_api::auth::refresh_token(&http, &creds_clone, &refresh_tok).await {
            Ok(fresh_token) => {
                eprintln!("✅ Token refreshed successfully");
                client = client.with_token(fresh_token);
            }
            Err(e) => {
                eprintln!("⚠️  Token refresh failed: {e}");
                // Fall back to access token from file (may be expired)
                if let Ok(access) = env::var("TRADESTATION_ACCESS_TOKEN") {
                    let tok = Token {
                        access_token: access,
                        refresh_token: Some(refresh_tok),
                        token_type: "Bearer".to_string(),
                        expires_at: Utc::now() + Duration::minutes(19),
                        refresh_expires_at: Some(Utc::now() + Duration::days(30)),
                    };
                    client = client.with_token(tok);
                }
            }
        }
    } else if let Ok(access) = env::var("TRADESTATION_ACCESS_TOKEN") {
        let tok = Token {
            access_token: access,
            refresh_token: None,
            token_type: "Bearer".to_string(),
            expires_at: Utc::now() + Duration::minutes(19),
            refresh_expires_at: None,
        };
        client = client.with_token(tok);
    }

    // ALWAYS use sim API — live API calls are expensive
    // Set TRADESTATION_USE_LIVE=1 to override (dangerous!)
    if env::var("TRADESTATION_USE_LIVE").is_err() {
        client = client.with_sim();
    } else {
        eprintln!("⚠️  WARNING: Running against LIVE TradeStation API!");
    }

    Some(client)
}

// ============================================================
// Market Data Tests — these don't require trading permissions
// ============================================================

#[tokio::test]
#[ignore = "requires live API credentials"]
async fn test_live_get_quotes() {
    let mut client = live_client().await.expect("API credentials not set");
    let quotes = client.get_quotes(&["AAPL"]).await;
    match quotes {
        Ok(q) => {
            assert!(!q.is_empty(), "Expected at least one quote");
            println!("AAPL Last: {}", q[0].last);
            println!("AAPL Bid: {}, Ask: {}", q[0].bid, q[0].ask);
            assert!(!q[0].last.is_empty(), "Last price should not be empty");
            assert!(!q[0].symbol.is_empty(), "Symbol should not be empty");
        }
        Err(e) => {
            // Auth errors are expected if no valid token
            println!("Quote fetch failed (expected if no token): {e}");
        }
    }
}

#[tokio::test]
#[ignore = "requires live API credentials"]
async fn test_live_get_bars() {
    let mut client = live_client().await.expect("API credentials not set");
    let query = tradestation_api::BarChartQuery::daily_bars("AAPL", 5);
    let bars = client.get_bars(&query).await;
    match bars {
        Ok(b) => {
            assert!(!b.is_empty(), "Expected at least one bar");
            println!("Got {} bars for AAPL", b.len());
            for bar in &b {
                let (o, h, l, c, v) = bar.ohlcv().expect("Should parse OHLCV");
                println!(
                    "  {} O:{:.2} H:{:.2} L:{:.2} C:{:.2} V:{}",
                    bar.time_stamp, o, h, l, c, v
                );
                assert!(h >= l, "High should be >= Low");
                assert!(o > 0.0, "Open should be positive");
            }
        }
        Err(e) => println!("Bar fetch failed: {e}"),
    }
}

#[tokio::test]
#[ignore = "requires live API credentials"]
async fn test_live_get_symbol_info() {
    let mut client = live_client().await.expect("API credentials not set");
    let symbols = client.get_symbol_info(&["AAPL", "MSFT"]).await;
    match symbols {
        Ok(s) => {
            assert!(s.len() >= 2, "Expected info for both symbols");
            for sym in &s {
                println!(
                    "{}: {} ({})",
                    sym.symbol,
                    sym.description.as_deref().unwrap_or("?"),
                    sym.exchange.as_deref().unwrap_or("?")
                );
            }
        }
        Err(e) => println!("Symbol info failed: {e}"),
    }
}

// ============================================================
// Brokerage Tests — require ReadAccount scope
// ============================================================

#[tokio::test]
#[ignore = "requires live API credentials + ReadAccount scope"]
async fn test_live_get_accounts() {
    let mut client = live_client().await.expect("API credentials not set");
    let accounts = client.get_accounts().await;
    match accounts {
        Ok(a) => {
            assert!(!a.is_empty(), "Expected at least one account");
            for acct in &a {
                let marker = if acct.account_id.starts_with("SIM") {
                    "✅ SIM"
                } else {
                    "⚠️  LIVE"
                };
                println!(
                    "Account: {} ({}) {}",
                    acct.account_id,
                    acct.account_type.as_deref().unwrap_or("?"),
                    marker
                );
            }
        }
        Err(e) => println!("Accounts failed: {e}"),
    }
}

/// Find a SIM account ID, skipping live accounts.
fn find_sim_account(accounts: &[tradestation_api::Account]) -> Option<&str> {
    // Prefer SIM accounts to avoid touching real money
    accounts
        .iter()
        .find(|a| a.account_id.starts_with("SIM"))
        .map(|a| a.account_id.as_str())
}

#[tokio::test]
#[ignore = "requires live API credentials + ReadAccount scope"]
async fn test_live_get_balances() {
    let mut client = live_client().await.expect("API credentials not set");
    let accounts = client.get_accounts().await;
    match accounts {
        Ok(a) if !a.is_empty() => {
            let id = find_sim_account(&a).unwrap_or_else(|| {
                println!("⚠️  No SIM account, skipping");
                &a[0].account_id
            });
            println!("Using account: {id}");
            let balances = client.get_balances(&[id]).await;
            match balances {
                Ok(b) => {
                    assert!(!b.is_empty(), "Expected balance data");
                    for bal in &b {
                        println!(
                            "Cash: {}, Equity: {}, Buying Power: {}",
                            bal.cash_balance.as_deref().unwrap_or("?"),
                            bal.equity.as_deref().unwrap_or("?"),
                            bal.buying_power.as_deref().unwrap_or("?")
                        );
                    }
                }
                Err(e) => println!("Balances failed: {e}"),
            }
        }
        _ => println!("No accounts available for balance test"),
    }
}

#[tokio::test]
#[ignore = "requires live API credentials + ReadAccount scope"]
async fn test_live_get_positions() {
    let mut client = live_client().await.expect("API credentials not set");
    let accounts = client.get_accounts().await;
    match accounts {
        Ok(a) if !a.is_empty() => {
            let id = find_sim_account(&a).unwrap_or_else(|| {
                println!("⚠️  No SIM account, skipping");
                &a[0].account_id
            });
            println!("Using account: {id}");
            let positions = client.get_positions(&[id]).await;
            match positions {
                Ok(p) => {
                    println!("Open positions: {}", p.len());
                    for pos in &p {
                        println!(
                            "  {} qty:{} avg:{} pnl:{}",
                            pos.symbol.as_deref().unwrap_or("?"),
                            pos.quantity.as_deref().unwrap_or("?"),
                            pos.average_price.as_deref().unwrap_or("?"),
                            pos.unrealized_profit_loss.as_deref().unwrap_or("?")
                        );
                    }
                }
                Err(e) => println!("Positions failed: {e}"),
            }
        }
        _ => println!("No accounts available"),
    }
}

// ============================================================
// Historical Orders — last 60 days
// ============================================================

#[tokio::test]
#[ignore = "requires live API credentials + ReadAccount scope"]
async fn test_live_get_historical_orders() {
    let mut client = live_client().await.expect("API credentials not set");
    let accounts = client.get_accounts().await;
    match accounts {
        Ok(a) if !a.is_empty() => {
            let id = find_sim_account(&a).unwrap_or_else(|| {
                println!("⚠️  No SIM account, skipping");
                &a[0].account_id
            });
            // 60 days back
            let since = (chrono::Utc::now() - chrono::Duration::days(60))
                .format("%Y-%m-%d")
                .to_string();
            println!("Historical orders for {id} since {since}");
            let orders = client.get_historical_orders(&[id], &since).await;
            match orders {
                Ok(o) => {
                    println!("Historical orders: {}", o.len());
                    for ord in o.iter().take(10) {
                        println!(
                            "  {} {} {} qty:{} @ {} [{}]",
                            ord.order_id.as_deref().unwrap_or("?"),
                            ord.trade_action.as_deref().unwrap_or("?"),
                            ord.symbol.as_deref().unwrap_or("?"),
                            ord.quantity.as_deref().unwrap_or("?"),
                            ord.filled_quantity.as_deref().unwrap_or("?"),
                            ord.status_description.as_deref().unwrap_or("?"),
                        );
                    }
                }
                Err(e) => println!("Historical orders failed: {e}"),
            }
        }
        _ => println!("No accounts available"),
    }
}

// ============================================================
// Options Tests — require OptionSpreads scope
// ============================================================

#[tokio::test]
#[ignore = "requires live API credentials"]
async fn test_live_get_option_expirations() {
    let mut client = live_client().await.expect("API credentials not set");
    let exps = client.get_option_expirations("AAPL").await;
    match exps {
        Ok(e) => {
            assert!(!e.is_empty(), "Expected option expirations for AAPL");
            println!("AAPL has {} expiration dates", e.len());
            for exp in e.iter().take(5) {
                println!(
                    "  {} ({})",
                    exp.date,
                    exp.expiration_type.as_deref().unwrap_or("?")
                );
            }
        }
        Err(e) => println!("Option expirations failed: {e}"),
    }
}

// ============================================================
// Streaming Test — requires MarketData scope
// ============================================================

#[tokio::test]
#[ignore = "requires live API credentials + active market"]
async fn test_live_stream_quotes() {
    use futures::StreamExt;

    let mut client = live_client().await.expect("API credentials not set");
    let stream_result = client.stream_quotes(&["AAPL"]).await;
    match stream_result {
        Ok(mut stream) => {
            println!("Connected to quote stream for AAPL");
            let mut count = 0;
            while let Some(event) = stream.next().await {
                match event {
                    Ok(quote) => {
                        if quote.is_status() {
                            println!("  Status: {:?}", quote.status);
                        } else {
                            println!(
                                "  AAPL: Last={} Bid={} Ask={}",
                                quote.last.as_deref().unwrap_or("?"),
                                quote.bid.as_deref().unwrap_or("?"),
                                quote.ask.as_deref().unwrap_or("?")
                            );
                        }
                        count += 1;
                        if count >= 5 {
                            break;
                        } // Just get 5 events
                    }
                    Err(e) => {
                        println!("Stream error: {e}");
                        break;
                    }
                }
            }
            assert!(count > 0, "Should have received at least one stream event");
        }
        Err(e) => println!("Stream connect failed: {e}"),
    }
}