tvdata-rs 0.1.2

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

use time::{Date, Duration as TimeDuration, OffsetDateTime};
use tvdata_rs::prelude::*;

const SYMBOL_COUNT: usize = 1000;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BenchMode {
    All,
    SnapshotOnly,
    HistoryOnly,
}

#[derive(Clone)]
struct SnapshotCase {
    name: &'static str,
    client: TradingViewClient,
}

#[derive(Clone)]
struct HistoryCase {
    name: &'static str,
    client: TradingViewClient,
}

#[tokio::main]
async fn main() -> Result<()> {
    let mode = bench_mode();
    let symbol_count = benchmark_symbol_count();
    let baseline = TradingViewClient::builder().build()?;
    let symbols = load_symbols(&baseline, symbol_count).await?;

    println!("loaded {} equity symbols for benchmarking", symbols.len());

    if matches!(mode, BenchMode::All | BenchMode::SnapshotOnly) {
        let snapshot_cases = snapshot_cases()?;
        println!("\n== Snapshot batch benchmark ==");
        for case in snapshot_cases {
            bench_snapshot_case(&case, &symbols).await?;
        }
    }

    if matches!(mode, BenchMode::All | BenchMode::HistoryOnly) {
        let history_cases = history_cases()?;
        println!("\n== Daily bar benchmark ==");
        for case in history_cases {
            bench_history_case(&case, &symbols).await?;
        }
    }

    Ok(())
}

fn bench_mode() -> BenchMode {
    match std::env::var("TVDATA_BENCH_MODE")
        .unwrap_or_else(|_| String::from("all"))
        .to_ascii_lowercase()
        .as_str()
    {
        "snapshot" | "snapshots" => BenchMode::SnapshotOnly,
        "history" | "daily" | "daily_bars" => BenchMode::HistoryOnly,
        _ => BenchMode::All,
    }
}

fn benchmark_symbol_count() -> usize {
    std::env::var("TVDATA_BENCH_SYMBOLS")
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .filter(|count| *count > 0)
        .unwrap_or(SYMBOL_COUNT)
}

fn snapshot_cases() -> Result<Vec<SnapshotCase>> {
    Ok(vec![
        SnapshotCase {
            name: "auto(default)",
            client: TradingViewClient::builder().build()?,
        },
        SnapshotCase {
            name: "single-request",
            client: TradingViewClient::builder()
                .snapshot_batch_config(
                    SnapshotBatchConfig::builder()
                        .strategy(SnapshotBatchStrategy::SingleRequest)
                        .build(),
                )
                .build()?,
        },
        SnapshotCase {
            name: "chunked-250x4",
            client: TradingViewClient::builder()
                .snapshot_batch_config(
                    SnapshotBatchConfig::builder()
                        .strategy(SnapshotBatchStrategy::Chunked {
                            chunk_size: 250,
                            max_concurrent_requests: 4,
                        })
                        .build(),
                )
                .build()?,
        },
        SnapshotCase {
            name: "chunked-125x4",
            client: TradingViewClient::builder()
                .snapshot_batch_config(
                    SnapshotBatchConfig::builder()
                        .strategy(SnapshotBatchStrategy::Chunked {
                            chunk_size: 125,
                            max_concurrent_requests: 4,
                        })
                        .build(),
                )
                .build()?,
        },
    ])
}

fn history_cases() -> Result<Vec<HistoryCase>> {
    history_concurrencies()
        .into_iter()
        .map(|concurrency| {
            let name = format!("history-{concurrency}");
            let client = TradingViewClient::from_config(
                TradingViewClientConfig::builder()
                    .history(
                        HistoryClientConfig::builder()
                            .default_batch_concurrency(concurrency)
                            .default_session(TradingSession::Regular)
                            .default_adjustment(Adjustment::Splits)
                            .build(),
                    )
                    .request_budget(
                        RequestBudget::builder()
                            .max_concurrent_websocket_sessions(concurrency)
                            .max_concurrent_http_requests(8)
                            .min_http_interval(std::time::Duration::from_millis(50))
                            .build(),
                    )
                    .build(),
            )?;

            Ok(HistoryCase {
                name: Box::leak(name.into_boxed_str()),
                client,
            })
        })
        .collect()
}

