use std::io::{Cursor, Read};
use chrono::{Datelike, NaiveDate, Utc};
use crate::adapters::edgar::build_client;
use crate::error::{FinanceError, Result};
use crate::models::filings::FailToDeliver;
const SEC_WWW_BASE: &str = "https://www.sec.gov";
const LOOKBACK_PERIODS: usize = 3;
const MAX_ATTEMPTS: usize = 8;
fn periods_desc(from: NaiveDate) -> impl Iterator<Item = String> {
let mut year = from.year();
let mut month = from.month();
let mut half = if from.day() <= 15 { 'a' } else { 'b' };
std::iter::from_fn(move || {
let period = format!("{year:04}{month:02}{half}");
if half == 'b' {
half = 'a';
} else {
half = 'b';
month -= 1;
if month == 0 {
month = 12;
year -= 1;
}
}
Some(period)
})
}
fn zip_url(base: &str, period: &str) -> String {
format!("{base}/files/data/fails-deliver-data/cnsfails{period}.zip")
}
fn format_settlement_date(raw: &str) -> Option<String> {
(raw.len() == 8).then(|| format!("{}-{}-{}", &raw[0..4], &raw[4..6], &raw[6..8]))
}
fn parse_rows(text: &str, symbol: &str) -> Vec<FailToDeliver> {
text.lines()
.skip(1)
.filter_map(|line| {
let mut cols = line.split('|');
let date = cols.next()?;
let _cusip = cols.next()?;
let sym = cols.next()?;
if sym != symbol {
return None;
}
let quantity = cols.next()?;
let description = cols.next()?;
let price = cols.next()?;
let name = description.trim();
Some(FailToDeliver {
symbol: Some(sym.to_string()),
date: format_settlement_date(date),
quantity: quantity.trim().parse().ok(),
price: price.trim().parse().ok(),
name: (!name.is_empty()).then(|| name.to_string()),
description: None,
})
})
.collect()
}
fn parse_period(bytes: &[u8], symbol: &str) -> Result<Vec<FailToDeliver>> {
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| {
FinanceError::ResponseStructureError {
field: "secftd.zip".to_string(),
context: format!("failed to open fails-to-deliver archive: {e}"),
}
})?;
let mut entry = archive
.by_index(0)
.map_err(|e| FinanceError::ResponseStructureError {
field: "secftd.zip".to_string(),
context: format!("empty fails-to-deliver archive: {e}"),
})?;
let mut text = String::new();
entry
.read_to_string(&mut text)
.map_err(|e| FinanceError::ResponseStructureError {
field: "secftd.zip".to_string(),
context: format!("non-UTF8 fails-to-deliver data: {e}"),
})?;
Ok(parse_rows(&text, symbol))
}
async fn fetch_fails_to_deliver_with(
base: &str,
symbol: &str,
lookback_periods: usize,
max_attempts: usize,
) -> Result<Vec<FailToDeliver>> {
let client = build_client()?;
let symbol = symbol.to_uppercase();
let mut records = Vec::new();
let mut periods_found = 0;
for period in periods_desc(Utc::now().date_naive()).take(max_attempts) {
if periods_found >= lookback_periods {
break;
}
let bytes = match client.get_document(&zip_url(base, &period)).await {
Ok(bytes) => bytes,
Err(FinanceError::SymbolNotFound { .. }) => continue,
Err(e) => return Err(e),
};
records.extend(parse_period(&bytes, &symbol)?);
periods_found += 1;
}
records.sort_by(|a, b| a.date.cmp(&b.date));
Ok(records)
}
pub(crate) async fn fetch_fails_to_deliver_response(symbol: &str) -> Result<Vec<FailToDeliver>> {
fetch_fails_to_deliver_with(SEC_WWW_BASE, symbol, LOOKBACK_PERIODS, MAX_ATTEMPTS).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn periods_walk_back_across_year_and_half_boundaries() {
let start = NaiveDate::from_ymd_opt(2026, 1, 10).unwrap();
let periods: Vec<String> = periods_desc(start).take(4).collect();
assert_eq!(periods, vec!["202601a", "202512b", "202512a", "202511b"]);
}
#[test]
fn periods_split_on_the_15th() {
let first_half = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
let second_half = NaiveDate::from_ymd_opt(2026, 3, 16).unwrap();
assert_eq!(periods_desc(first_half).next().unwrap(), "202603a");
assert_eq!(periods_desc(second_half).next().unwrap(), "202603b");
}
#[test]
fn rows_are_filtered_by_symbol_and_mapped() {
let text = "SETTLEMENT DATE|CUSIP|SYMBOL|QUANTITY (FAILS)|DESCRIPTION|PRICE\n\
20250602|037833100|AAPL|69|APPLE INC;COM NPV|201.70\n\
20250602|B38564108|CMBT|51003|CMB.TECH NV (BEL)|8.83\n";
let rows = parse_rows(text, "AAPL");
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].symbol.as_deref(), Some("AAPL"));
assert_eq!(rows[0].date.as_deref(), Some("2025-06-02"));
assert_eq!(rows[0].quantity, Some(69.0));
assert!((rows[0].price.unwrap() - 201.70).abs() < 1e-9);
assert_eq!(rows[0].name.as_deref(), Some("APPLE INC;COM NPV"));
}
#[test]
fn a_symbol_absent_from_the_period_yields_no_rows() {
let text = "SETTLEMENT DATE|CUSIP|SYMBOL|QUANTITY (FAILS)|DESCRIPTION|PRICE\n\
20250602|B38564108|CMBT|51003|CMB.TECH NV (BEL)|8.83\n";
assert!(parse_rows(text, "AAPL").is_empty());
}
fn zip_bytes(inner_name: &str, contents: &str) -> Vec<u8> {
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored);
writer.start_file(inner_name, options).unwrap();
writer.write_all(contents.as_bytes()).unwrap();
writer.finish().unwrap().into_inner()
}
#[tokio::test]
async fn fetches_the_current_period_when_it_is_already_published() {
let _ = crate::adapters::edgar::init("test@example.com");
let mut server = mockito::Server::new_async().await;
let period = periods_desc(Utc::now().date_naive()).next().unwrap();
let body = zip_bytes(
&format!("cnsfails{period}"),
"SETTLEMENT DATE|CUSIP|SYMBOL|QUANTITY (FAILS)|DESCRIPTION|PRICE\n\
20250602|037833100|AAPL|69|APPLE INC;COM NPV|201.70\n",
);
let _m = server
.mock(
"GET",
format!("/files/data/fails-deliver-data/cnsfails{period}.zip").as_str(),
)
.with_status(200)
.with_header("content-type", "application/octet-stream")
.with_body(body)
.create_async()
.await;
let records = fetch_fails_to_deliver_with(&server.url(), "AAPL", 1, 1)
.await
.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].symbol.as_deref(), Some("AAPL"));
}
#[tokio::test]
async fn walks_back_one_period_when_the_current_one_is_unpublished() {
let _ = crate::adapters::edgar::init("test@example.com");
let mut server = mockito::Server::new_async().await;
let mut periods = periods_desc(Utc::now().date_naive());
let current = periods.next().unwrap();
let previous = periods.next().unwrap();
let _missing = server
.mock(
"GET",
format!("/files/data/fails-deliver-data/cnsfails{current}.zip").as_str(),
)
.with_status(404)
.create_async()
.await;
let body = zip_bytes(
&format!("cnsfails{previous}"),
"SETTLEMENT DATE|CUSIP|SYMBOL|QUANTITY (FAILS)|DESCRIPTION|PRICE\n\
20250602|037833100|AAPL|69|APPLE INC;COM NPV|201.70\n",
);
let _m = server
.mock(
"GET",
format!("/files/data/fails-deliver-data/cnsfails{previous}.zip").as_str(),
)
.with_status(200)
.with_header("content-type", "application/octet-stream")
.with_body(body)
.create_async()
.await;
let records = fetch_fails_to_deliver_with(&server.url(), "AAPL", 1, 4)
.await
.unwrap();
assert_eq!(records.len(), 1);
}
#[tokio::test]
#[ignore = "requires network access"]
async fn test_live_fails_to_deliver() {
let _ = crate::adapters::edgar::init("test@example.com");
let records = fetch_fails_to_deliver_response("AAPL").await.unwrap();
assert!(!records.is_empty());
}
}