mod auth;
mod constants;
mod retry;
use crate::core::YfError;
use crate::core::client::constants::DEFAULT_BASE_INSIDER_SEARCH;
use crate::core::currency::currency_for_country;
use paft::money::{Currency, IsoCurrency};
pub use retry::{Backoff, CacheMode, RetryConfig};
use constants::{
DEFAULT_BASE_CHART, DEFAULT_BASE_QUOTE, DEFAULT_BASE_QUOTE_API, DEFAULT_COOKIE_URL,
DEFAULT_CRUMB_URL, USER_AGENT,
};
use reqwest::Client;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use url::Url;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApiPreference {
ApiThenScrape,
ApiOnly,
ScrapeOnly,
}
#[derive(Debug)]
struct CacheEntry {
body: String,
expires_at: Instant,
}
#[derive(Debug)]
struct CacheStore {
map: RwLock<HashMap<String, CacheEntry>>,
default_ttl: Duration,
}
#[derive(Debug, Default)]
struct ClientState {
cookie: Option<String>,
crumb: Option<String>,
}
#[derive(Debug, Clone)]
pub struct YfClient {
http: Client,
base_chart: Url,
base_quote: Url,
base_quote_api: Url,
base_quote_v7: Url,
base_options_v7: Url,
base_stream: Url,
base_news: Url,
base_insider_search: Url,
base_timeseries: Url,
cookie_url: Url,
crumb_url: Url,
user_agent: String,
state: Arc<RwLock<ClientState>>,
credential_fetch_lock: Arc<tokio::sync::Mutex<()>>,
#[cfg(feature = "test-mode")]
api_preference: ApiPreference,
retry: RetryConfig,
reporting_currency_cache: Arc<RwLock<HashMap<String, Currency>>>,
instrument_cache: Arc<RwLock<HashMap<String, paft::domain::Instrument>>>,
cache: Option<Arc<CacheStore>>,
}
impl Default for YfClient {
fn default() -> Self {
Self::builder().build().expect("default client")
}
}
impl YfClient {
#[must_use]
pub fn builder() -> YfClientBuilder {
YfClientBuilder::default()
}
pub(crate) const fn http(&self) -> &Client {
&self.http
}
pub(crate) fn user_agent(&self) -> &str {
&self.user_agent
}
pub(crate) const fn base_chart(&self) -> &Url {
&self.base_chart
}
pub(crate) const fn base_quote(&self) -> &Url {
&self.base_quote
}
pub(crate) const fn base_quote_api(&self) -> &Url {
&self.base_quote_api
}
pub(crate) const fn base_quote_v7(&self) -> &Url {
&self.base_quote_v7
}
pub(crate) const fn base_options_v7(&self) -> &Url {
&self.base_options_v7
}
pub(crate) const fn base_stream(&self) -> &Url {
&self.base_stream
}
pub(crate) const fn base_news(&self) -> &Url {
&self.base_news
}
pub(crate) const fn base_insider_search(&self) -> &Url {
&self.base_insider_search
}
pub(crate) const fn base_timeseries(&self) -> &Url {
&self.base_timeseries
}
#[cfg(feature = "test-mode")]
pub(crate) const fn api_preference(&self) -> ApiPreference {
self.api_preference
}
#[must_use]
pub const fn cache_enabled(&self) -> bool {
self.cache.is_some()
}
pub(crate) async fn cache_get(&self, url: &Url) -> Option<String> {
let store = self.cache.as_ref()?;
let key = url.as_str().to_string();
if let Some(entry) = store.map.read().await.get(&key)
&& Instant::now() <= entry.expires_at
{
return Some(entry.body.clone());
}
None
}
pub(crate) async fn cache_put(&self, url: &Url, body: &str, ttl_override: Option<Duration>) {
let store = match &self.cache {
Some(s) => s.clone(),
None => return,
};
let key = url.as_str().to_string();
let ttl = ttl_override.unwrap_or(store.default_ttl);
let expires_at = Instant::now() + ttl;
let entry = CacheEntry {
body: body.to_string(),
expires_at,
};
let mut guard = store.map.write().await;
guard.insert(key, entry);
}
pub(crate) async fn cached_instrument(&self, key: &str) -> Option<paft::domain::Instrument> {
let guard = self.instrument_cache.read().await;
guard.get(key).cloned()
}
pub(crate) async fn store_instrument(&self, key: String, inst: paft::domain::Instrument) {
let mut guard = self.instrument_cache.write().await;
guard.insert(key, inst);
}
pub async fn clear_cache(&self) {
if let Some(store) = &self.cache {
let mut guard = store.map.write().await;
guard.clear();
}
}
pub async fn invalidate_cache_entry(&self, url: &Url) {
if let Some(store) = &self.cache {
let key = url.as_str().to_string();
let mut guard = store.map.write().await;
guard.remove(&key);
}
}
async fn cached_reporting_currency(&self, symbol: &str) -> Option<Currency> {
let guard = self.reporting_currency_cache.read().await;
guard.get(symbol).cloned()
}
async fn store_reporting_currency(&self, symbol: &str, currency: Currency) {
let mut guard = self.reporting_currency_cache.write().await;
guard.insert(symbol.to_string(), currency);
}
pub(crate) async fn reporting_currency(
&self,
symbol: &str,
override_currency: Option<Currency>,
) -> Currency {
if let Some(currency) = override_currency {
self.store_reporting_currency(symbol, currency.clone())
.await;
return currency;
}
if let Some(currency) = self.cached_reporting_currency(symbol).await {
return currency;
}
let mut debug_reason: Option<String> = None;
let currency = match crate::profile::load_profile(self, symbol).await {
Ok(profile) => extract_currency_from_profile(&profile).map_or_else(
|| {
debug_reason = Some("profile missing country or unsupported currency".into());
Currency::Iso(IsoCurrency::USD)
},
|currency| currency,
),
Err(err) => {
debug_reason = Some(format!("failed to load profile: {err}"));
Currency::Iso(IsoCurrency::USD)
}
};
if let Some(reason) =
debug_reason.filter(|_| std::env::var("YF_DEBUG").ok().as_deref() == Some("1"))
{
eprintln!(
"YF_DEBUG(currency): {symbol} -> {reason}; using {}",
currency.code()
);
}
self.store_reporting_currency(symbol, currency.clone())
.await;
currency
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(
skip(self, req, override_retry),
err,
fields(
url = %{
let b = req
.try_clone()
.expect("cloneable")
.build()
.unwrap();
b.url().clone()
}
)
)
)]
pub(crate) async fn send_with_retry(
&self,
mut req: reqwest::RequestBuilder,
override_retry: Option<&RetryConfig>,
) -> Result<reqwest::Response, reqwest::Error> {
req = req.header("User-Agent", &self.user_agent);
let cfg = override_retry.unwrap_or(&self.retry);
if !cfg.enabled {
return req.send().await;
}
let mut attempt = 0u32;
loop {
let response = req.try_clone().expect("cloneable request").send().await;
match response {
Ok(resp) => {
let code = resp.status().as_u16();
if cfg.retry_on_status.contains(&code) && attempt < cfg.max_retries {
#[cfg(feature = "tracing")]
{
let backoff = compute_backoff_duration(&cfg.backoff, attempt);
tracing::event!(
tracing::Level::INFO,
attempt,
backoff_ms = backoff.as_secs_f64() * 1000.0,
status = code,
"retrying after status"
);
tokio::time::sleep(backoff).await;
}
#[cfg(not(feature = "tracing"))]
{
sleep_backoff(&cfg.backoff, attempt).await;
}
attempt += 1;
continue;
}
return Ok(resp);
}
Err(e) => {
let should_retry = (cfg.retry_on_timeout && e.is_timeout())
|| (cfg.retry_on_connect && e.is_connect());
if should_retry && attempt < cfg.max_retries {
#[cfg(feature = "tracing")]
{
let backoff = compute_backoff_duration(&cfg.backoff, attempt);
tracing::event!(
tracing::Level::INFO,
attempt,
backoff_ms = backoff.as_secs_f64() * 1000.0,
error = %e,
timeout = e.is_timeout(),
connect = e.is_connect(),
"retrying after error"
);
tokio::time::sleep(backoff).await;
}
#[cfg(not(feature = "tracing"))]
{
sleep_backoff(&cfg.backoff, attempt).await;
}
attempt += 1;
continue;
}
return Err(e);
}
}
}
}
#[must_use]
pub const fn retry_config(&self) -> &RetryConfig {
&self.retry
}
}
#[derive(Default)]
pub struct YfClientBuilder {
user_agent: Option<String>,
base_chart: Option<Url>,
base_quote: Option<Url>,
base_quote_api: Option<Url>,
base_quote_v7: Option<Url>,
base_options_v7: Option<Url>,
base_stream: Option<Url>,
base_news: Option<Url>,
base_insider_search: Option<Url>,
base_timeseries: Option<Url>,
cookie_url: Option<Url>,
crumb_url: Option<Url>,
#[allow(dead_code)]
api_preference: Option<ApiPreference>,
#[allow(dead_code)]
preauth_cookie: Option<String>,
#[allow(dead_code)]
preauth_crumb: Option<String>,
timeout: Option<Duration>,
connect_timeout: Option<Duration>,
retry: Option<RetryConfig>,
cache_ttl: Option<Duration>,
custom_client: Option<Client>,
proxy: Option<reqwest::Proxy>,
}
fn extract_currency_from_profile(profile: &crate::profile::Profile) -> Option<Currency> {
match profile {
crate::profile::Profile::Company(company) => company
.address
.as_ref()
.and_then(|addr| addr.country.as_deref())
.and_then(currency_for_country),
crate::profile::Profile::Fund(_) => None,
}
}
impl YfClientBuilder {
#[must_use]
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = Some(ua.into());
self
}
#[must_use]
pub fn base_quote(mut self, url: Url) -> Self {
self.base_quote = Some(url);
self
}
#[must_use]
pub fn base_chart(mut self, url: Url) -> Self {
self.base_chart = Some(url);
self
}
#[must_use]
pub fn base_quote_api(mut self, url: Url) -> Self {
self.base_quote_api = Some(url);
self
}
#[must_use]
pub fn base_quote_v7(mut self, url: Url) -> Self {
self.base_quote_v7 = Some(url);
self
}
#[must_use]
pub fn base_options_v7(mut self, url: Url) -> Self {
self.base_options_v7 = Some(url);
self
}
#[must_use]
pub fn base_stream(mut self, url: Url) -> Self {
self.base_stream = Some(url);
self
}
#[must_use]
pub fn base_news(mut self, url: Url) -> Self {
self.base_news = Some(url);
self
}
#[must_use]
pub fn base_insider_search(mut self, url: Url) -> Self {
self.base_insider_search = Some(url);
self
}
#[must_use]
pub fn base_timeseries(mut self, url: Url) -> Self {
self.base_timeseries = Some(url);
self
}
#[must_use]
pub fn cookie_url(mut self, url: Url) -> Self {
self.cookie_url = Some(url);
self
}
#[must_use]
pub fn crumb_url(mut self, url: Url) -> Self {
self.crumb_url = Some(url);
self
}
#[must_use]
pub fn retry_config(mut self, cfg: RetryConfig) -> Self {
self.retry = Some(cfg);
self
}
#[must_use]
pub fn retry_enabled(mut self, yes: bool) -> Self {
let mut cfg = self.retry.unwrap_or_default();
cfg.enabled = yes;
self.retry = Some(cfg);
self
}
#[must_use]
pub const fn no_cache(mut self) -> Self {
self.cache_ttl = None;
self
}
#[doc(hidden)]
#[must_use]
#[allow(unused_variables, unused_mut)]
pub const fn _api_preference(mut self, pref: ApiPreference) -> Self {
#[cfg(feature = "test-mode")]
{
self.api_preference = Some(pref);
}
self
}
#[doc(hidden)]
#[must_use]
#[allow(unused_variables, unused_mut)]
pub fn _preauth(mut self, cookie: impl Into<String>, crumb: impl Into<String>) -> Self {
#[cfg(feature = "test-mode")]
{
self.preauth_cookie = Some(cookie.into());
self.preauth_crumb = Some(crumb.into());
}
self
}
#[must_use]
pub const fn timeout(mut self, dur: Duration) -> Self {
self.timeout = Some(dur);
self
}
#[must_use]
pub const fn connect_timeout(mut self, dur: Duration) -> Self {
self.connect_timeout = Some(dur);
self
}
#[must_use]
pub const fn cache_ttl(mut self, dur: Duration) -> Self {
self.cache_ttl = Some(dur);
self
}
#[must_use]
pub fn custom_client(mut self, client: Client) -> Self {
self.custom_client = Some(client);
self
}
#[must_use]
pub fn proxy(mut self, proxy_url: &str) -> Self {
assert!(
url::Url::parse(proxy_url).is_ok(),
"invalid proxy URL format: {proxy_url}"
);
self.proxy = Some(reqwest::Proxy::http(proxy_url).expect("invalid proxy URL"));
self
}
pub fn try_proxy(mut self, proxy_url: &str) -> Result<Self, YfError> {
url::Url::parse(proxy_url)
.map_err(|e| YfError::InvalidParams(format!("invalid proxy URL format: {e}")))?;
let proxy = reqwest::Proxy::http(proxy_url)
.map_err(|e| YfError::InvalidParams(format!("invalid proxy URL: {e}")))?;
self.proxy = Some(proxy);
Ok(self)
}
#[must_use]
pub fn https_proxy(mut self, proxy_url: &str) -> Self {
assert!(
url::Url::parse(proxy_url).is_ok(),
"invalid HTTPS proxy URL format: {proxy_url}"
);
self.proxy = Some(reqwest::Proxy::https(proxy_url).expect("invalid HTTPS proxy URL"));
self
}
pub fn try_https_proxy(mut self, proxy_url: &str) -> Result<Self, YfError> {
url::Url::parse(proxy_url)
.map_err(|e| YfError::InvalidParams(format!("invalid HTTPS proxy URL format: {e}")))?;
let proxy = reqwest::Proxy::https(proxy_url)
.map_err(|e| YfError::InvalidParams(format!("invalid HTTPS proxy URL: {e}")))?;
self.proxy = Some(proxy);
Ok(self)
}
pub fn build(self) -> Result<YfClient, YfError> {
let base_chart = self.base_chart.unwrap_or(Url::parse(DEFAULT_BASE_CHART)?);
let base_quote = self.base_quote.unwrap_or(Url::parse(DEFAULT_BASE_QUOTE)?);
let base_quote_api = self
.base_quote_api
.unwrap_or(Url::parse(DEFAULT_BASE_QUOTE_API)?);
let base_quote_v7 = self
.base_quote_v7
.unwrap_or(Url::parse(constants::DEFAULT_BASE_QUOTE_V7)?);
let base_options_v7 = self
.base_options_v7
.unwrap_or(Url::parse(constants::DEFAULT_BASE_OPTIONS_V7)?);
let base_stream = self
.base_stream
.unwrap_or(Url::parse(constants::DEFAULT_BASE_STREAM)?);
let base_news = self
.base_news
.unwrap_or(Url::parse(constants::DEFAULT_BASE_NEWS)?);
let base_insider_search = self
.base_insider_search
.unwrap_or(Url::parse(DEFAULT_BASE_INSIDER_SEARCH)?);
let base_timeseries = self
.base_timeseries
.unwrap_or(Url::parse(constants::DEFAULT_BASE_TIMESERIES)?);
let cookie_url = self.cookie_url.unwrap_or(Url::parse(DEFAULT_COOKIE_URL)?);
let crumb_url = self.crumb_url.unwrap_or(Url::parse(DEFAULT_CRUMB_URL)?);
let user_agent = self.user_agent.as_deref().unwrap_or(USER_AGENT).to_string();
let http = if let Some(custom_client) = self.custom_client {
custom_client
} else {
let mut httpb = reqwest::Client::builder().cookie_store(true);
if let Some(t) = self.timeout {
httpb = httpb.timeout(t);
}
if let Some(ct) = self.connect_timeout {
httpb = httpb.connect_timeout(ct);
}
if let Some(proxy) = self.proxy {
httpb = httpb.proxy(proxy);
}
httpb.build()?
};
let initial_state = ClientState {
cookie: {
#[cfg(feature = "test-mode")]
{
self.preauth_cookie
}
#[cfg(not(feature = "test-mode"))]
{
None
}
},
crumb: {
#[cfg(feature = "test-mode")]
{
self.preauth_crumb
}
#[cfg(not(feature = "test-mode"))]
{
None
}
},
};
Ok(YfClient {
http,
base_chart,
base_quote,
base_quote_api,
base_quote_v7,
base_options_v7,
base_stream,
base_news,
base_insider_search,
base_timeseries,
cookie_url,
crumb_url,
user_agent,
state: Arc::new(RwLock::new(initial_state)),
credential_fetch_lock: Arc::new(tokio::sync::Mutex::new(())),
#[cfg(feature = "test-mode")]
api_preference: self.api_preference.unwrap_or(ApiPreference::ApiThenScrape),
retry: self.retry.unwrap_or_default(),
reporting_currency_cache: Arc::new(RwLock::new(HashMap::new())),
instrument_cache: Arc::new(RwLock::new(HashMap::new())),
cache: self.cache_ttl.map(|ttl| {
Arc::new(CacheStore {
map: RwLock::new(HashMap::new()),
default_ttl: ttl,
})
}),
})
}
}
#[cfg(not(feature = "tracing"))]
async fn sleep_backoff(b: &Backoff, attempt: u32) {
let dur = compute_backoff_duration(b, attempt);
tokio::time::sleep(dur).await;
}
#[inline]
fn compute_backoff_duration(b: &Backoff, attempt: u32) -> Duration {
use std::time::Duration;
match *b {
Backoff::Fixed(d) => d,
Backoff::Exponential {
base,
factor,
max,
jitter,
} => {
let pow = factor.powi(i32::try_from(attempt).unwrap());
let mut d = Duration::from_secs_f64(base.as_secs_f64() * pow);
if d > max {
d = max;
}
if jitter {
let nanos = d.as_nanos();
let j = u64::try_from(nanos / 2).unwrap_or(0)
* ((u64::from(attempt) % 5 + 1) * 13 % 100)
/ 100;
let add = attempt.is_multiple_of(2);
d = if add {
d.saturating_add(Duration::from_nanos(j))
} else {
d.saturating_sub(Duration::from_nanos(j))
};
}
d
}
}
}