use crate::parse::parse_usd;
use crate::AdapterError;
use btctax_core::{PriceProvider, TaxDate, Usd};
use std::collections::BTreeMap;
use time::macros::format_description;
const DATASET_CSV: &str = include_str!("../data/btc_usd_daily_close.csv");
#[derive(Debug, Clone)]
pub struct BundledPrices {
by_date: BTreeMap<TaxDate, Usd>,
}
impl BundledPrices {
pub fn load() -> Result<Self, AdapterError> {
Self::from_csv_str(DATASET_CSV)
}
pub fn max_date(&self) -> Option<TaxDate> {
self.by_date.keys().next_back().copied()
}
pub fn contains(&self, date: TaxDate) -> bool {
self.by_date.contains_key(&date)
}
pub fn from_csv_str(csv: &str) -> Result<Self, AdapterError> {
let date_fmt = format_description!("[year]-[month]-[day]");
let mut by_date = BTreeMap::new();
for (i, line) in csv.lines().enumerate() {
let line = line.trim();
if line.is_empty() || (i == 0 && line.starts_with("date")) {
continue;
}
let (d, p) = line.split_once(',').ok_or_else(|| {
AdapterError::PriceDataset(format!("line {}: expected `date,usd_close`", i + 1))
})?;
let date = TaxDate::parse(d.trim(), &date_fmt).map_err(|e| {
AdapterError::PriceDataset(format!("line {}: bad date {:?}: {e}", i + 1, d))
})?;
let close = parse_usd("price-dataset", i + 1, "usd_close", p)?;
by_date.insert(date, close);
}
Ok(Self { by_date })
}
}
impl PriceProvider for BundledPrices {
fn usd_per_btc(&self, date: TaxDate) -> Option<Usd> {
self.by_date.get(&date).copied()
}
}
#[derive(Debug, Clone)]
pub struct LayeredPrices {
bundled: BundledPrices,
cache: BundledPrices,
}
impl LayeredPrices {
pub fn load_with_cache(cache_path: Option<&std::path::Path>) -> Result<Self, AdapterError> {
let bundled = BundledPrices::load()?;
let cache = match cache_path {
Some(p) if p.exists() => {
let csv = std::fs::read_to_string(p).map_err(|source| AdapterError::Io {
path: p.display().to_string(),
source,
})?;
BundledPrices::from_csv_str(&csv)?
}
_ => BundledPrices {
by_date: BTreeMap::new(),
},
};
Ok(Self { bundled, cache })
}
}
impl PriceProvider for LayeredPrices {
fn usd_per_btc(&self, date: TaxDate) -> Option<Usd> {
self.cache
.usd_per_btc(date)
.or_else(|| self.bundled.usd_per_btc(date))
}
}
#[cfg(test)]
mod tests {
use super::*;
use btctax_core::price::fmv_of;
use rust_decimal_macros::dec;
use time::macros::date;
#[test]
fn looks_up_daily_close_exact_date() {
let p = BundledPrices::from_csv_str("date,usd_close\n2025-03-01,84000.00\n").unwrap();
assert_eq!(p.usd_per_btc(date!(2025 - 03 - 01)), Some(dec!(84000.00)));
assert_eq!(p.usd_per_btc(date!(2025 - 03 - 02)), None); }
#[test]
fn fmv_of_uses_provider_for_sat_quantity() {
let p = BundledPrices::from_csv_str("date,usd_close\n2025-03-01,84000.00\n").unwrap();
assert_eq!(
fmv_of(&p, date!(2025 - 03 - 01), 50_000_000),
Some(dec!(42000.00))
);
}
#[test]
fn parses_exact_decimals_not_floats() {
let p = BundledPrices::from_csv_str("date,usd_close\n2024-02-01,43100.50\n").unwrap();
assert_eq!(p.usd_per_btc(date!(2024 - 02 - 01)), Some(dec!(43100.50)));
}
#[test]
fn bundled_dataset_row_count() {
let p = BundledPrices::load().unwrap();
assert_eq!(
p.by_date.len(),
5801,
"bundled daily-close row count changed — regenerate intentionally + update this pin"
);
}
#[test]
fn bundled_dataset_parses_sorted_deduped() {
let raw_lines = DATASET_CSV
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("date"))
.count();
let p = BundledPrices::load().unwrap();
assert_eq!(
p.by_date.len(),
raw_lines,
"a duplicate date collapsed on load — the source dataset is not deduped"
);
let dates: Vec<_> = p.by_date.keys().copied().collect();
assert!(
dates.windows(2).all(|w| w[0] < w[1]),
"dates must be strictly ascending (sorted, no duplicates)"
);
}
#[test]
fn bundled_dataset_covers_2010_to_2026() {
let p = BundledPrices::load().unwrap();
assert_eq!(
p.usd_per_btc(date!(2010 - 07 - 17)),
Some(dec!(0.05)),
"first close (2010-07-17)"
);
assert_eq!(
p.usd_per_btc(date!(2025 - 06 - 15)),
Some(dec!(105651.98)),
"mid-range spot (2025-06-15)"
);
assert_eq!(
p.usd_per_btc(date!(2026 - 06 - 03)),
Some(dec!(64813.38)),
"last close (2026-06-03)"
);
assert_eq!(
p.usd_per_btc(date!(2009 - 01 - 03)),
None,
"before coverage → None"
);
assert_eq!(
p.usd_per_btc(date!(2030 - 01 - 01)),
None,
"after coverage → None"
);
}
#[test]
fn real_date_fmv_is_exact() {
let p = BundledPrices::load().unwrap();
assert_eq!(
fmv_of(&p, date!(2025 - 06 - 15), 100_000_000),
Some(dec!(105651.98))
);
assert_eq!(
fmv_of(&p, date!(2025 - 06 - 15), 50_000_000),
Some(dec!(52825.99))
);
}
#[test]
fn layered_prices_cache_over_bundled() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("price_cache.csv");
std::fs::write(
&cache,
"date,usd_close\n2026-06-03,70000.00\n2026-07-01,71234.56\n",
)
.unwrap();
let p = LayeredPrices::load_with_cache(Some(cache.as_path())).unwrap();
assert_eq!(
p.usd_per_btc(date!(2026 - 06 - 03)),
Some(dec!(70000.00)),
"the cache overrides the bundled close for a shared date"
);
assert_eq!(
p.usd_per_btc(date!(2026 - 07 - 01)),
Some(dec!(71234.56)),
"the cache supplies a date beyond the bundled range"
);
assert_eq!(
p.usd_per_btc(date!(2025 - 06 - 15)),
Some(dec!(105651.98)),
"a bundled-only date still resolves from the shipped dataset"
);
}
#[test]
fn cache_absent_is_bundled_only() {
let bundled = BundledPrices::load().unwrap();
for cache_path in [
None,
Some(std::path::Path::new("/nonexistent/price_cache.csv")),
] {
let layered = LayeredPrices::load_with_cache(cache_path).unwrap();
for d in [
date!(2010 - 07 - 17),
date!(2025 - 06 - 15),
date!(2026 - 06 - 03),
date!(2030 - 01 - 01), ] {
assert_eq!(
layered.usd_per_btc(d),
bundled.usd_per_btc(d),
"layered (no cache) must equal bundled-only at {d}"
);
}
}
}
}