tvdata-rs 0.1.2

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

use serde_json::Value;
use serde_json::json;
use time::macros::datetime;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

use super::*;
use crate::client::Endpoints;

fn encode_row(columns: &[Column], symbol: &str, entries: Vec<(&str, Value)>) -> Value {
    let values = entries
        .into_iter()
        .map(|(column, value)| (column.to_owned(), value))
        .collect::<HashMap<_, _>>();
    let ordered = columns
        .iter()
        .map(|column| values.get(column.as_str()).cloned().unwrap_or(Value::Null))
        .collect::<Vec<_>>();

    json!({
        "s": symbol,
        "d": ordered,
    })
}

fn encode_response(
    columns: &[Column],
    total_count: usize,
    rows: Vec<(&str, Vec<(&str, Value)>)>,
) -> String {
    let data = rows
        .into_iter()
        .map(|(symbol, entries)| encode_row(columns, symbol, entries))
        .collect::<Vec<_>>();

    serde_json::to_string(&json!({
        "totalCount": total_count,
        "data": data,
    }))
    .unwrap()
}

#[tokio::test]
async fn earnings_calendar_clips_to_requested_window() {
    let server = MockServer::start().await;
    let columns = earnings_calendar_columns();
    Mock::given(method("POST"))
        .and(path("/america/scan"))
        .and(body_string_contains(r#""range":[0,200]"#))
        .respond_with(ResponseTemplate::new(200).set_body_string(encode_response(
            &columns,
            4,
            vec![
                (
                    "NASDAQ:PAST",
                    vec![
                        ("name", json!("Past")),
                        ("market", json!("america")),
                        ("exchange", json!("NASDAQ")),
                        ("currency", json!("USD")),
                        ("country", json!("US")),
                        ("type", json!("stock")),
                        ("earnings_release_next_date", json!(1769904000i64)),
                    ],
                ),
                (
                    "NASDAQ:IN1",
                    vec![
                        ("name", json!("In One")),
                        ("market", json!("america")),
                        ("exchange", json!("NASDAQ")),
                        ("currency", json!("USD")),
                        ("country", json!("US")),
                        ("type", json!("stock")),
                        ("earnings_release_next_date", json!(1770249600i64)),
                        ("earnings_release_next_time", json!(1)),
                        ("earnings_per_share_forecast_next_fq", json!(1.23)),
                    ],
                ),
                (
                    "NASDAQ:IN2",
                    vec![
                        ("name", json!("In Two")),
                        ("market", json!("america")),
                        ("exchange", json!("NASDAQ")),
                        ("currency", json!("USD")),
                        ("country", json!("US")),
                        ("type", json!("stock")),
                        ("earnings_release_next_date", json!(1770422400i64)),
                    ],
                ),
                (
                    "NASDAQ:FUTURE",
                    vec![
                        ("name", json!("Future")),
                        ("market", json!("america")),
                        ("exchange", json!("NASDAQ")),
                        ("currency", json!("USD")),
                        ("country", json!("US")),
                        ("type", json!("stock")),
                        ("earnings_release_next_date", json!(1771113600i64)),
                    ],
                ),
            ],
        )))
        .mount(&server)
        .await;

    let client = TradingViewClient::builder()
        .endpoints(
            Endpoints::default()
                .with_scanner_base_url(server.uri())
                .unwrap(),
        )
        .build()
        .unwrap();

    let request = CalendarWindowRequest::new(
        "america",
        datetime!(2026-02-03 00:00 UTC),
        datetime!(2026-02-10 00:00 UTC),
    );

    let entries = client.corporate_earnings_calendar(&request).await.unwrap();

    assert_eq!(entries.len(), 2);
    assert_eq!(entries[0].instrument.ticker.as_str(), "NASDAQ:IN1");
    assert_eq!(entries[1].instrument.ticker.as_str(), "NASDAQ:IN2");
    assert_eq!(entries[0].release_time_code, Some(1));
    assert_eq!(entries[0].eps_forecast_next_fq, Some(1.23));
}

#[tokio::test]
async fn dividend_calendar_uses_requested_effective_date_kind() {
    let server = MockServer::start().await;
    let columns = dividend_calendar_columns();
    Mock::given(method("POST"))
        .and(path("/america/scan"))
        .respond_with(ResponseTemplate::new(200).set_body_string(encode_response(
            &columns,
            1,
            vec![(
                "NYSE:ABC",
                vec![
                    ("name", json!("ABC")),
                    ("market", json!("america")),
                    ("exchange", json!("NYSE")),
                    ("currency", json!("USD")),
                    ("country", json!("US")),
                    ("type", json!("stock")),
                    ("dividend_amount_upcoming", json!(0.42)),
                    ("dividend_yield_upcoming", json!(1.9)),
                    ("ex_dividend_date_upcoming", json!(1771286400i64)),
                    ("payment_date_upcoming", json!(1771718400i64)),
                ],
            )],
        )))
        .mount(&server)
        .await;

    let client = TradingViewClient::builder()
        .endpoints(
            Endpoints::default()
                .with_scanner_base_url(server.uri())
                .unwrap(),
        )
        .build()
        .unwrap();

    let ex_entries = client
        .corporate_dividend_calendar(&DividendCalendarRequest::new(
            "america",
            datetime!(2026-02-10 00:00 UTC),
            datetime!(2026-02-25 00:00 UTC),
        ))
        .await
        .unwrap();
    let payment_entries = client
        .corporate_dividend_calendar(
            &DividendCalendarRequest::new(
                "america",
                datetime!(2026-02-10 00:00 UTC),
                datetime!(2026-02-25 00:00 UTC),
            )
            .date_kind(DividendDateKind::PaymentDate),
        )
        .await
        .unwrap();

    assert_eq!(
        ex_entries[0].effective_date,
        datetime!(2026-02-17 00:00 UTC)
    );
    assert_eq!(
        payment_entries[0].effective_date,
        datetime!(2026-02-22 00:00 UTC)
    );
    assert_eq!(payment_entries[0].amount, Some(0.42));
}

#[tokio::test]
async fn ipo_calendar_scans_descending_but_returns_ascending_window() {
    let server = MockServer::start().await;
    let columns = ipo_calendar_columns();
    Mock::given(method("POST"))
        .and(path("/america/scan"))
        .and(body_string_contains(r#""sortOrder":"desc""#))
        .respond_with(ResponseTemplate::new(200).set_body_string(encode_response(
            &columns,
            4,
            vec![
                (
                    "NASDAQ:FUTURE",
                    vec![
                        ("name", json!("Future")),
                        ("market", json!("america")),
                        ("exchange", json!("NASDAQ")),
                        ("currency", json!("USD")),
                        ("country", json!("US")),
                        ("type", json!("stock")),
                        ("ipo_offer_date", json!(1772323200i64)),
                    ],
                ),
                (
                    "NASDAQ:IN2",
                    vec![
                        ("name", json!("In Two")),
                        ("market", json!("america")),
                        ("exchange", json!("NASDAQ")),
                        ("currency", json!("USD")),
                        ("country", json!("US")),
                        ("type", json!("stock")),
                        ("ipo_offer_date", json!(1771459200i64)),
                        ("ipo_offer_price_usd", json!(15.0)),
                    ],
                ),
                (
                    "NASDAQ:IN1",
                    vec![
                        ("name", json!("In One")),
                        ("market", json!("america")),
                        ("exchange", json!("NASDAQ")),
                        ("currency", json!("USD")),
                        ("country", json!("US")),
                        ("type", json!("stock")),
                        ("ipo_offer_date", json!(1771113600i64)),
                        ("ipo_offer_price_usd", json!(13.5)),
                    ],
                ),
                (
                    "NASDAQ:PAST",
                    vec![
                        ("name", json!("Past")),
                        ("market", json!("america")),
                        ("exchange", json!("NASDAQ")),
                        ("currency", json!("USD")),
                        ("country", json!("US")),
                        ("type", json!("stock")),
                        ("ipo_offer_date", json!(1770595200i64)),
                    ],
                ),
            ],
        )))
        .mount(&server)
        .await;

    let client = TradingViewClient::builder()
        .endpoints(
            Endpoints::default()
                .with_scanner_base_url(server.uri())
                .unwrap(),
        )
        .build()
        .unwrap();

    let request = CalendarWindowRequest::new(
        "america",
        datetime!(2026-02-12 00:00 UTC),
        datetime!(2026-02-25 00:00 UTC),
    )
    .limit(10);

    let entries = client.corporate_ipo_calendar(&request).await.unwrap();

    assert_eq!(entries.len(), 2);
    assert_eq!(entries[0].instrument.ticker.as_str(), "NASDAQ:IN1");
    assert_eq!(entries[1].instrument.ticker.as_str(), "NASDAQ:IN2");
    assert_eq!(entries[0].offer_price_usd, Some(13.5));
    assert_eq!(entries[1].offer_price_usd, Some(15.0));
}

#[tokio::test]
async fn calendar_requests_return_empty_for_zero_limit_or_inverted_window() {
    let client = TradingViewClient::builder().build().unwrap();
    let empty = client
        .corporate_earnings_calendar(
            &CalendarWindowRequest::new(
                "america",
                datetime!(2026-03-01 00:00 UTC),
                datetime!(2026-03-10 00:00 UTC),
            )
            .limit(0),
        )
        .await
        .unwrap();
    assert!(empty.is_empty());

    let inverted = client
        .corporate_ipo_calendar(&CalendarWindowRequest::new(
            "america",
            datetime!(2026-03-10 00:00 UTC),
            datetime!(2026-03-01 00:00 UTC),
        ))
        .await
        .unwrap();
    assert!(inverted.is_empty());
}