use async_trait::async_trait;
use chrono::NaiveDate;
use std::collections::BTreeMap;
use tracing::warn;
use crate::curve::{YieldCurve, YieldType};
use crate::error::{Error, Result};
const MATURITY_COLUMNS: [&str; 12] = [
"1 Mo", "2 Mo", "3 Mo", "6 Mo", "1 Yr", "2 Yr", "3 Yr", "5 Yr", "7 Yr", "10 Yr", "20 Yr",
"30 Yr",
];
const MATURITY_DAYS: [u32; 12] = [
30, 60, 91, 182, 365, 730, 1095, 1825, 2555, 3650, 7300, 10950,
];
#[async_trait]
pub trait TreasuryFetcher: Send + Sync {
async fn fetch(&self, start: u32, end: u32) -> Result<Vec<YieldCurve>>;
}
pub fn parse_treasury_csv(csv: &str) -> Result<Vec<YieldCurve>> {
let mut lines = csv.lines();
let header = lines
.next()
.ok_or_else(|| Error::Treasury("empty CSV".into()))?;
let headers: Vec<&str> = split_csv_row(header);
let date_idx = headers
.iter()
.position(|h| h.trim_matches('"').trim() == "Date")
.ok_or_else(|| Error::Treasury("missing 'Date' column".into()))?;
let col_indices: [Option<usize>; 12] = std::array::from_fn(|i| {
headers
.iter()
.position(|col| col.trim_matches('"').trim() == MATURITY_COLUMNS[i])
});
let mut curves = Vec::new();
for line in lines {
if line.trim().is_empty() {
continue;
}
let fields = split_csv_row(line);
let date_str = match fields.get(date_idx) {
Some(s) => s.trim_matches('"').trim().to_owned(),
None => {
warn!(row = %line, "treasury: row missing date field, skipping");
continue;
}
};
let date = match parse_treasury_date(&date_str) {
Some(d) => d,
None => {
warn!(row = %line, date = %date_str, "treasury: unparseable date, skipping");
continue;
}
};
let mut points = BTreeMap::new();
for (i, col_opt) in col_indices.iter().enumerate() {
let Some(col_idx) = col_opt else { continue };
let Some(val_str) = fields.get(*col_idx) else {
continue;
};
let cleaned = val_str.trim_matches('"').trim();
if cleaned.is_empty() {
continue;
}
match cleaned.parse::<f64>() {
Ok(bey_pct) => {
let bey = bey_pct / 100.0;
let apy = (1.0 + bey / 2.0).powi(2) - 1.0;
let cont = (1.0 + apy).ln();
points.insert(MATURITY_DAYS[i], cont);
}
Err(_) => {
warn!(
col = MATURITY_COLUMNS[i],
val = cleaned,
"treasury: unparseable yield, skipping column"
);
}
}
}
curves.push(YieldCurve {
date,
yield_type: YieldType::Par,
points,
});
}
Ok(curves)
}
fn split_csv_row(row: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut in_quotes = false;
let mut start = 0usize;
for (i, ch) in row.char_indices() {
match ch {
'"' => in_quotes = !in_quotes,
',' if !in_quotes => {
out.push(&row[start..i]);
start = i + 1;
}
_ => {}
}
}
out.push(&row[start..]);
out
}
fn parse_treasury_date(s: &str) -> Option<NaiveDate> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() != 3 {
return None;
}
let mm: u32 = parts[0].parse().ok()?;
let dd: u32 = parts[1].parse().ok()?;
let yyyy: i32 = parts[2].parse().ok()?;
NaiveDate::from_ymd_opt(yyyy, mm, dd)
}
pub struct HttpTreasuryFetcher {
client: reqwest::Client,
}
impl HttpTreasuryFetcher {
pub fn new() -> anyhow::Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.user_agent("curvekit/0.1 (+https://github.com/userFRM/curvekit)")
.build()?;
Ok(Self { client })
}
fn url_for_year(year: i32) -> String {
format!(
"https://home.treasury.gov/resource-center/data-chart-center/interest-rates/\
daily-treasury-rates.csv/{year}/all\
?type=daily_treasury_yield_curve\
&field_tdr_date_value={year}\
&page&_format=csv"
)
}
}
#[async_trait]
impl TreasuryFetcher for HttpTreasuryFetcher {
async fn fetch(&self, start: u32, end: u32) -> Result<Vec<YieldCurve>> {
if start > end {
return Err(Error::Treasury(format!(
"invalid date range: start {start} > end {end}"
)));
}
let start_year = (start / 10000) as i32;
let end_year = (end / 10000) as i32;
let mut all: Vec<YieldCurve> = Vec::new();
for year in start_year..=end_year {
let url = Self::url_for_year(year);
let body = self
.client
.get(&url)
.send()
.await?
.error_for_status()?
.text()
.await?;
all.extend(parse_treasury_csv(&body)?);
}
all.retain(|c| {
let d = date_to_yyyymmdd(c.date);
d >= start && d <= end
});
Ok(all)
}
}
fn date_to_yyyymmdd(d: NaiveDate) -> u32 {
(d.format("%Y%m%d").to_string()).parse().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn fixture(name: &str) -> String {
let path: PathBuf = [
env!("CARGO_MANIFEST_DIR"),
"..",
"..",
"tests",
"fixtures",
name,
]
.iter()
.collect();
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}
#[test]
fn parse_happy_returns_two_curves() {
let curves = parse_treasury_csv(&fixture("treasury_happy.csv")).unwrap();
assert_eq!(curves.len(), 2);
assert_eq!(
curves[0].date,
NaiveDate::from_ymd_opt(2026, 4, 14).unwrap()
);
assert_eq!(
curves[1].date,
NaiveDate::from_ymd_opt(2026, 4, 15).unwrap()
);
assert_eq!(curves[1].points.len(), 12);
}
#[test]
fn parse_happy_ten_year_continuous_rate() {
let curves = parse_treasury_csv(&fixture("treasury_happy.csv")).unwrap();
let c = &curves[1]; let r = c.points[&3650];
let bey = 0.0387_f64;
let apy = (1.0 + bey / 2.0).powi(2) - 1.0;
let expected = (1.0 + apy).ln();
assert!((r - expected).abs() < 1e-9, "r={r} expected={expected}");
}
#[test]
fn parse_partial_fills_present_columns_only() {
let curves = parse_treasury_csv(&fixture("treasury_partial.csv")).unwrap();
assert_eq!(curves.len(), 1);
let c = &curves[0];
for &days in &[30u32, 91, 182, 365, 3650] {
assert!(c.points.contains_key(&days), "missing {days}d");
}
for &days in &[60u32, 730, 1095, 1825, 2555, 7300, 10950] {
assert!(!c.points.contains_key(&days), "unexpected {days}d");
}
}
#[test]
fn parse_empty_returns_empty_vec() {
let curves = parse_treasury_csv(&fixture("treasury_empty.csv")).unwrap();
assert!(curves.is_empty());
}
}