questrade-client 0.2.1

Async Rust client for the Questrade REST API
Documentation

questrade-client

Crates.io Docs.rs CI

Async Rust client for the Questrade REST API.

Handles OAuth token refresh, typed market-data access (quotes, option chains, candles), and account-data access (positions, balances, activities, orders, executions).

Features

  • Automatic OAuth token management with single-use refresh token rotation
  • Token caching to skip OAuth round-trips on subsequent runs
  • Transparent 401 retry — forces a token refresh and retries once on Unauthorized
  • Proactive rate limiting — tracks X-RateLimit-Remaining / X-RateLimit-Reset headers and blocks requests until the window resets, with independent budgets for account and market data categories
  • Rate-limit retry with exponential backoff and jitter on 429 responses (fallback when headers are absent)
  • Fully typed request/response types with serde deserialization
  • tracing instrumented for structured logging at debug/trace levels
  • Raw response logging mode for API debugging

Quick start

Add to your Cargo.toml:

[dependencies]
questrade-client = "0.2.1"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
use questrade_client::{TokenManager, QuestradeClient};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let manager = TokenManager::new(
        "your_refresh_token".to_string(),
        false, // false = live account, true = practice account
        None,  // optional token-refresh callback
        None,  // optional cached token to skip initial refresh
    ).await?;

    let client = QuestradeClient::new(manager)?;
    let accounts = client.get_accounts().await?;
    println!("accounts: {:?}", accounts);
    Ok(())
}

Auth flow

Questrade uses OAuth 2.0 with single-use refresh tokens. Every time you exchange a refresh token for an access token, the old refresh token is invalidated and a new one is returned. If you lose the rotated token, you must generate a new one from the Questrade API Hub.

Token persistence

Pass an OnTokenRefresh callback to TokenManager::new to persist the rotated token after every automatic refresh:

use std::sync::Arc;
use questrade_client::{OnTokenRefresh, TokenManager, CachedToken};

let on_refresh: OnTokenRefresh = Arc::new(|token| {
    // Save token.refresh_token to disk, database, etc.
    // Also save token.access_token + token.api_server for caching
    std::fs::write("/tmp/refresh_token", &token.refresh_token).ok();
});

let manager = TokenManager::new(
    refresh_token,
    false,
    Some(on_refresh),
    None, // or pass a CachedToken to skip the initial refresh
).await?;

To skip the OAuth round-trip on subsequent runs, pass a CachedToken:

let cached = CachedToken {
    access_token: "saved_access_token".to_string(),
    api_server: "https://api01.iq.questrade.com/".to_string(),
    expires_at: saved_expiry,
};

let manager = TokenManager::new(
    refresh_token,
    false,
    Some(on_refresh),
    Some(cached),
).await?;

See the token_manager example for a complete working implementation.

API coverage

Category Method Questrade endpoint
Auth TokenManager::new GET /oauth2/token
Server get_server_time GET /v1/time
Markets get_markets GET /v1/markets
Symbols resolve_symbol GET /v1/symbols/search
Symbols get_symbol GET /v1/symbols/:id
Quotes get_raw_quote GET /v1/markets/quotes/:id
Options get_option_chain_structure GET /v1/symbols/:id/options
Options get_option_quotes_by_ids POST /v1/markets/quotes/options
Options get_option_quotes_raw POST /v1/markets/quotes/options
Candles get_candles GET /v1/markets/candles/:id
Accounts get_accounts GET /v1/accounts
Positions get_positions GET /v1/accounts/:id/positions
Balances get_account_balances GET /v1/accounts/:id/balances
Activities get_activities GET /v1/accounts/:id/activities
Orders get_orders GET /v1/accounts/:id/orders
Executions get_executions GET /v1/accounts/:id/executions
Strategies get_strategy_quotes POST /v1/markets/quotes/strategies
Raw get_text Any GET /v1/* endpoint

Not yet implemented

Endpoint Description Notes
POST /v1/accounts/:id/orders Place a new order Requires trade scope (partner-only)
POST /v1/accounts/:id/orders/:orderId Replace/modify an existing order Requires trade scope (partner-only)
DELETE /v1/accounts/:id/orders/:orderId Cancel an order Requires trade scope (partner-only)
POST /v1/accounts/:id/orders/:orderId/impact Preview order impact (buying power, commission) Requires trade scope (partner-only)

The order management endpoints require the trade OAuth scope, which Questrade restricts to approved partner developers.

Partial coverage notes

  • POST /v1/markets/quotes/options supports two modes: by explicit IDs (implemented) and by filters (underlyingId + expiryDate + optional strike range + option type). Only the ID-based mode is currently exposed.
  • GET /v1/symbols/search is wrapped by resolve_symbol() which returns only the first exact-match symbol ID. The raw search response (multiple partial matches) is not directly exposed as a public method.

Automatic windowing

The get_activities and get_executions methods automatically split date ranges longer than 30 days into compliant sub-windows (Questrade limits queries to 31-day windows). Results are combined and sorted chronologically.

API variances from Questrade docs

A few behaviours differ from the official documentation:

  • Batch endpoints use query params, not path params. The docs show GET /v1/markets/quotes/:id and GET /v1/symbols/:id for single IDs. For multiple IDs, the working form is ?ids=1,2,3 (e.g. /v1/markets/quotes?ids=123,456). Putting comma-separated IDs in the path returns HTTP 400.
  • /symbols is rate-limited under the account bucket, not market data. The docs list /symbols under the 20 req/s / 15k req/hr market data category, but X-RateLimit-Remaining headers empirically return account-bucket values (~30k ceiling) for /symbols calls and market-data values (~15k ceiling) for /markets calls.
  • Rate limit headers only track the per-hour quota. The docs specify both per-second (20–30 req/s) and per-hour (15k–30k req/hr) limits, but X-RateLimit-Remaining / X-RateLimit-Reset reflect only the hourly counter. The per-second limit is not surfaced in headers — you only learn about it from a 429 response. This means the proactive rate limiter only prevents hourly exhaustion; per-second bursts rely on the reactive 429 retry + backoff fallback.

Future: per-second rate limiting

The current implementation has no per-second throttling. Under bursty workloads (e.g. a batch snapshot triggering 36 sequential fallback fetches), we can easily exceed 20 req/s for market data. To fix this, we should add a client-side token bucket or sliding window limiter (one per category) that caps outgoing requests to the documented per-second limits. This would sit alongside the existing hourly header-based limiter and fire before each request, independent of the 429 retry path.

Examples

License

MIT License. See LICENSE for details.