use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use crate::error::{FinanceError, Result};
use crate::models::filings::{
FilingSection, FilingSectionForm, ProviderFiling, ProviderFilings, RiskFactor,
};
use super::build_client;
use super::client::PathRule;
use super::models::PaginatedResponseDTO;
const INDEX_PATH: &str = "/stocks/filings/vX/index";
const TEN_K_SECTIONS_PATH: &str = "/stocks/filings/10-K/vX/sections";
const EIGHT_K_TEXT_PATH: &str = "/stocks/filings/8-K/vX/text";
const RISK_FACTORS_PATH: &str = "/stocks/filings/vX/risk-factors";
const MAX_SECTION_PAGES: usize = 10;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct FilingEntryDTO {
pub accession_number: Option<String>,
pub cik: Option<String>,
pub filing_date: Option<String>,
pub filing_url: Option<String>,
pub form_type: Option<String>,
pub issuer_name: Option<String>,
pub ticker: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TenKSectionDTO {
pub cik: Option<String>,
pub ticker: Option<String>,
pub section: Option<String>,
pub filing_date: Option<String>,
pub period_end: Option<String>,
pub filing_url: Option<String>,
pub text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct EightKTextDTO {
pub accession_number: Option<String>,
pub cik: Option<String>,
pub ticker: Option<String>,
pub form_type: Option<String>,
pub filing_date: Option<String>,
pub items_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RiskFactorDTO {
pub cik: Option<String>,
pub ticker: Option<String>,
pub primary_category: Option<String>,
pub secondary_category: Option<String>,
pub tertiary_category: Option<String>,
pub filing_date: Option<String>,
pub supporting_text: Option<String>,
}
pub async fn filing_index(params: &[(&str, &str)]) -> Result<PaginatedResponseDTO<FilingEntryDTO>> {
build_client()?.get(INDEX_PATH, params).await
}
pub async fn risk_factors(params: &[(&str, &str)]) -> Result<PaginatedResponseDTO<RiskFactorDTO>> {
build_client()?.get(RISK_FACTORS_PATH, params).await
}
fn filing_to_canonical(entry: FilingEntryDTO) -> ProviderFiling {
ProviderFiling {
accession_number: entry.accession_number,
filing_date: entry.filing_date,
filing_type: entry.form_type,
filing_url: entry.filing_url,
company_name: entry.issuer_name,
cik: entry.cik,
}
}
fn risk_factor_to_canonical(row: RiskFactorDTO) -> RiskFactor {
RiskFactor {
title: row.tertiary_category,
text: row.supporting_text,
category: row.primary_category,
filing_date: row.filing_date,
}
}
pub async fn fetch_filings_response(symbol: &str) -> Result<ProviderFilings> {
let page = filing_index(&[
("ticker", symbol),
("sort", "filing_date.desc"),
("limit", "100"),
])
.await?;
Ok(ProviderFilings {
symbol: symbol.to_string(),
filings: page
.results
.unwrap_or_default()
.into_iter()
.map(filing_to_canonical)
.collect(),
})
}
pub async fn fetch_risk_factors_response(symbol: &str) -> Result<Vec<RiskFactor>> {
let page = risk_factors(&[("ticker", symbol), ("limit", "100")]).await?;
Ok(page
.results
.unwrap_or_default()
.into_iter()
.map(risk_factor_to_canonical)
.collect())
}
fn accession_window(accession_number: &str) -> Option<(&str, String, String)> {
let mut parts = accession_number.split('-');
let (cik, year, sequence) = (parts.next()?, parts.next()?, parts.next()?);
let digits = |s: &str, len: usize| s.len() == len && s.bytes().all(|b| b.is_ascii_digit());
if parts.next().is_some() || !digits(cik, 10) || !digits(year, 2) || !digits(sequence, 6) {
return None;
}
let yy: i32 = year.parse().ok()?;
let year = if yy >= 93 { 1900 + yy } else { 2000 + yy };
Some((cik, format!("{year}-01-01"), format!("{}-01-15", year + 1)))
}
fn url_names_accession(url: &str, accession_number: &str) -> bool {
url.contains(accession_number) || url.contains(&accession_number.replace('-', ""))
}
async fn scan_pages<T: DeserializeOwned>(
path: &str,
params: &[(&str, &str)],
matches: &impl Fn(&T) -> bool,
) -> Result<Vec<T>> {
let client = build_client()?;
let mut found = Vec::new();
let mut cursor = None;
for _ in 0..MAX_SECTION_PAGES {
let (page, current): (PaginatedResponseDTO<T>, _) = client
.page(path, params, PathRule::Exact, cursor.as_ref())
.await?;
found.extend(page.results.unwrap_or_default().into_iter().filter(matches));
cursor = client.continuation(page.next_url, path, params, PathRule::Exact, ¤t)?;
if cursor.is_none() {
break;
}
}
Ok(found)
}
async fn rows_for_accession<T: DeserializeOwned>(
path: &str,
ticker: Option<&str>,
accession_number: &str,
matches: impl Fn(&T) -> bool,
) -> Result<Vec<T>> {
let (filer_cik, from, to) =
accession_window(accession_number).ok_or_else(|| FinanceError::InvalidParameter {
param: "accession_number".into(),
reason: "expected ##########-YY-######".into(),
})?;
let issuers = ticker
.map(|ticker| ("ticker", ticker))
.into_iter()
.chain([("cik", filer_cik)]);
for issuer in issuers {
let params = [
issuer,
("filing_date.gte", from.as_str()),
("filing_date.lte", to.as_str()),
("limit", "100"),
];
let found = scan_pages(path, ¶ms, &matches).await?;
if !found.is_empty() {
return Ok(found);
}
}
Ok(Vec::new())
}
pub async fn fetch_filing_sections_response(
ticker: Option<&str>,
accession_number: &str,
form: FilingSectionForm,
) -> Result<Vec<FilingSection>> {
let sections: Vec<FilingSection> = match form {
FilingSectionForm::TenK => rows_for_accession(
TEN_K_SECTIONS_PATH,
ticker,
accession_number,
|row: &TenKSectionDTO| {
row.filing_url
.as_deref()
.is_some_and(|url| url_names_accession(url, accession_number))
},
)
.await?
.into_iter()
.map(|row| FilingSection {
section: row.section,
content: row.text,
})
.collect(),
FilingSectionForm::EightK => rows_for_accession(
EIGHT_K_TEXT_PATH,
ticker,
accession_number,
|row: &EightKTextDTO| row.accession_number.as_deref() == Some(accession_number),
)
.await?
.into_iter()
.map(|row| FilingSection {
section: Some("items".into()),
content: row.items_text,
})
.collect(),
};
if sections.is_empty() {
return Err(FinanceError::SymbolNotFound {
symbol: None,
context: format!("Massive has no parsed text for filing {accession_number}"),
});
}
Ok(sections)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accession_numbers_give_the_filer_and_a_filing_year_window() {
assert_eq!(
accession_window("0000320193-25-000079"),
Some(("0000320193", "2025-01-01".into(), "2026-01-15".into()))
);
assert_eq!(
accession_window("0001047469-98-000123").map(|w| w.1),
Some("1998-01-01".into())
);
for bad in [
"",
"320193-25-000079",
"0000320193-2025-000079",
"0000320193-25-79x",
"a-b-c-d",
] {
assert_eq!(accession_window(bad), None, "{bad}");
}
}
#[test]
fn filing_urls_match_dashed_and_undashed_accessions() {
let accession = "0000320193-25-000079";
assert!(url_names_accession(
"https://www.sec.gov/Archives/edgar/data/320193/0000320193-25-000079.txt",
accession
));
assert!(url_names_accession(
"https://www.sec.gov/Archives/edgar/data/320193/000032019325000079/aapl-20250927.htm",
accession
));
assert!(!url_names_accession(
"https://www.sec.gov/Archives/edgar/data/320193/0000320193-24-000123.txt",
accession
));
}
#[test]
fn index_and_risk_rows_map_to_canonical_models() {
let entry: FilingEntryDTO = serde_json::from_value(serde_json::json!({
"cik": "0000320193",
"issuer_name": "Apple Inc.",
"form_type": "10-K",
"filing_date": "2025-10-31",
"filing_url": "https://www.sec.gov/Archives/edgar/data/320193/0000320193-25-000079.txt",
"accession_number": "0000320193-25-000079",
"ticker": "AAPL"
}))
.unwrap();
let filing = filing_to_canonical(entry);
assert_eq!(filing.filing_type.as_deref(), Some("10-K"));
assert_eq!(filing.company_name.as_deref(), Some("Apple Inc."));
let row: RiskFactorDTO = serde_json::from_value(serde_json::json!({
"cik": "0000320193",
"ticker": "AAPL",
"primary_category": "financial_and_market",
"secondary_category": "capital_structure_and_performance",
"tertiary_category": "dividend_policy_and_capital_allocation",
"filing_date": "2024-11-01",
"supporting_text": "The Company believes the price of its stock..."
}))
.unwrap();
let risk = risk_factor_to_canonical(row);
assert_eq!(risk.category.as_deref(), Some("financial_and_market"));
assert_eq!(
risk.title.as_deref(),
Some("dividend_policy_and_capital_allocation")
);
assert!(risk.text.unwrap().starts_with("The Company"));
}
}