fn history_concurrencies() -> Vec<usize> {
    std::env::var("TVDATA_BENCH_HISTORY_CONCURRENCIES")
        .ok()
        .map(|value| {
            value
                .split(',')
                .filter_map(|item| item.trim().parse::<usize>().ok())
                .filter(|value| *value > 0)
                .collect::<Vec<_>>()
        })
        .filter(|values| !values.is_empty())
        .unwrap_or_else(|| vec![2, 4, 6])
}

async fn load_symbols(client: &TradingViewClient, count: usize) -> Result<Vec<String>> {
    let query = ScanQuery::new()
        .market("america")
        .select([fields::core::NAME, fields::fundamentals::MARKET_CAP_BASIC])
        .sort(fields::fundamentals::MARKET_CAP_BASIC.sort(SortOrder::Desc))
        .page(0, count)?;
    let response = client.scan(&query).await?;
    Ok(response.rows.into_iter().map(|row| row.symbol).collect())
}

async fn bench_snapshot_case(case: &SnapshotCase, symbols: &[String]) -> Result<()> {
    let started = Instant::now();
    let quotes = case.client.equity().quotes_batch(symbols.to_vec()).await?;
    let quote_elapsed = started.elapsed();

    let started = Instant::now();
    let fundamentals = case
        .client
        .equity()
        .fundamentals_batch(symbols.to_vec())
        .await?;
    let fundamentals_elapsed = started.elapsed();

    let started = Instant::now();
    let analysts = case
        .client
        .equity()
        .analyst_summaries(symbols.to_vec())
        .await?;
    let analyst_elapsed = started.elapsed();

    let started = Instant::now();
    let technicals = case
        .client
        .equity()
        .technical_summaries(symbols.to_vec())
        .await?;
    let technical_elapsed = started.elapsed();

    let started = Instant::now();
    let overviews = case.client.equity().overviews(symbols.to_vec()).await?;
    let overview_elapsed = started.elapsed();

    println!(
        "{:<18} quotes={:>6}ms ok={} missing={} failed={} fundamentals={:>6}ms rows={} analysts={:>6}ms rows={} technicals={:>6}ms rows={} overviews={:>6}ms rows={}",
        case.name,
        quote_elapsed.as_millis(),
        quotes.successes.len(),
        quotes.missing.len(),
        quotes.failures.len(),
        fundamentals_elapsed.as_millis(),
        fundamentals.len(),
        analyst_elapsed.as_millis(),
        analysts.len(),
        technical_elapsed.as_millis(),
        technicals.len(),
        overview_elapsed.as_millis(),
        overviews.len(),
    );

    Ok(())
}

async fn bench_history_case(case: &HistoryCase, symbols: &[String]) -> Result<()> {
    let asof = benchmark_asof();
    let instruments = symbols
        .iter()
        .map(|ticker| instrument_from_ticker(ticker))
        .collect::<Vec<_>>();
    let request = DailyBarRequest::builder()
        .symbols(instruments)
        .asof(asof)
        .selection(BarSelectionPolicy::LatestOnOrBefore)
        .session(TradingSession::Regular)
        .adjustment(Adjustment::Splits)
        .concurrency(case.client.history_config().default_batch_concurrency)
        .build();

    let started = Instant::now();
    let batch = case.client.daily_bars_on(&request).await?;
    let elapsed = started.elapsed();

    println!(
        "{:<18} daily_bars_on={:>6}ms ok={} missing={} failed={} concurrency={}",
        case.name,
        elapsed.as_millis(),
        batch.successes.len(),
        batch.missing.len(),
        batch.failures.len(),
        case.client.history_config().default_batch_concurrency,
    );

    Ok(())
}

fn instrument_from_ticker(ticker: &str) -> InstrumentRef {
    let (exchange, symbol) = ticker.split_once(':').unwrap_or(("NYSE", ticker));
    InstrumentRef::new(exchange, symbol)
}

fn benchmark_asof() -> Date {
    (OffsetDateTime::now_utc() - TimeDuration::days(1)).date()
}