use chrono::{NaiveDate, NaiveDateTime};
use reqwest::Client as ReqwestClient;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::time::Duration;
use urlencoding::encode;
pub type Row = Map<String, Value>;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("API error {status}: {detail}")]
Api { status: u16, detail: String },
#[error("HTTP error")]
Http(#[from] reqwest::Error),
#[error("Decode error: {0}")]
Decode(String),
}
#[derive(Debug, Clone, serde::Deserialize, Default)]
pub struct Response<T> {
#[serde(default = "Vec::new")]
pub data: Vec<T>,
#[serde(default)]
pub count: Option<u64>,
#[serde(default)]
pub history_note: Option<String>,
#[serde(default)]
pub upgrade: Option<Value>,
}
impl<T> Response<T> {
pub fn into_data(self) -> Vec<T> { self.data }
pub fn is_empty(&self) -> bool { self.data.is_empty() }
pub fn len(&self) -> usize { self.data.len() }
}
#[derive(Debug, Clone, serde::Deserialize, Default)]
pub struct ClientBuilder {
api_key: Option<String>,
base_url: Option<String>,
timeout: Duration,
}
impl ClientBuilder {
pub fn new() -> Self { Self { api_key: None, base_url: None, timeout: Duration::from_secs(60) } }
pub fn api_key<S: Into<String>>(mut self, k: S) -> Self { self.api_key = Some(k.into()); self }
pub fn base_url<S: Into<String>>(mut self, u: S) -> Self { self.base_url = Some(u.into()); self }
pub fn timeout(mut self, d: Duration) -> Self { self.timeout = d; self }
pub fn build(self) -> Result<Client> {
let base = self.base_url.unwrap_or_else(|| std::env::var("FINZDATA_URL").unwrap_or_else(|_| "https://api.finzdata.com".to_string()));
let api_key = self.api_key.or_else(|| std::env::var("FINZDATA_API_KEY").ok());
let http = ReqwestClient::builder().timeout(self.timeout).user_agent(format!("finzdata-rust/{}", env!("CARGO_PKG_VERSION"))).build()?;
Ok(Client { http, base_url: base, api_key })
}
}
#[derive(Clone)]
pub struct Client { http: ReqwestClient, base_url: String, api_key: Option<String> }
#[allow(clippy::too_many_arguments)]
impl Client {
pub fn new() -> Result<Self> { Self::builder().build() }
pub fn builder() -> ClientBuilder { ClientBuilder::new() }
fn build_url(&self, path: &str, params: &[(&str, Option<String>)]) -> String {
let mut url = format!("{}{}", self.base_url, path);
let qs: Vec<String> = params.iter().filter_map(|(k,v)| v.as_ref().map(|v| format!("{}={}", k, encode(v)))).collect();
if !qs.is_empty() { url.push('?'); url.push_str(&qs.join("&")); }
url
}
async fn request<T: serde::de::DeserializeOwned>(&self, path: &str, params: &[(&str, Option<String>)]) -> Result<Response<T>> {
let url = self.build_url(path, params);
let mut req = self.http.get(&url);
if let Some(k) = &self.api_key { req = req.header("X-API-Key", k); }
let resp = req.send().await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
if (200..300).contains(&status) {
let v: Response<T> = serde_json::from_str(&text).map_err(|e| Error::Decode(e.to_string()))?;
Ok(v)
} else {
let detail = parse_detail(&text);
Err(Error::Api { status, detail })
}
}
async fn request_json(&self, path: &str, params: &[(&str, Option<String>)]) -> Result<Value> {
let url = self.build_url(path, params);
let mut req = self.http.get(&url);
if let Some(k) = &self.api_key { req = req.header("X-API-Key", k); }
let resp = req.send().await?;
let status = resp.status().as_u16();
let text = resp.text().await?;
if (200..300).contains(&status) {
serde_json::from_str(&text).map_err(|e| Error::Decode(e.to_string()))
} else {
let detail = parse_detail(&text);
Err(Error::Api { status, detail })
}
}
pub async fn get<T: serde::de::DeserializeOwned>(&self, path: &str, params: &[(&str, Option<String>)]) -> Result<Response<T>> { self.request(path, params).await }
pub async fn get_json(&self, path: &str, params: &[(&str, Option<String>)]) -> Result<Value> { self.request_json(path, params).await }
pub async fn prices(&self, symbol: &str, start: Option<&str>, end: Option<&str>, adjust: Option<&str>) -> Result<Response<Bar>> {
self.get("/v1/prices/daily", &[("symbol", Some(symbol.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string())), ("adjust", adjust.map(|s| s.to_string()))]).await
}
pub async fn minutes(&self, symbol: &str, start: Option<&str>, end: Option<&str>, interval: Option<&str>, adjust: Option<&str>) -> Result<Response<MinuteBar>> {
self.get("/v1/prices/minute", &[("symbol", Some(symbol.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string())), ("interval", interval.map(|s| s.to_string())), ("adjust", adjust.map(|s| s.to_string()))]).await
}
pub async fn futures_daily(&self, root: &str, contract: Option<&str>, continuous: bool, adjust: Option<&str>, start: Option<&str>, end: Option<&str>) -> Result<Response<FuturesDay>> {
self.get("/v1/futures/daily", &[("root", Some(root.to_string())), ("contract", contract.map(|s| s.to_string())), ("continuous", Some(continuous.to_string())), ("adjust", adjust.map(|s| s.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string()))]).await
}
pub async fn futures_bars(&self, root: &str, contract: Option<&str>, continuous: bool, adjust: Option<&str>, interval: Option<&str>, start: Option<&str>, end: Option<&str>) -> Result<Response<Row>> {
self.get("/v1/futures/bars", &[("root", Some(root.to_string())), ("contract", contract.map(|s| s.to_string())), ("continuous", Some(continuous.to_string())), ("adjust", adjust.map(|s| s.to_string())), ("interval", interval.map(|s| s.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string()))]).await
}
pub async fn futures_roots(&self) -> Result<Response<Row>> { self.get("/v1/futures/roots", &[]).await }
pub async fn futures_contracts(&self, root: &str) -> Result<Response<Row>> { self.get("/v1/futures/contracts", &[("root", Some(root.to_string()))]).await }
pub async fn gex(&self, ticker: &str, start: Option<&str>, end: Option<&str>) -> Result<Response<GexDay>> {
self.get("/v1/gex/daily", &[("ticker", Some(ticker.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string()))]).await
}
pub async fn gex_strikes(&self, ticker: &str, date: &str) -> Result<Response<GexStrike>> {
self.get("/v1/gex/strikes", &[("ticker", Some(ticker.to_string())), ("date", Some(date.to_string()))]).await
}
pub async fn gex_tickers(&self) -> Result<Response<Row>> { self.get("/v1/gex/tickers", &[]).await }
pub async fn cot(&self, report: Option<&str>, code: Option<&str>, market: Option<&str>, start: Option<&str>, end: Option<&str>) -> Result<Response<Row>> {
self.get("/v1/cot", &[("report", report.map(|s| s.to_string())), ("code", code.map(|s| s.to_string())), ("market", market.map(|s| s.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string()))]).await
}
pub async fn cot_markets(&self, report: Option<&str>) -> Result<Response<Row>> { self.get("/v1/cot/markets", &[("report", report.map(|s| s.to_string()))]).await }
pub async fn macro_obs(&self, series: &str, start: Option<&str>, end: Option<&str>, vintage: Option<&str>, as_of: Option<&str>) -> Result<Response<MacroObs>> {
self.get("/v1/macro", &[("series", Some(series.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string())), ("vintage", vintage.map(|s| s.to_string())), ("as_of", as_of.map(|s| s.to_string()))]).await
}
pub async fn macro_series(&self, category: Option<&str>) -> Result<Response<Row>> { self.get("/v1/macro/series", &[("category", category.map(|s| s.to_string()))]).await }
pub async fn fundamentals(&self, ticker: Option<&str>, cik: Option<&str>, concept: Option<&str>, as_of: Option<&str>) -> Result<Response<Fundamental>> {
self.get("/v1/fundamentals", &[("ticker", ticker.map(|s| s.to_string())), ("cik", cik.map(|s| s.to_string())), ("concept", concept.map(|s| s.to_string())), ("as_of", as_of.map(|s| s.to_string()))]).await
}
pub async fn holders(&self, ticker: Option<&str>, period: Option<&str>, limit: Option<u32>) -> Result<Response<Row>> {
self.get("/v1/13f/holders", &[("ticker", ticker.map(|s| s.to_string())), ("period", period.map(|s| s.to_string())), ("limit", limit.map(|n| n.to_string()))]).await
}
pub async fn liquidity(&self, series: &[&str], start: Option<&str>, end: Option<&str>, transform: Option<&str>) -> Result<Response<Row>> {
let s = series.join(",");
self.get("/v1/liquidity", &[("series", Some(s)), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string())), ("transform", transform.map(|s| s.to_string()))]).await
}
pub async fn model_latest(&self) -> Result<Value> { self.request_json("/v1/model/latest", &[]).await }
pub async fn me(&self) -> Result<Me> {
let v = self.request_json("/v1/me", &[]).await?;
serde_json::from_value(v).map_err(|e| Error::Decode(e.to_string()))
}
pub async fn fx_bars(&self, symbol: &str, interval: Option<&str>, start: Option<&str>, end: Option<&str>) -> Result<Response<Row>> {
self.get("/v1/fx/bars", &[("symbol", Some(symbol.to_string())), ("interval", interval.map(|s| s.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string()))]).await
}
pub async fn fx_pairs(&self) -> Result<Response<Row>> { self.get("/v1/fx/pairs", &[]).await }
pub async fn index_bars(&self, symbol: &str, interval: Option<&str>, start: Option<&str>, end: Option<&str>) -> Result<Response<Row>> {
self.get("/v1/index/bars", &[("symbol", Some(symbol.to_string())), ("interval", interval.map(|s| s.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string()))]).await
}
pub async fn index_symbols(&self) -> Result<Response<Row>> { self.get("/v1/index/symbols", &[]).await }
pub async fn crypto_bars(&self, symbol: &str, interval: Option<&str>, start: Option<&str>, end: Option<&str>) -> Result<Response<Row>> {
self.get("/v1/crypto/bars", &[("symbol", Some(symbol.to_string())), ("interval", interval.map(|s| s.to_string())), ("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string()))]).await
}
pub async fn crypto_pairs(&self) -> Result<Response<Row>> { self.get("/v1/crypto/pairs", &[]).await }
pub async fn ticks(&self, kind: &str, symbol: &str, day: &str) -> Result<Response<Row>> {
self.get(&format!("/v1/ticks/{}", kind), &[("symbol", Some(symbol.to_string())), ("day", Some(day.to_string()))]).await
}
pub async fn pulse(&self, name: &str, start: Option<&str>, end: Option<&str>) -> Result<Response<Row>> {
self.get(&format!("/v1/pulse/{}", name), &[("start", start.map(|s| s.to_string())), ("end", end.map(|s| s.to_string()))]).await
}
pub async fn datasets(&self) -> Result<Response<DatasetInfo>> { self.get("/v1/datasets", &[]).await }
}
fn parse_detail(text: &str) -> String {
if let Ok(v) = serde_json::from_str::<Value>(text) {
if let Some(s) = v.get("detail").and_then(|d| d.as_str()) { return s.to_string(); }
}
text.to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Bar {
pub symbol: String,
pub date: NaiveDate,
#[serde(default)]
pub open: Option<f64>,
#[serde(default)]
pub high: Option<f64>,
#[serde(default)]
pub low: Option<f64>,
#[serde(default)]
pub close: Option<f64>,
#[serde(default)]
pub volume: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MinuteBar {
pub symbol: String,
pub ts: NaiveDateTime,
#[serde(default)]
pub open: Option<f64>,
#[serde(default)]
pub high: Option<f64>,
#[serde(default)]
pub low: Option<f64>,
#[serde(default)]
pub close: Option<f64>,
#[serde(default)]
pub volume: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FuturesDay {
pub root: String,
#[serde(deserialize_with = "date_flex")]
pub date: NaiveDate,
#[serde(default)]
pub contract: Option<String>,
#[serde(default)]
pub open: Option<f64>,
#[serde(default)]
pub high: Option<f64>,
#[serde(default)]
pub low: Option<f64>,
#[serde(default)]
pub close: Option<f64>,
#[serde(default)]
pub volume: Option<f64>,
#[serde(default)]
pub open_interest: Option<f64>,
#[serde(default)]
pub status: Option<String>,
#[serde(flatten)]
pub extra: Row,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GexDay {
pub ticker: String,
pub date: NaiveDate,
#[serde(default)]
pub knowledge_time: Option<String>,
#[serde(default)]
pub spot: Option<f64>,
#[serde(default)]
pub net_gex_bn: Option<f64>,
#[serde(default)]
pub call_gex_bn: Option<f64>,
#[serde(default)]
pub put_gex_bn: Option<f64>,
#[serde(default)]
pub flip: Option<f64>,
#[serde(default)]
pub call_wall: Option<f64>,
#[serde(default)]
pub put_wall: Option<f64>,
#[serde(default)]
pub call_oi: Option<f64>,
#[serde(default)]
pub put_oi: Option<f64>,
#[serde(default)]
pub pcr_oi: Option<f64>,
#[serde(default)]
pub atm_iv_pct: Option<f64>,
#[serde(default)]
pub n_contracts: Option<f64>,
#[serde(default)]
pub chain_coverage: Option<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(flatten)]
pub extra: Row,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GexStrike {
pub ticker: String,
pub date: NaiveDate,
pub strike: f64,
#[serde(default)]
pub call_gex_bn: Option<f64>,
#[serde(default)]
pub put_gex_bn: Option<f64>,
#[serde(default)]
pub net_gex_bn: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MacroObs {
pub series_id: String,
pub date: NaiveDate,
#[serde(default)]
pub value: Option<f64>,
#[serde(default)]
pub realtime_start: Option<String>,
#[serde(default)]
pub realtime_end: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Fundamental {
#[serde(default)]
pub cik: Option<u64>,
#[serde(default)]
pub entity: Option<String>,
#[serde(default)]
pub taxonomy: Option<String>,
#[serde(default)]
pub concept: Option<String>,
#[serde(default)]
pub unit: Option<String>,
#[serde(default)]
pub value: Option<f64>,
#[serde(default)]
pub start: Option<NaiveDate>,
#[serde(default)]
pub end: Option<NaiveDate>,
#[serde(default)]
pub fy: Option<f64>,
#[serde(default)]
pub fp: Option<String>,
#[serde(default)]
pub form: Option<String>,
#[serde(default)]
pub filed: Option<NaiveDate>,
#[serde(default)]
pub accn: Option<String>,
#[serde(default)]
pub frame: Option<String>,
#[serde(flatten)]
pub extra: Row,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Me {
#[serde(default)]
pub owner: Option<String>,
#[serde(default)]
pub tier: Option<String>,
#[serde(default)]
pub plan: Option<String>,
#[serde(default)]
pub calls_today: Option<u64>,
#[serde(default)]
pub daily_limit: Option<u64>,
#[serde(flatten)]
pub extra: Row,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DatasetInfo {
pub dataset: String,
#[serde(default)]
pub min_tier: Option<String>,
#[serde(default)]
pub allowed: Option<bool>,
}
#[allow(clippy::too_many_arguments)]
pub mod blocking {
use super::*;
macro_rules! mirror {
($( $name:ident ( $($arg:ident : $ty:ty),* ) -> $ret:ty ;)*) => {
$( #[doc = concat!("Blocking version of [`crate::Client::", stringify!($name), "`].")]
pub fn $name(&self $(, $arg: $ty)*) -> Result<$ret> { self.rt.block_on(self.inner.$name($($arg),*)) } )*
};
}
pub struct Client { rt: tokio::runtime::Runtime, inner: super::Client }
impl Client {
pub fn new() -> Result<Self> { Self::from_async(super::Client::new()?) }
pub fn from_async(inner: super::Client) -> Result<Self> {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()
.map_err(|e| Error::Decode(e.to_string()))?;
Ok(Self { rt, inner })
}
pub fn get<T: serde::de::DeserializeOwned>(&self, path: &str, params: &[(&str, Option<String>)]) -> Result<Response<T>> {
self.rt.block_on(self.inner.get(path, params))
}
mirror! {
get_json(path: &str, params: &[(&str, Option<String>)]) -> Value;
prices(symbol: &str, start: Option<&str>, end: Option<&str>, adjust: Option<&str>) -> Response<Bar>;
minutes(symbol: &str, start: Option<&str>, end: Option<&str>, interval: Option<&str>, adjust: Option<&str>) -> Response<MinuteBar>;
futures_daily(root: &str, contract: Option<&str>, continuous: bool, adjust: Option<&str>, start: Option<&str>, end: Option<&str>) -> Response<FuturesDay>;
futures_bars(root: &str, contract: Option<&str>, continuous: bool, adjust: Option<&str>, interval: Option<&str>, start: Option<&str>, end: Option<&str>) -> Response<Row>;
futures_roots() -> Response<Row>;
futures_contracts(root: &str) -> Response<Row>;
gex(ticker: &str, start: Option<&str>, end: Option<&str>) -> Response<GexDay>;
gex_strikes(ticker: &str, date: &str) -> Response<GexStrike>;
gex_tickers() -> Response<Row>;
cot(report: Option<&str>, code: Option<&str>, market: Option<&str>, start: Option<&str>, end: Option<&str>) -> Response<Row>;
cot_markets(report: Option<&str>) -> Response<Row>;
macro_obs(series: &str, start: Option<&str>, end: Option<&str>, vintage: Option<&str>, as_of: Option<&str>) -> Response<MacroObs>;
macro_series(category: Option<&str>) -> Response<Row>;
fundamentals(ticker: Option<&str>, cik: Option<&str>, concept: Option<&str>, as_of: Option<&str>) -> Response<Fundamental>;
holders(ticker: Option<&str>, period: Option<&str>, limit: Option<u32>) -> Response<Row>;
liquidity(series: &[&str], start: Option<&str>, end: Option<&str>, transform: Option<&str>) -> Response<Row>;
model_latest() -> Value;
me() -> Me;
datasets() -> Response<DatasetInfo>;
fx_bars(symbol: &str, interval: Option<&str>, start: Option<&str>, end: Option<&str>) -> Response<Row>;
fx_pairs() -> Response<Row>;
index_bars(symbol: &str, interval: Option<&str>, start: Option<&str>, end: Option<&str>) -> Response<Row>;
index_symbols() -> Response<Row>;
crypto_bars(symbol: &str, interval: Option<&str>, start: Option<&str>, end: Option<&str>) -> Response<Row>;
crypto_pairs() -> Response<Row>;
ticks(kind: &str, symbol: &str, day: &str) -> Response<Row>;
pulse(name: &str, start: Option<&str>, end: Option<&str>) -> Response<Row>;
}
}
}
fn date_flex<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<NaiveDate, D::Error> {
let s = <String as serde::Deserialize>::deserialize(d)?;
NaiveDate::parse_from_str(s.get(..10).unwrap_or(&s), "%Y-%m-%d").map_err(serde::de::Error::custom)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_builder() {
let c = Client { http: ReqwestClient::new(), base_url: "https://api".into(), api_key: None };
let u = c.build_url("/p", &[("a", Some("a b,c".into())), ("b", None)]);
assert!(u.contains("a=a%20b%2Cc"));
assert!(!u.contains("b="));
}
#[test]
fn bar_deser() {
let s = r#"{"count":1,"data":[{"symbol":"AAPL","date":"2026-09-01","open":316.98,"high":327.3,"low":314.73,"close":325.13,"volume":53167388.0}]}"#;
let r: Response<Bar> = serde_json::from_str(s).unwrap();
assert_eq!(r.len(),1);
}
#[test]
fn minute_deser() {
let s = r#"{"data":[{"symbol":"A","ts":"2026-09-02T04:00:00","open":1.0,"high":2.0,"low":1.0,"close":1.5}]}"#;
let r: Response<MinuteBar> = serde_json::from_str(s).unwrap();
assert_eq!(r.data[0].ts.to_string(),"2026-09-02 04:00:00");
}
#[test]
fn macro_null() {
let s = r#"{"data":[{"series_id":"S","date":"2020-01-01","value":null}]}"#;
let r: Response<MacroObs> = serde_json::from_str(s).unwrap();
assert!(r.data[0].value.is_none());
}
#[test]
fn gex_extra() {
let s = r#"{"history_note":"x","data":[{"ticker":"SPY","date":"2024-01-01","spot":1.0,"net_gex_bn":1.0,"call_gex_bn":1.0,"put_gex_bn":1.0,"flip":1.0,"call_wall":1.0,"put_wall":1.0,"call_oi":1.0,"put_oi":1.0,"pcr_oi":1.0,"unknown":"v"}]}"#;
let r: Response<GexDay> = serde_json::from_str(s).unwrap();
assert_eq!(r.history_note.unwrap(),"x");
assert_eq!(r.data[0].extra.get("unknown").unwrap(),"v");
}
#[test]
fn futures_deser() {
let s = r#"{"data":[{"root":"ES","contract":"ESZ6","date":"2026-10-20","open":1.0,"high":2.0,"low":0.5,"close":1.5,"volume":10,"open_interest":null,"status":"preliminary"}]}"#;
let r: Response<FuturesDay> = serde_json::from_str(s).unwrap();
assert_eq!(r.data[0].status.as_deref(), Some("preliminary"));
assert!(r.data[0].open_interest.is_none());
}
#[test]
fn me_deser() {
let s = r#"{"owner":"x@y.com","tier":"quant","plan":"Look-back","calls_today":10,"daily_limit":null}"#;
let m: Me = serde_json::from_str(s).unwrap();
assert_eq!(m.owner.as_deref(), Some("x@y.com"));
}
#[test]
fn detail_parse() {
assert_eq!(parse_detail(r#"{"detail":"bad key"}"#),"bad key");
assert_eq!(parse_detail("plain"),"plain");
}
}