14.3 KB
raw
//! Data-source abstraction.
//!
//! Each upstream sits behind a trait so a source can be swapped without
//! touching callers. `QuoteProvider` + `HistoryProvider` (both Yahoo) cover
//! live quotes/intraday and deep daily history; `FundamentalsProvider` (SEC
//! EDGAR) covers stock fundamentals, filings, leadership, and ETF profiles.
//! (Stooq was the original history source; it was dropped 2026-05-30 — see
//! the data-source policy.)
pub mod http;
pub mod sec;
pub mod yahoo;
use std::collections::HashMap;
use anyhow::Result;
use async_trait::async_trait;
/// One day of OHLCV, as delivered by a history source.
#[derive(Debug, Clone)]
pub struct DailyBar {
/// Trading date, `YYYY-MM-DD`.
pub d: String,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: i64,
}
/// Deep daily OHLCV history. Implemented by `YahooProvider` (one
/// `interval=1d` chart call returns a symbol's whole history, or an
/// incremental window when `since` is given).
#[async_trait]
pub trait HistoryProvider: Send + Sync {
fn name(&self) -> &'static str;
/// Daily bars for `ticker`, oldest first. `since` (a `YYYY-MM-DD` date)
/// trims the response to an incremental window when supplied.
async fn daily(&self, ticker: &str, since: Option<&str>) -> Result<Vec<DailyBar>>;
}
/// A live quote snapshot from a quote source.
#[derive(Debug, Clone)]
pub struct Quote {
pub price: f64,
pub prev_close: Option<f64>,
pub open: Option<f64>,
pub day_high: Option<f64>,
pub day_low: Option<f64>,
pub volume: Option<i64>,
/// The source's market-state label (e.g. `REGULAR`, `PRE`, `CLOSED`).
pub market_state: Option<String>,
/// The source's own timestamp for this quote, UTC epoch-ms.
pub source_time: Option<i64>,
}
/// One intraday OHLCV bar — 15-minute granularity from Yahoo.
#[derive(Debug, Clone)]
pub struct IntradayBar {
/// Bar start, UTC epoch-ms.
pub ts: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume: i64,
}
/// A quote source's full reply: the live quote plus the day's intraday bars,
/// both from one request.
#[derive(Debug, Clone)]
pub struct QuoteData {
pub quote: Quote,
pub bars: Vec<IntradayBar>,
}
/// Near-real-time quotes and intraday bars. Implemented by `YahooProvider`.
#[async_trait]
pub trait QuoteProvider: Send + Sync {
/// The latest quote and the day's intraday bars for `ticker`.
async fn quote(&self, ticker: &str) -> Result<QuoteData>;
}
/// One fundamental fact: a single metric for a single fiscal period, parsed
/// from a company's SEC XBRL facts.
#[derive(Debug, Clone)]
pub struct Fact {
/// Our canonical metric name, e.g. `revenue`, `eps_diluted`.
pub metric: String,
/// Fiscal-period label: `FY2024` for a full year, `Q3-2024` for a quarter.
pub period: String,
pub fiscal_year: i64,
/// `None` for a full-year figure.
pub fiscal_qtr: Option<i64>,
/// Period end, `YYYY-MM-DD`.
pub period_end: String,
pub value: f64,
/// XBRL unit, e.g. `USD`, `USD/shares`, `shares`.
pub unit: Option<String>,
/// The form the figure was reported on, e.g. `10-K`.
pub form: Option<String>,
/// Filing date, `YYYY-MM-DD`.
pub filed_at: Option<String>,
}
/// One SEC filing from a company's submission history.
#[derive(Debug, Clone)]
pub struct FilingRecord {
pub accession: String,
pub form: String,
/// Filing date, `YYYY-MM-DD`.
pub filed_at: String,
/// The period the filing reports on, `YYYY-MM-DD`.
pub period_of_report: Option<String>,
pub primary_doc: Option<String>,
/// Full URL to the filing's primary document (or index) on EDGAR.
pub url: String,
pub description: Option<String>,
/// For an 8-K, the reported item codes, comma-separated as EDGAR lists them
/// (e.g. `5.02,9.01`); `None` for other forms. Item 5.02 is the
/// officer/director change the leadership-changes feed keys on (Phase 14).
pub items: Option<String>,
}
/// Company fundamentals and filing history from SEC EDGAR. Implemented by
/// `SecProvider`. Stocks only; ETFs and indexes do not file.
#[async_trait]
pub trait FundamentalsProvider: Send + Sync {
fn name(&self) -> &'static str;
/// The whole-market ticker -> CIK map, from one bulk request. Keys are
/// tickers normalised to bare uppercase alphanumerics (so our `BRK.B`
/// matches EDGAR's `BRK-B`); values are 10-digit zero-padded CIKs.
async fn cik_map(&self) -> Result<HashMap<String, String>>;
/// XBRL fundamental facts for one company, by its 10-digit CIK.
async fn facts(&self, cik: &str) -> Result<Vec<Fact>>;
/// Recent filing history for one company, by its 10-digit CIK.
async fn filings(&self, cik: &str) -> Result<Vec<FilingRecord>>;
}
// ── company leadership (Phase 14) ──────────────────────────────────────────
//
// A company's officers and board come from SEC Form 3/4/5 ownership filings:
// every director and Section-16 officer must file these, and each carries a
// structured `reportingOwnerRelationship`. Like the N-PORT fund methods, the
// leadership methods are inherent to `SecProvider` (this is wholly EDGAR
// territory), but their data types sit here beside `FilingRecord`.
/// One Form 3/4/5 ownership filing in a company's submission history — the
/// pointer the scheduler needs to fetch and parse the ownership XML itself.
#[derive(Debug, Clone)]
pub struct OwnershipFiling {
pub accession: String,
/// Filing date, `YYYY-MM-DD`.
pub filed_at: String,
/// The ownership XML's file name within the filing's Archives directory.
pub primary_doc: String,
}
/// One insider parsed from an ownership XML's `reportingOwnerRelationship`.
#[derive(Debug, Clone)]
pub struct OwnershipPerson {
/// Name as filed: last-name-first and upper-case (`COOK TIMOTHY D`).
pub name: String,
pub is_director: bool,
pub is_officer: bool,
/// The officer title, present when `is_officer` and the filer gave one.
pub officer_title: Option<String>,
}
// ── ETF fund profiles (Phase 18) ───────────────────────────────────────────
//
// ETFs file as registered funds: their portfolio comes from quarterly N-PORT
// filings, not the XBRL companyfacts behind the stock fundamentals above. The
// fund methods live on `SecProvider` as inherent methods (N-PORT is wholly
// SEC-specific, with no second source to abstract over), but their data types
// sit here next to `Fact` / `FilingRecord` for the scheduler and routes.
/// Identifies one ETF to the SEC's fund endpoints.
#[derive(Debug, Clone)]
pub struct FundId {
/// 10-digit zero-padded registrant CIK.
pub cik: String,
/// SEC series id (e.g. `S000002839`), present when the registrant hosts
/// more than one fund — then it, not the CIK, pins a lookup to this ETF.
pub series_id: Option<String>,
}
/// One portfolio holding parsed from an N-PORT filing.
#[derive(Debug, Clone)]
pub struct FundHolding {
/// Issuer / security name as the fund reported it.
pub name: String,
/// Percent of the fund's net assets, e.g. `8.4`.
pub pct: Option<f64>,
/// Market value of the position, USD.
pub value_usd: Option<f64>,
/// N-PORT asset-category code (`EC` equity, `DBT` debt, ...), for the mix.
pub asset_cat: Option<String>,
}
/// What a fund's filing history reveals about how to read its portfolio.
#[derive(Debug, Clone)]
pub enum FundShape {
/// A fund that files N-PORT: fetch this filing for its holdings. The value
/// is the filing's EDGAR index-page URL, whose directory also holds the
/// N-PORT XML — and which carries the registrant CIK even when a filing
/// agent (not the fund) is the named filer on the accession number.
Portfolio { nport_href: String },
/// A physical-commodity grantor trust (GLD, SLV): no N-PORT — it holds
/// bullion, not a securities portfolio — so AUM comes from its 10-K.
CommodityTrust,
/// Neither pattern matched; the page can still show the filing list.
Unknown,
}
/// The filing list for an ETF plus what it implies about the fund's shape.
#[derive(Debug, Clone)]
pub struct FundFilings {
pub filings: Vec<FilingRecord>,
pub shape: FundShape,
}
/// A fund's portfolio snapshot, parsed from one N-PORT filing.
#[derive(Debug, Clone, Default)]
pub struct PortfolioData {
/// Total net assets (AUM), USD.
pub net_assets: Option<f64>,
/// Gross assets, USD.
pub total_assets: Option<f64>,
/// The date the holdings are reported as of, `YYYY-MM-DD`.
pub report_date: Option<String>,
/// Positions in the full portfolio (not just the top slice kept below).
pub holdings_count: i64,
/// The largest holdings by weight, largest first.
pub top_holdings: Vec<FundHolding>,
/// Asset-class mix as `(bucket, percent)` pairs, largest bucket first.
pub asset_mix: Vec<(String, f64)>,
/// Sector mix derived from each holding's N-PORT `industryCode` (or
/// `assetCat` fallback for non-equity buckets), aggregated as
/// `(label, percent)` pairs largest first. Phase 28; empty on a
/// commodity-trust fund (no N-PORT).
pub sector_mix: Vec<(String, f64)>,
/// Geography mix derived from each holding's issuer country, same shape
/// as `sector_mix`. Phase 28; empty on a commodity trust.
pub geography_mix: Vec<(String, f64)>,
}
// ── dividend events (Phase 26) ─────────────────────────────────────────────
//
// Per-payout dividend history comes from Yahoo's chart endpoint, which carries
// an `events.dividends` series alongside the price bars when asked for
// `events=div`. SEC XBRL's `DividendsPerShare` (already in `fundamentals`) is
// per fiscal period, not per payout date, so it does not stand in. The fetch
// lives on `YahooProvider` as an inherent method (one source); the type sits
// here next to `Quote`/`IntradayBar` for the scheduler and routes.
/// One declared dividend payment, as carried by Yahoo's chart event series.
#[derive(Debug, Clone)]
pub struct DividendEvent {
/// Ex-dividend date, `YYYY-MM-DD`. The first trading day a new buyer does
/// NOT receive the upcoming payment — Yahoo timestamps each event by it.
pub ex_date: String,
/// Per-share amount, in the symbol's reporting currency.
pub amount: f64,
}
// ── ETF fund metadata (Phase 28) ───────────────────────────────────────────
//
// The slow-moving figures the prospectus carries that N-PORT does not — expense
// ratio, distribution yield, inception, category, fund family, the issuer's
// strategy paragraph — plus the intraday NAV used for the premium / discount
// read. Yahoo's `v10/finance/quoteSummary` endpoint serves all of them in one
// request behind the `fundProfile + defaultKeyStatistics + summaryDetail +
// price + assetProfile` modules. The fetch lives on `YahooProvider` as an
// inherent method (one source); the type sits here next to `Quote` /
// `DividendEvent` for the scheduler and routes.
/// One ETF's Yahoo `quoteSummary` snapshot. Every field is optional: Yahoo's
/// coverage is uneven and a small fund may carry only a subset, but a partial
/// snapshot is still useful, so the parser keeps what it has rather than
/// rejecting the row.
#[derive(Debug, Clone, Default)]
pub struct FundMetadata {
/// Annual expense ratio as a decimal, e.g. `0.0003` = 0.03%. From
/// `fundProfile.feesExpensesInvestment.annualReportExpenseRatio`.
pub expense_ratio: Option<f64>,
/// Forward / trailing distribution yield as a decimal. From
/// `summaryDetail.yield` (preferred) or `defaultKeyStatistics.yield`.
pub yield_pct: Option<f64>,
/// Trailing-twelve-month distribution yield as a decimal. From
/// `summaryDetail.trailingAnnualDividendYield`.
pub trailing_yield_pct: Option<f64>,
/// Latest NAV from `price.navPrice` or `summaryDetail.navPrice`. USD.
pub nav_price: Option<f64>,
/// Inception / first trade date as `YYYY-MM-DD`. From
/// `defaultKeyStatistics.fundInceptionDate` or `price.firstTradeDateEpochUtc`.
pub inception_date: Option<String>,
/// Morningstar-style fund category, e.g. "Large Blend". From
/// `fundProfile.categoryName`.
pub category: Option<String>,
/// Sponsor family, e.g. "Vanguard". From `fundProfile.family`.
pub fund_family: Option<String>,
/// The fund's strategy paragraph as the issuer writes it. From
/// `assetProfile.longBusinessSummary` (preferred) or
/// `summaryProfile.longBusinessSummary`.
pub strategy_summary: Option<String>,
}
/// One stock's Yahoo `assetProfile` classification (Phase 15). Both fields
/// are optional: small-cap and foreign tickers occasionally carry only one,
/// and a request that returned an `assetProfile` module Yahoo populated only
/// partially leaves the other side `None` rather than rejecting the row.
#[derive(Debug, Clone, Default)]
pub struct AssetProfile {
/// GICS-style sector ("Technology"). From `assetProfile.sector`.
pub sector: Option<String>,
/// GICS-style industry ("Consumer Electronics"). From
/// `assetProfile.industry`.
pub industry: Option<String>,
}
/// An upstream rejected a request with an explicit rate-limit signal (HTTP 429
/// or 503). A provider returns this as the source of its `anyhow::Error` so the
/// `EndpointGuard` (see `src/guard.rs`) can recognise it by downcast and trip
/// the circuit breaker immediately, rather than waiting for a failure streak.
#[derive(Debug)]
pub struct RateLimited {
/// The HTTP status that carried the signal.
pub status: u16,
/// `Retry-After` from the response, in seconds, when the upstream sent one
/// in the numeric form. The HTTP-date form is not parsed (the guard's own
/// exponential backoff covers it), so this is `None` then.
pub retry_after_secs: Option<i64>,
}
impl std::fmt::Display for RateLimited {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "upstream rate-limited (HTTP {})", self.status)?;
if let Some(s) = self.retry_after_secs {
write!(f, ", Retry-After {s}s")?;
}
Ok(())
}
}
impl std::error::Error for RateLimited {}