use crate::adapters::edgar;
use crate::adapters::yahoo::client::{ClientConfig, YahooClient};
#[cfg(feature = "backtesting")]
use crate::backtesting;
use crate::constants::{Frequency, Interval, Region, StatementType, TimeRange};
use crate::error::{FinanceError, Result};
use crate::format::Both;
#[cfg(any(feature = "backtesting", feature = "indicators"))]
use crate::indicators;
use crate::models::chart::events::ChartEvents;
use crate::models::chart::{CapitalGain, Chart, Dividend, DividendAnalytics, Split};
use crate::models::corporate::news::News;
use crate::models::corporate::recommendation::Recommendation;
use crate::models::filings::{CompanyFacts, EdgarSubmissions, ProviderFilings};
use crate::models::format::Format;
use crate::models::fundamentals::FinancialStatement;
use crate::models::options::Options;
use crate::models::quote::{
AssetProfile, CalendarEvents, DefaultKeyStatistics, Earnings, EarningsHistory, EarningsTrend,
EquityPerformance, FinancialData, FundOwnership, FundPerformance, FundProfile, IndexTrend,
IndustryTrend, InsiderHolders, InsiderTransactions, InstitutionOwnership,
MajorHoldersBreakdown, NetSharePurchaseActivity, Price, Quote, QuoteSummaryResponse,
QuoteTypeData, RecommendationTrend, SecFilings, SectorTrend, SummaryDetail, SummaryProfile,
TopHoldings, UpgradeDowngradeHistory,
};
use super::macros::ticker_fetch;
use crate::providers::types::recommendation_from_similar;
use crate::providers::yahoo::YahooProvider;
use crate::providers::{
Capability, Fetch, Provider, ProviderAdapter, ProviderSet, Routes, build_providers,
};
#[cfg(feature = "risk")]
use crate::risk;
use crate::utils::{CacheEntry, CacheMode, FetchGuards, filter_by_range};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
type Cache<T> = Arc<RwLock<Option<CacheEntry<T>>>>;
type MapCache<K, V> = Arc<RwLock<HashMap<K, CacheEntry<V>>>>;
#[derive(Clone)]
pub struct ClientHandle(pub(crate) Arc<YahooClient>);
pub struct TickerBuilder {
symbol: Arc<str>,
config: ClientConfig,
shared_client: Option<ClientHandle>,
injected_providers: Option<Arc<ProviderSet>>,
cache_mode: CacheMode,
include_logo: bool,
}
impl TickerBuilder {
fn new(symbol: impl Into<String>) -> Self {
Self {
symbol: symbol.into().into(),
config: ClientConfig::default(),
shared_client: None,
injected_providers: None,
cache_mode: CacheMode::default(),
include_logo: false,
}
}
pub fn region(mut self, region: Region) -> Self {
self.config.lang = region.lang().to_string();
self.config.region = region.region().to_string();
self
}
pub fn lang(mut self, lang: impl Into<String>) -> Self {
self.config.lang = lang.into();
self
}
pub fn region_code(mut self, r: impl Into<String>) -> Self {
self.config.region = r.into();
self
}
pub fn timeout(mut self, t: Duration) -> Self {
self.config.timeout = t;
self
}
pub fn proxy(mut self, p: impl Into<String>) -> Self {
self.config.proxy = Some(p.into());
self
}
#[allow(dead_code)]
pub(crate) fn config(mut self, c: ClientConfig) -> Self {
self.config = c;
self
}
#[doc(hidden)]
pub fn with_provider_set(mut self, set: Arc<ProviderSet>) -> Self {
self.injected_providers = Some(set);
self
}
pub fn client(mut self, handle: ClientHandle) -> Self {
self.shared_client = Some(handle);
self
}
pub fn cache(mut self, ttl: Duration) -> Self {
self.cache_mode = CacheMode::Ttl(ttl);
self
}
pub fn cache_forever(mut self) -> Self {
self.cache_mode = CacheMode::Lifetime;
self
}
pub fn no_cache(mut self) -> Self {
self.cache_mode = CacheMode::Off;
self
}
pub fn logo(mut self) -> Self {
self.include_logo = true;
self
}
pub async fn build(self) -> Result<Ticker> {
#[cfg(feature = "translation")]
let translate_lang = {
let lang = crate::translation::Lang::parse(&self.config.lang)?;
(!lang.is_english()).then_some(lang)
};
let providers = if let Some(set) = self.injected_providers {
set
} else if let Some(handle) = self.shared_client {
let yahoo = YahooProvider::from_client(handle.0);
let client = yahoo.client_arc();
Arc::new(
ProviderSet::new(
vec![Arc::new(yahoo) as Arc<dyn ProviderAdapter>],
Routes::new(Fetch::Sequential),
)
.with_yahoo_client(Some(client)),
)
} else {
Arc::new(
build_providers(
&[Provider::Yahoo],
Vec::new(),
&self.config,
Routes::new(Fetch::Sequential),
)
.await?,
)
};
Ok(Ticker {
symbol: self.symbol,
providers,
cache_mode: self.cache_mode,
include_logo: self.include_logo,
#[cfg(feature = "translation")]
translate_lang,
quote_cache: Default::default(),
quote_fetch: Arc::new(tokio::sync::Mutex::new(())),
chart_cache: Default::default(),
chart_guards: Default::default(),
events_cache: Default::default(),
events_fetch: Arc::new(tokio::sync::Mutex::new(())),
news_cache: Default::default(),
news_fetch: Arc::new(tokio::sync::Mutex::new(())),
logo_cache: Default::default(),
options_cache: Default::default(),
options_guards: Default::default(),
financials_cache: Default::default(),
financials_guards: Default::default(),
#[cfg(feature = "indicators")]
indicators_cache: Default::default(),
#[cfg(feature = "indicators")]
indicators_guards: Default::default(),
edgar_submissions_cache: Default::default(),
edgar_submissions_fetch: Arc::new(tokio::sync::Mutex::new(())),
edgar_facts_cache: Default::default(),
edgar_facts_fetch: Arc::new(tokio::sync::Mutex::new(())),
})
}
}
pub struct Ticker {
symbol: Arc<str>,
providers: Arc<ProviderSet>,
cache_mode: CacheMode,
include_logo: bool,
#[cfg(feature = "translation")]
translate_lang: Option<crate::translation::Lang>,
quote_cache: Cache<QuoteSummaryResponse>,
quote_fetch: Arc<tokio::sync::Mutex<()>>,
chart_cache: MapCache<(Interval, TimeRange), Chart>,
chart_guards: FetchGuards<(Interval, TimeRange)>,
events_cache: Cache<ChartEvents>,
events_fetch: Arc<tokio::sync::Mutex<()>>,
news_cache: Cache<Vec<News>>,
news_fetch: Arc<tokio::sync::Mutex<()>>,
logo_cache: Cache<(Option<String>, Option<String>)>,
options_cache: MapCache<Option<i64>, Options>,
options_guards: FetchGuards<Option<i64>>,
financials_cache: MapCache<(StatementType, Frequency), FinancialStatement>,
financials_guards: FetchGuards<(StatementType, Frequency)>,
#[cfg(feature = "indicators")]
indicators_cache: MapCache<(Interval, TimeRange), indicators::IndicatorsSummary>,
#[cfg(feature = "indicators")]
indicators_guards: FetchGuards<(Interval, TimeRange)>,
edgar_submissions_cache: Cache<EdgarSubmissions>,
edgar_submissions_fetch: Arc<tokio::sync::Mutex<()>>,
edgar_facts_cache: Cache<CompanyFacts>,
edgar_facts_fetch: Arc<tokio::sync::Mutex<()>>,
}
impl Ticker {
pub async fn new(symbol: impl Into<String>) -> Result<Self> {
Self::builder(symbol).build().await
}
pub fn builder(symbol: impl Into<String>) -> TickerBuilder {
TickerBuilder::new(symbol)
}
pub fn symbol(&self) -> &str {
&self.symbol
}
pub fn client_handle(&self) -> ClientHandle {
ClientHandle(
self.providers
.first_yahoo()
.expect("client_handle requires a Yahoo session; use Providers::ticker() for multi-provider tickers"),
)
}
#[allow(dead_code)]
pub(crate) fn provider_set(&self) -> &Arc<ProviderSet> {
&self.providers
}
#[cfg(feature = "translation")]
pub(crate) async fn translate_response<T: crate::translation::Translatable>(
&self,
value: &mut T,
) -> Result<()> {
if let Some(lang) = &self.translate_lang {
crate::translation::translate_with(value, lang).await?;
}
Ok(())
}
fn is_cache_fresh<T>(&self, entry: Option<&CacheEntry<T>>) -> bool {
CacheEntry::is_fresh_entry(entry, self.cache_mode)
}
fn cache_insert<K: Eq + std::hash::Hash, V>(
&self,
map: &mut HashMap<K, CacheEntry<V>>,
key: K,
value: V,
) {
crate::utils::cache_insert(
map,
key,
value,
self.cache_mode,
crate::utils::EVICTION_THRESHOLD,
);
}
pub async fn quote<F>(&self) -> Result<Quote<F>>
where
F: Format,
Quote<Both>: Into<Quote<F>>,
{
let logo_fut = async {
if !self.include_logo {
return (None, None);
}
if let Some(e) = self.logo_cache.read().await.as_ref()
&& self.is_cache_fresh(Some(e))
{
return e.value.clone();
}
let fetched = match self.providers.first_yahoo() {
Ok(y) => y.get_logo_url(&self.symbol).await,
Err(e) => Err(e),
};
match fetched {
Ok(logos) => {
if self.cache_mode.enabled() {
*self.logo_cache.write().await = Some(CacheEntry::new(logos.clone()));
}
logos
}
Err(_) => (None, None),
}
};
let (cache, (logo_url, company_logo_url)) = tokio::join!(self.ensure_quote(), logo_fut);
let cache = cache?;
let summary = cache.as_ref().ok_or_else(|| {
FinanceError::ApiError("Quote summary cache was empty after fetch".to_string())
})?;
let quote = Quote::from_response(&summary.value, logo_url, company_logo_url);
#[cfg(feature = "translation")]
let quote = {
drop(cache);
let mut quote = quote;
self.translate_response(&mut quote).await?;
quote
};
Ok(quote.into())
}
fn chart_from_provider_data(
mut data: Chart,
interval: Option<Interval>,
range: Option<TimeRange>,
) -> Chart {
data.interval = interval;
data.range = range;
data
}
pub async fn chart(&self, interval: Interval, range: TimeRange) -> Result<Chart> {
let key = (interval, range);
{
let cache = self.chart_cache.read().await;
if let Some(entry) = cache.get(&key)
&& self.is_cache_fresh(Some(entry))
{
return Ok(entry.value.clone());
}
}
self.chart_guards
.dedup(key, || async {
{
let cache = self.chart_cache.read().await;
if let Some(entry) = cache.get(&key)
&& self.is_cache_fresh(Some(entry))
{
return Ok(entry.value.clone());
}
}
let data =
ticker_fetch!(self, CHART, as_chart, Chart, fetch_chart, interval, range)?;
let chart = Self::chart_from_provider_data(data, Some(interval), Some(range));
if self.cache_mode.enabled() {
let mut cache = self.chart_cache.write().await;
self.cache_insert(&mut cache, key, chart.clone());
}
Ok(chart)
})
.await
}
pub async fn chart_range(&self, interval: Interval, start: i64, end: i64) -> Result<Chart> {
if start >= end {
return Err(FinanceError::InvalidParameter {
param: "end".into(),
reason: format!("end ({end}) must be > start ({start})"),
});
}
let data = ticker_fetch!(
self,
CHART,
as_chart,
ChartRange,
fetch_chart_range,
interval,
start,
end
)?;
Ok(Self::chart_from_provider_data(data, Some(interval), None))
}
async fn ensure_events(&self) -> Result<()> {
{
let cache = self.events_cache.read().await;
if self.is_cache_fresh(cache.as_ref()) {
return Ok(());
}
}
let _guard = self.events_fetch.lock().await;
{
let cache = self.events_cache.read().await;
if self.is_cache_fresh(cache.as_ref()) {
return Ok(());
}
}
let events = ticker_fetch!(self, CORPORATE, as_corporate, Events, fetch_events)?;
let mut cache = self.events_cache.write().await;
*cache = Some(CacheEntry::new(events));
Ok(())
}
pub async fn dividends(&self, range: TimeRange) -> Result<Vec<Dividend>> {
self.ensure_events().await?;
let cache = self.events_cache.read().await;
let all = cache
.as_ref()
.map(|e| e.value.to_dividends())
.unwrap_or_default();
Ok(filter_by_range(all, range))
}
pub async fn dividend_analytics(&self, range: TimeRange) -> Result<DividendAnalytics> {
let divs = self.dividends(range).await?;
Ok(DividendAnalytics::from_dividends(&divs))
}
pub async fn splits(&self, range: TimeRange) -> Result<Vec<Split>> {
self.ensure_events().await?;
let cache = self.events_cache.read().await;
let all = cache
.as_ref()
.map(|e| e.value.to_splits())
.unwrap_or_default();
Ok(filter_by_range(all, range))
}
pub async fn capital_gains(&self, range: TimeRange) -> Result<Vec<CapitalGain>> {
self.ensure_events().await?;
let cache = self.events_cache.read().await;
let all = cache
.as_ref()
.map(|e| e.value.to_capital_gains())
.unwrap_or_default();
Ok(filter_by_range(all, range))
}
pub async fn recommendations(&self, limit: u32) -> Result<Recommendation> {
if limit == 0 {
return Err(FinanceError::InvalidParameter {
param: "limit".into(),
reason: "limit must be > 0".into(),
});
}
let sym = self.symbol.clone();
let (provider_id, items) = self
.providers
.fetch(Capability::CORPORATE, move |p| {
let sym = sym.clone();
let p = p.clone();
async move {
let r = p
.as_corporate()
.ok_or_else(|| {
p.not_supported(crate::providers::Operation::Recommendations)
})?
.fetch_similar_symbols(&sym, limit)
.await?;
Ok((p.id(), r))
}
})
.await?;
Ok(recommendation_from_similar(
self.symbol.to_string(),
Some(provider_id),
items,
Some(limit),
))
}
pub async fn news(&self) -> Result<Vec<News>> {
{
let cache = self.news_cache.read().await;
if let Some(e) = cache.as_ref()
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let _guard = self.news_fetch.lock().await;
{
let cache = self.news_cache.read().await;
if let Some(e) = cache.as_ref()
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let data = ticker_fetch!(self, CORPORATE, as_corporate, News, fetch_news)?;
let news = data;
#[cfg(feature = "sentiment")]
let news = {
let mut news = news;
for article in news.iter_mut() {
article.sentiment = Some(crate::models::sentiment::analyze(&article.title));
}
news
};
#[cfg(feature = "translation")]
let news = {
let mut news = news;
self.translate_response(&mut news).await?;
news
};
if self.cache_mode.enabled() {
let mut c = self.news_cache.write().await;
*c = Some(CacheEntry::new(news.clone()));
}
Ok(news)
}
#[cfg(feature = "sentiment")]
pub async fn news_sentiment(&self) -> Result<crate::models::sentiment::Sentiment> {
let news = self.news().await?;
let scores: Vec<f64> = news
.iter()
.filter_map(|n| n.sentiment.as_ref().map(|s| s.score))
.collect();
Ok(crate::models::sentiment::aggregate(&scores)
.unwrap_or_else(crate::models::sentiment::Sentiment::neutral))
}
pub async fn options(&self, date: Option<i64>) -> Result<Options> {
{
let cache = self.options_cache.read().await;
if let Some(e) = cache.get(&date)
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
self.options_guards
.dedup(date, || async {
{
let cache = self.options_cache.read().await;
if let Some(e) = cache.get(&date)
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let opts = ticker_fetch!(self, OPTIONS, as_options, Options, fetch_options, date)?;
if self.cache_mode.enabled() {
let mut c = self.options_cache.write().await;
self.cache_insert(&mut c, date, opts.clone());
}
Ok(opts)
})
.await
}
pub async fn financials(
&self,
stmt_type: StatementType,
frequency: Frequency,
) -> Result<FinancialStatement> {
let key = (stmt_type, frequency);
{
let cache = self.financials_cache.read().await;
if let Some(e) = cache.get(&key)
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
self.financials_guards
.dedup(key, || async {
{
let cache = self.financials_cache.read().await;
if let Some(e) = cache.get(&key)
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let stmt = ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
Financials,
fetch_financials,
stmt_type,
frequency
)?;
if self.cache_mode.enabled() {
let mut c = self.financials_cache.write().await;
self.cache_insert(&mut c, key, stmt.clone());
}
Ok(stmt)
})
.await
}
#[cfg(feature = "indicators")]
pub async fn indicators(
&self,
interval: Interval,
range: TimeRange,
) -> Result<indicators::IndicatorsSummary> {
let key = (interval, range);
{
let cache = self.indicators_cache.read().await;
if let Some(e) = cache.get(&key)
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
self.indicators_guards
.dedup(key, || async {
{
let cache = self.indicators_cache.read().await;
if let Some(e) = cache.get(&key)
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let chart = self.chart(interval, range).await?;
let ind = indicators::summary::calculate_indicators(&chart.candles);
if self.cache_mode.enabled() {
let mut c = self.indicators_cache.write().await;
self.cache_insert(&mut c, key, ind.clone());
}
Ok(ind)
})
.await
}
pub async fn edgar_submissions(&self) -> Result<EdgarSubmissions> {
{
let cache = self.edgar_submissions_cache.read().await;
if let Some(e) = cache.as_ref()
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let _guard = self.edgar_submissions_fetch.lock().await;
{
let cache = self.edgar_submissions_cache.read().await;
if let Some(e) = cache.as_ref()
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let subs = edgar::submissions_for_symbol(&self.symbol).await?;
if self.cache_mode.enabled() {
let mut c = self.edgar_submissions_cache.write().await;
*c = Some(CacheEntry::new(subs.clone()));
}
Ok(subs)
}
pub async fn edgar_company_facts(&self) -> Result<CompanyFacts> {
{
let cache = self.edgar_facts_cache.read().await;
if let Some(e) = cache.as_ref()
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let _guard = self.edgar_facts_fetch.lock().await;
{
let cache = self.edgar_facts_cache.read().await;
if let Some(e) = cache.as_ref()
&& self.is_cache_fresh(Some(e))
{
return Ok(e.value.clone());
}
}
let facts = edgar::company_facts_for_symbol(&self.symbol).await?;
if self.cache_mode.enabled() {
let mut c = self.edgar_facts_cache.write().await;
*c = Some(CacheEntry::new(facts.clone()));
}
Ok(facts)
}
pub async fn filings(&self) -> Result<ProviderFilings> {
ticker_fetch!(self, FILINGS, as_filings, Filings, fetch_filings)
}
pub async fn short_interest(&self) -> Result<Vec<crate::models::fundamentals::ShortInterest>> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
ShortInterest,
fetch_short_interest
)
}
pub async fn short_volume(&self) -> Result<Vec<crate::models::fundamentals::ShortVolume>> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
ShortVolume,
fetch_short_volume
)
}
pub async fn share_float(&self) -> Result<crate::models::fundamentals::ShareFloat> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
ShareFloat,
fetch_share_float
)
}
pub async fn press_releases(
&self,
limit: u32,
) -> Result<Vec<crate::models::corporate::press_release::PressRelease>> {
ticker_fetch!(
self,
CORPORATE,
as_corporate,
PressReleases,
fetch_press_releases,
limit
)
}
pub async fn price_target_consensus(
&self,
) -> Result<crate::models::fundamentals::PriceTargetConsensus> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
PriceTargetConsensus,
fetch_price_target_consensus
)
}
pub async fn price_target_summary(
&self,
) -> Result<crate::models::fundamentals::PriceTargetSummary> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
PriceTargetSummary,
fetch_price_target_summary
)
}
pub async fn rating_consensus(&self) -> Result<crate::models::fundamentals::RatingConsensus> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
RatingConsensus,
fetch_rating_consensus
)
}
pub async fn key_metrics_ttm(&self) -> Result<crate::models::fundamentals::KeyMetricsTtm> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
KeyMetricsTtm,
fetch_key_metrics_ttm
)
}
pub async fn ratios_ttm(&self) -> Result<crate::models::fundamentals::FinancialRatiosTtm> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
RatiosTtm,
fetch_ratios_ttm
)
}
pub async fn executive_compensation(
&self,
) -> Result<Vec<crate::models::corporate::governance::ExecutiveCompensation>> {
ticker_fetch!(
self,
CORPORATE,
as_corporate,
ExecutiveCompensation,
fetch_executive_compensation
)
}
pub async fn employee_count(
&self,
) -> Result<Vec<crate::models::corporate::governance::EmployeeCount>> {
ticker_fetch!(
self,
CORPORATE,
as_corporate,
EmployeeCount,
fetch_employee_count
)
}
pub async fn etf_profile(&self) -> Result<crate::models::fundamentals::EtfProfile> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
EtfProfile,
fetch_etf_profile
)
}
pub async fn earnings_surprises(
&self,
) -> Result<Vec<crate::models::fundamentals::EarningsSurprise>> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
EarningsSurprises,
fetch_earnings_surprises
)
}
pub async fn grading_actions(&self) -> Result<Vec<crate::models::fundamentals::GradingAction>> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
GradingHistory,
fetch_grading_history
)
}
pub async fn company_profile(&self) -> Result<crate::models::fundamentals::CompanyProfile> {
ticker_fetch!(
self,
FUNDAMENTALS,
as_fundamentals,
CompanyProfile,
fetch_company_profile
)
}
pub async fn earnings_transcript(
&self,
quarter: Option<&str>,
year: Option<i32>,
) -> Result<crate::models::corporate::earnings_transcript::EarningsTranscript> {
ticker_fetch!(
self,
CORPORATE,
as_corporate,
EarningsTranscript,
fetch_earnings_transcript,
quarter,
year
)
}
#[cfg(feature = "indicators")]
pub async fn indicator(
&self,
indicator: indicators::Indicator,
interval: Interval,
range: TimeRange,
) -> Result<indicators::IndicatorResult> {
let chart = self.chart(interval, range).await?;
Ok(indicators::compute_indicator(indicator, &chart)?)
}
#[cfg(feature = "backtesting")]
pub async fn backtest<S: backtesting::Strategy>(
&self,
strategy: S,
interval: Interval,
range: TimeRange,
config: Option<backtesting::BacktestConfig>,
) -> backtesting::Result<backtesting::BacktestResult> {
let config = config.unwrap_or_default();
config.validate()?;
let (chart, dividends) = tokio::join!(self.chart(interval, range), self.dividends(range));
let chart = chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
let dividends = dividends.unwrap_or_default();
backtesting::BacktestEngine::new(config).run_with_dividends(
&self.symbol,
&chart.candles,
strategy,
÷nds,
)
}
#[cfg(feature = "backtesting")]
pub async fn backtest_with_benchmark<S: backtesting::Strategy>(
&self,
strategy: S,
interval: Interval,
range: TimeRange,
config: Option<backtesting::BacktestConfig>,
benchmark: &str,
) -> backtesting::Result<backtesting::BacktestResult> {
let config = config.unwrap_or_default();
config.validate()?;
let bench_fut = async {
let bench_ticker = Ticker::new(benchmark).await?;
bench_ticker.chart(interval, range).await
};
let (chart, bench_chart, dividends) = tokio::join!(
self.chart(interval, range),
bench_fut,
self.dividends(range)
);
let chart = chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
let bench_chart =
bench_chart.map_err(|e| backtesting::BacktestError::ChartError(e.to_string()))?;
let dividends = dividends.unwrap_or_default();
backtesting::BacktestEngine::new(config).run_with_benchmark(
&self.symbol,
&chart.candles,
strategy,
÷nds,
benchmark,
&bench_chart.candles,
)
}
#[cfg(feature = "risk")]
pub async fn risk(
&self,
interval: Interval,
range: TimeRange,
benchmark: Option<&str>,
) -> Result<risk::RiskSummary> {
let bench_fut = async {
let Some(sym) = benchmark else {
return Result::Ok(None);
};
let bt = Ticker::new(sym).await?;
let bench_chart = bt.chart(interval, range).await?;
Result::Ok(Some(risk::candles_to_returns(&bench_chart.candles)))
};
let (chart, bench_returns) = tokio::join!(self.chart(interval, range), bench_fut);
let chart = chart?;
let bench_returns = bench_returns?;
Ok(risk::compute_risk_summary(
&chart.candles,
bench_returns.as_deref(),
))
}
pub async fn calendar(
&self,
range: TimeRange,
) -> Result<Vec<crate::models::calendar::CalendarEvent>> {
let now = chrono::Utc::now().timestamp();
let window = (now, now + range.approx_duration_secs());
#[cfg(feature = "fred")]
let (calendar_events, options, releases) = tokio::join!(
self.calendar_events(),
self.options(None),
crate::adapters::fred::release_dates(),
);
#[cfg(not(feature = "fred"))]
let (calendar_events, options) = tokio::join!(self.calendar_events(), self.options(None));
let calendar_events = calendar_events?;
let options = options.ok();
let mut events = crate::models::calendar::build_symbol_events(
&self.symbol,
calendar_events.as_ref(),
options.as_ref(),
window,
);
#[cfg(feature = "fred")]
if let Ok(releases) = releases {
events.extend(crate::models::calendar::build_economic_events(
releases, window,
));
}
crate::models::calendar::sort_events(&mut events);
Ok(events)
}
async fn ensure_quote(
&self,
) -> Result<tokio::sync::RwLockReadGuard<'_, Option<CacheEntry<QuoteSummaryResponse>>>> {
{
let cache = self.quote_cache.read().await;
if self.is_cache_fresh(cache.as_ref()) {
return Ok(cache);
}
}
let _guard = self.quote_fetch.lock().await;
{
let cache = self.quote_cache.read().await;
if self.is_cache_fresh(cache.as_ref()) {
return Ok(cache);
}
}
let summary = ticker_fetch!(self, QUOTE, as_quote, Quote, fetch_quote)?;
{
let mut cache = self.quote_cache.write().await;
*cache = Some(CacheEntry::new(summary));
}
Ok(self.quote_cache.read().await)
}
}
super::macros::define_quote_accessors! {
price -> Price, price,
summary_detail -> SummaryDetail, summary_detail,
financial_data -> FinancialData, financial_data,
key_stats -> DefaultKeyStatistics, default_key_statistics,
asset_profile -> AssetProfile, asset_profile,
calendar_events -> CalendarEvents, calendar_events,
earnings -> Earnings, earnings,
earnings_trend -> EarningsTrend, earnings_trend,
earnings_history -> EarningsHistory, earnings_history,
recommendation_trend -> RecommendationTrend, recommendation_trend,
insider_holders -> InsiderHolders, insider_holders,
insider_transactions -> InsiderTransactions, insider_transactions,
institution_ownership -> InstitutionOwnership, institution_ownership,
fund_ownership -> FundOwnership, fund_ownership,
major_holders -> MajorHoldersBreakdown, major_holders_breakdown,
share_purchase_activity -> NetSharePurchaseActivity, net_share_purchase_activity,
quote_type -> QuoteTypeData, quote_type,
summary_profile -> SummaryProfile, summary_profile,
sec_filings -> SecFilings, sec_filings,
grading_history -> UpgradeDowngradeHistory, upgrade_downgrade_history,
fund_performance -> FundPerformance, fund_performance,
fund_profile -> FundProfile, fund_profile,
top_holdings -> TopHoldings, top_holdings,
index_trend -> IndexTrend, index_trend,
industry_trend -> IndustryTrend, industry_trend,
sector_trend -> SectorTrend, sector_trend,
equity_performance -> EquityPerformance, equity_performance,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::mock::{CountingProvider, provider_set};
#[tokio::test]
async fn default_caches_quote_across_accessors() {
let provider = CountingProvider::new();
let ticker = Ticker::builder("AAPL")
.with_provider_set(provider_set(Arc::clone(&provider)))
.build()
.await
.unwrap();
let _ = ticker.price().await.unwrap();
let _ = ticker.summary_detail().await.unwrap();
let _ = ticker.asset_profile().await.unwrap();
assert_eq!(provider.quotes(), 1);
}
#[tokio::test]
async fn no_cache_refetches_every_accessor() {
let provider = CountingProvider::new();
let ticker = Ticker::builder("AAPL")
.with_provider_set(provider_set(Arc::clone(&provider)))
.no_cache()
.build()
.await
.unwrap();
let _ = ticker.price().await.unwrap();
let _ = ticker.summary_detail().await.unwrap();
let _ = ticker.asset_profile().await.unwrap();
assert_eq!(provider.quotes(), 3);
}
#[tokio::test]
async fn charts_cache_per_interval_and_range() {
let provider = CountingProvider::new();
let ticker = Ticker::builder("AAPL")
.with_provider_set(provider_set(Arc::clone(&provider)))
.build()
.await
.unwrap();
let _ = ticker
.chart(Interval::OneDay, TimeRange::OneMonth)
.await
.unwrap();
let _ = ticker
.chart(Interval::OneDay, TimeRange::OneMonth)
.await
.unwrap();
assert_eq!(provider.charts(), 1);
let _ = ticker
.chart(Interval::OneDay, TimeRange::OneYear)
.await
.unwrap();
assert_eq!(provider.charts(), 2);
}
#[tokio::test]
async fn unresolved_logo_is_not_cached() {
let provider = CountingProvider::new();
let ticker = Ticker::builder("AAPL")
.with_provider_set(provider_set(Arc::clone(&provider)))
.logo()
.build()
.await
.unwrap();
let _: Quote<crate::format::Raw> = ticker.quote().await.unwrap();
assert!(
ticker.logo_cache.read().await.is_none(),
"an unresolved logo must not be cached, or one blip is permanent"
);
}
#[tokio::test]
async fn concurrent_chart_misses_dedup_to_one_fetch() {
let provider = CountingProvider::new();
let ticker = Arc::new(
Ticker::builder("AAPL")
.with_provider_set(provider_set(Arc::clone(&provider)))
.build()
.await
.unwrap(),
);
let mut handles = Vec::new();
for _ in 0..8 {
let ticker = Arc::clone(&ticker);
handles.push(tokio::spawn(async move {
ticker
.chart(Interval::OneDay, TimeRange::OneMonth)
.await
.unwrap()
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(provider.charts(), 1);
}
#[tokio::test]
async fn concurrent_news_misses_dedup_to_one_fetch() {
let provider = CountingProvider::new();
let ticker = Arc::new(
Ticker::builder("AAPL")
.with_provider_set(provider_set(Arc::clone(&provider)))
.build()
.await
.unwrap(),
);
let mut handles = Vec::new();
for _ in 0..8 {
let ticker = Arc::clone(&ticker);
handles.push(tokio::spawn(async move { ticker.news().await.unwrap() }));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(provider.news(), 1);
}
#[tokio::test(start_paused = true)]
async fn ttl_expires() {
let provider = CountingProvider::new();
let ticker = Ticker::builder("AAPL")
.with_provider_set(provider_set(Arc::clone(&provider)))
.cache(Duration::from_secs(60))
.build()
.await
.unwrap();
let _ = ticker
.chart(Interval::OneDay, TimeRange::OneMonth)
.await
.unwrap();
tokio::time::advance(Duration::from_secs(120)).await;
let _ = ticker
.chart(Interval::OneDay, TimeRange::OneMonth)
.await
.unwrap();
assert_eq!(provider.charts(), 2);
}
}