pub mod cache;
pub mod discovery;
pub mod external;
pub mod sources;
use crate::config::{CommodityMapping, PriceConfig, PriceSourceConfig, SourceRef};
use anyhow::{Context, Result};
use rust_decimal::Decimal;
use rustledger_core::NaiveDate;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
pub use sources::PriceSource;
#[derive(Debug, Clone)]
pub struct PriceRequest {
pub ticker: String,
pub currency: String,
pub date: Option<NaiveDate>,
}
impl PriceRequest {
pub fn new(ticker: impl Into<String>, currency: impl Into<String>) -> Self {
Self {
ticker: ticker.into(),
currency: currency.into(),
date: None,
}
}
#[must_use]
pub const fn with_date(mut self, date: NaiveDate) -> Self {
self.date = Some(date);
self
}
}
#[derive(Debug, Clone)]
pub struct PriceResponse {
pub price: Decimal,
pub currency: String,
pub date: NaiveDate,
pub source: String,
}
pub struct PriceSourceRegistry {
sources: HashMap<String, Arc<dyn PriceSource>>,
default_source: String,
timeout: Duration,
use_default_source: bool,
}
impl PriceSourceRegistry {
pub fn new(config: &PriceConfig) -> Self {
let mut sources: HashMap<String, Arc<dyn PriceSource>> = HashMap::new();
let timeout = Duration::from_secs(config.effective_timeout());
sources.insert(
"yahoo".to_string(),
Arc::new(sources::YahooFinanceSource::new(timeout)),
);
sources.insert(
"coinbase".to_string(),
Arc::new(sources::CoinbaseSource::new(timeout)),
);
sources.insert(
"coincap".to_string(),
Arc::new(sources::CoinCapSource::new(timeout)),
);
sources.insert(
"ecb".to_string(),
Arc::new(sources::EcbSource::new(timeout)),
);
sources.insert(
"ratesapi".to_string(),
Arc::new(sources::RatesApiSource::new(timeout)),
);
sources.insert(
"tsp".to_string(),
Arc::new(sources::TspSource::new(timeout)),
);
sources.insert(
"eastmoneyfund".to_string(),
Arc::new(sources::EastMoneyFundSource::new(timeout)),
);
sources.insert(
"oanda".to_string(),
Arc::new(sources::OandaSource::new(timeout)),
);
sources.insert(
"alphavantage".to_string(),
Arc::new(sources::AlphaVantageSource::new(timeout)),
);
sources.insert(
"coinmarketcap".to_string(),
Arc::new(sources::CoinMarketCapSource::new(timeout)),
);
sources.insert(
"quandl".to_string(),
Arc::new(sources::QuandlSource::new(timeout)),
);
for (name, source_config) in &config.sources {
if let PriceSourceConfig::Command {
command,
timeout: cmd_timeout,
env,
} = source_config
{
let cmd_timeout =
Duration::from_secs(cmd_timeout.unwrap_or(config.effective_timeout()));
sources.insert(
name.clone(),
Arc::new(external::ExternalCommandSource::with_name(
command.clone(),
cmd_timeout,
env.clone(),
name.clone(),
)),
);
}
}
Self {
sources,
default_source: config.effective_default_source().to_string(),
timeout,
use_default_source: config.effective_use_default_source(),
}
}
pub fn get(&self, name: &str) -> Option<Arc<dyn PriceSource>> {
self.sources.get(name).cloned()
}
pub fn default_source(&self) -> Option<Arc<dyn PriceSource>> {
self.get(&self.default_source)
}
pub fn default_source_name(&self) -> &str {
&self.default_source
}
pub fn list_sources(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.sources.keys().map(String::as_str).collect();
names.sort_unstable();
names
}
pub fn has_source(&self, name: &str) -> bool {
self.sources.contains_key(name)
}
pub const fn timeout(&self) -> Duration {
self.timeout
}
pub fn fetch_price(
&self,
commodity: &str,
currency: &str,
date: Option<NaiveDate>,
mapping: &HashMap<String, CommodityMapping>,
) -> Result<PriceResponse> {
let attempts = self.resolve_mapping(commodity, mapping)?;
let mut last_error = None;
let mut unknown_sources = Vec::new();
for (source_name, ticker) in &attempts {
if let Some(source) = self.get(source_name) {
let request = PriceRequest {
ticker: ticker.clone(),
currency: currency.to_string(),
date,
};
match source.fetch_price(&request) {
Ok(response) => return Ok(response),
Err(e) => {
last_error = Some(e);
}
}
} else {
unknown_sources.push(source_name.clone());
}
}
let err_msg = if let Some(e) = last_error {
if unknown_sources.is_empty() {
e
} else {
anyhow::anyhow!(
"{}; note: unknown sources skipped: {}",
e,
unknown_sources.join(", ")
)
}
} else if !unknown_sources.is_empty() {
anyhow::anyhow!(
"No price source available for commodity {commodity}: unknown sources: {}",
unknown_sources.join(", ")
)
} else {
anyhow::anyhow!("No price source available for commodity {commodity}")
};
Err(err_msg)
}
fn resolve_mapping(
&self,
commodity: &str,
mapping: &HashMap<String, CommodityMapping>,
) -> Result<Vec<(String, String)>> {
if let Some(commodity_mapping) = mapping.get(commodity) {
let attempts = match commodity_mapping {
CommodityMapping::Simple(ticker) => {
vec![(self.default_source.clone(), ticker.clone())]
}
CommodityMapping::Detailed(d) => {
let parent_ticker = d.ticker.clone().unwrap_or_else(|| commodity.to_string());
match &d.source {
SourceRef::Single(s) => vec![(s.clone(), parent_ticker)],
SourceRef::Fallback(entries) => entries
.iter()
.map(|e| {
let t = e
.ticker()
.map_or_else(|| parent_ticker.clone(), str::to_string);
(e.source_name().to_string(), t)
})
.collect(),
}
}
};
return Ok(attempts);
}
if self.use_default_source {
return Ok(vec![(self.default_source.clone(), commodity.to_string())]);
}
Err(anyhow::anyhow!(
"no price source configured for {commodity}. Pick one:\n \
- pass `--source <name>` (e.g. `--source ecb`),\n \
- pass `--mapping {commodity}:<TICKER>`,\n \
- add `[price.mapping.{commodity}]` to your rledger config,\n \
- annotate the commodity in your beancount file with \
`price: \"<quote>:<source>/<ticker>\"` or `quote_currency: \"<currency>\"` \
and load the file with `-f`,\n \
- or set `[price] use_default_source = true` in your config to fall back \
to the default source ({default}) for unmapped symbols.",
default = self.default_source,
))
}
}
impl Default for PriceSourceRegistry {
fn default() -> Self {
Self::new(&PriceConfig::default())
}
}
pub fn fetch_price(
ticker: &str,
currency: &str,
source_name: Option<&str>,
) -> Result<PriceResponse> {
let config = PriceConfig::default();
let registry = PriceSourceRegistry::new(&config);
let source_name = source_name.unwrap_or(registry.default_source_name());
let source = registry
.get(source_name)
.with_context(|| format!("Unknown price source: {source_name}"))?;
let request = PriceRequest::new(ticker, currency);
source.fetch_price(&request)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{FallbackDetail, FallbackEntry};
#[test]
fn test_price_request_builder() {
let request = PriceRequest::new("AAPL", "USD");
assert_eq!(request.ticker, "AAPL");
assert_eq!(request.currency, "USD");
assert!(request.date.is_none());
let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
let request_with_date = PriceRequest::new("AAPL", "USD").with_date(date);
assert_eq!(request_with_date.date, Some(date));
}
#[test]
fn test_registry_default_sources() {
let registry = PriceSourceRegistry::default();
assert!(registry.has_source("yahoo"));
assert!(registry.has_source("coinbase"));
assert!(registry.has_source("coincap"));
assert!(registry.has_source("ecb"));
assert!(registry.has_source("ratesapi"));
assert!(registry.has_source("tsp"));
assert!(registry.has_source("eastmoneyfund"));
assert!(registry.has_source("oanda"));
assert!(registry.has_source("alphavantage"));
assert!(registry.has_source("coinmarketcap"));
assert!(registry.has_source("quandl"));
assert_eq!(registry.default_source_name(), "yahoo");
}
#[test]
fn test_registry_list_sources() {
let registry = PriceSourceRegistry::default();
let sources = registry.list_sources();
assert!(sources.contains(&"yahoo"));
assert!(sources.contains(&"coinbase"));
let mut sorted = sources.clone();
sorted.sort_unstable();
assert_eq!(sources, sorted);
}
#[test]
fn test_resolve_mapping_simple() {
let registry = PriceSourceRegistry::default();
let mut mapping = HashMap::new();
mapping.insert(
"BTC".to_string(),
CommodityMapping::Simple("BTC-USD".to_string()),
);
let attempts = registry.resolve_mapping("BTC", &mapping).unwrap();
assert_eq!(attempts, vec![("yahoo".to_string(), "BTC-USD".to_string())]);
}
#[test]
fn test_resolve_mapping_detailed() {
let registry = PriceSourceRegistry::default();
let mut mapping = HashMap::new();
mapping.insert(
"EUR".to_string(),
CommodityMapping::Detailed(crate::config::DetailedMapping {
source: SourceRef::Fallback(vec![
FallbackEntry::Name("ecb".to_string()),
FallbackEntry::Name("ratesapi".to_string()),
]),
ticker: None,
quote_currency: None,
}),
);
let attempts = registry.resolve_mapping("EUR", &mapping).unwrap();
assert_eq!(
attempts,
vec![
("ecb".to_string(), "EUR".to_string()),
("ratesapi".to_string(), "EUR".to_string()),
]
);
}
#[test]
fn test_resolve_mapping_no_mapping_errors_by_default() {
let registry = PriceSourceRegistry::default();
let mapping = HashMap::new();
let result = registry.resolve_mapping("AAPL", &mapping);
let err = result.expect_err("default behavior must refuse unmapped symbols");
let msg = err.to_string();
assert!(
msg.contains("AAPL"),
"error must name the offending symbol: {msg}"
);
for needle in [
"--source",
"--mapping",
"[price.mapping.AAPL]",
"price:",
"quote_currency:",
"-f",
"use_default_source",
] {
assert!(msg.contains(needle), "error must mention `{needle}`: {msg}");
}
}
#[test]
fn test_resolve_mapping_no_mapping_uses_default_when_opted_in() {
let config = PriceConfig {
use_default_source: Some(true),
..PriceConfig::default()
};
let registry = PriceSourceRegistry::new(&config);
let mapping = HashMap::new();
let attempts = registry
.resolve_mapping("AAPL", &mapping)
.expect("opt-in must allow default-source dispatch");
assert_eq!(attempts, vec![("yahoo".to_string(), "AAPL".to_string())]);
}
#[test]
fn test_resolve_mapping_fallback_uses_per_source_tickers() {
let registry = PriceSourceRegistry::default();
let mut mapping = HashMap::new();
mapping.insert(
"GBP".to_string(),
CommodityMapping::Detailed(crate::config::DetailedMapping {
source: SourceRef::Fallback(vec![
FallbackEntry::Detailed(FallbackDetail {
source: "ecbrates".to_string(),
ticker: Some("GBP-EUR".to_string()),
}),
FallbackEntry::Detailed(FallbackDetail {
source: "ecb".to_string(),
ticker: Some("GBP".to_string()),
}),
]),
ticker: Some("GBP-EUR".to_string()),
quote_currency: Some("EUR".to_string()),
}),
);
let attempts = registry.resolve_mapping("GBP", &mapping).unwrap();
assert_eq!(
attempts,
vec![
("ecbrates".to_string(), "GBP-EUR".to_string()),
("ecb".to_string(), "GBP".to_string()),
],
"each fallback source must use its own ticker (issue #963)"
);
}
#[test]
fn test_resolve_mapping_fallback_mixed_entries_inherit_parent_ticker() {
let registry = PriceSourceRegistry::default();
let mut mapping = HashMap::new();
mapping.insert(
"BTC".to_string(),
CommodityMapping::Detailed(crate::config::DetailedMapping {
source: SourceRef::Fallback(vec![
FallbackEntry::Name("yahoo".to_string()),
FallbackEntry::Detailed(FallbackDetail {
source: "coingecko".to_string(),
ticker: Some("bitcoin".to_string()),
}),
]),
ticker: Some("BTC-USD".to_string()),
quote_currency: None,
}),
);
let attempts = registry.resolve_mapping("BTC", &mapping).unwrap();
assert_eq!(
attempts,
vec![
("yahoo".to_string(), "BTC-USD".to_string()),
("coingecko".to_string(), "bitcoin".to_string()),
]
);
}
#[test]
fn test_custom_config() {
let config = PriceConfig {
default_source: Some("coinbase".to_string()),
timeout: Some(60),
..Default::default()
};
let registry = PriceSourceRegistry::new(&config);
assert_eq!(registry.default_source_name(), "coinbase");
assert_eq!(registry.timeout(), Duration::from_mins(1));
}
}