repos
/ finance-rust master

finance-rust

mirror archived upstream

Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.

axumdockerfinancerustself-hostedsqlitestocksvite

14.3 KB · 351 lines · Rust Raw History
  1//! Data-source abstraction.
  2//!
  3//! Each upstream sits behind a trait so a source can be swapped without
  4//! touching callers. `QuoteProvider` + `HistoryProvider` (both Yahoo) cover
  5//! live quotes/intraday and deep daily history; `FundamentalsProvider` (SEC
  6//! EDGAR) covers stock fundamentals, filings, leadership, and ETF profiles.
  7//! (Stooq was the original history source; it was dropped 2026-05-30 — see
  8//! the data-source policy.)
  9
 10pub mod http;
 11pub mod sec;
 12pub mod yahoo;
 13
 14use std::collections::HashMap;
 15
 16use anyhow::Result;
 17use async_trait::async_trait;
 18
 19/// One day of OHLCV, as delivered by a history source.
 20#[derive(Debug, Clone)]
 21pub struct DailyBar {
 22    /// Trading date, `YYYY-MM-DD`.
 23    pub d: String,
 24    pub open: f64,
 25    pub high: f64,
 26    pub low: f64,
 27    pub close: f64,
 28    pub volume: i64,
 29}
 30
 31/// Deep daily OHLCV history. Implemented by `YahooProvider` (one
 32/// `interval=1d` chart call returns a symbol's whole history, or an
 33/// incremental window when `since` is given).
 34#[async_trait]
 35pub trait HistoryProvider: Send + Sync {
 36    fn name(&self) -> &'static str;
 37
 38    /// Daily bars for `ticker`, oldest first. `since` (a `YYYY-MM-DD` date)
 39    /// trims the response to an incremental window when supplied.
 40    async fn daily(&self, ticker: &str, since: Option<&str>) -> Result<Vec<DailyBar>>;
 41}
 42
 43/// A live quote snapshot from a quote source.
 44#[derive(Debug, Clone)]
 45pub struct Quote {
 46    pub price: f64,
 47    pub prev_close: Option<f64>,
 48    pub open: Option<f64>,
 49    pub day_high: Option<f64>,
 50    pub day_low: Option<f64>,
 51    pub volume: Option<i64>,
 52    /// The source's market-state label (e.g. `REGULAR`, `PRE`, `CLOSED`).
 53    pub market_state: Option<String>,
 54    /// The source's own timestamp for this quote, UTC epoch-ms.
 55    pub source_time: Option<i64>,
 56}
 57
 58/// One intraday OHLCV bar — 15-minute granularity from Yahoo.
 59#[derive(Debug, Clone)]
 60pub struct IntradayBar {
 61    /// Bar start, UTC epoch-ms.
 62    pub ts: i64,
 63    pub open: f64,
 64    pub high: f64,
 65    pub low: f64,
 66    pub close: f64,
 67    pub volume: i64,
 68}
 69
 70/// A quote source's full reply: the live quote plus the day's intraday bars,
 71/// both from one request.
 72#[derive(Debug, Clone)]
 73pub struct QuoteData {
 74    pub quote: Quote,
 75    pub bars: Vec<IntradayBar>,
 76}
 77
 78/// Near-real-time quotes and intraday bars. Implemented by `YahooProvider`.
 79#[async_trait]
 80pub trait QuoteProvider: Send + Sync {
 81    /// The latest quote and the day's intraday bars for `ticker`.
 82    async fn quote(&self, ticker: &str) -> Result<QuoteData>;
 83}
 84
 85/// One fundamental fact: a single metric for a single fiscal period, parsed
 86/// from a company's SEC XBRL facts.
 87#[derive(Debug, Clone)]
 88pub struct Fact {
 89    /// Our canonical metric name, e.g. `revenue`, `eps_diluted`.
 90    pub metric: String,
 91    /// Fiscal-period label: `FY2024` for a full year, `Q3-2024` for a quarter.
 92    pub period: String,
 93    pub fiscal_year: i64,
 94    /// `None` for a full-year figure.
 95    pub fiscal_qtr: Option<i64>,
 96    /// Period end, `YYYY-MM-DD`.
 97    pub period_end: String,
 98    pub value: f64,
 99    /// XBRL unit, e.g. `USD`, `USD/shares`, `shares`.
100    pub unit: Option<String>,
101    /// The form the figure was reported on, e.g. `10-K`.
102    pub form: Option<String>,
103    /// Filing date, `YYYY-MM-DD`.
104    pub filed_at: Option<String>,
105}
106
107/// One SEC filing from a company's submission history.
108#[derive(Debug, Clone)]
109pub struct FilingRecord {
110    pub accession: String,
111    pub form: String,
112    /// Filing date, `YYYY-MM-DD`.
113    pub filed_at: String,
114    /// The period the filing reports on, `YYYY-MM-DD`.
115    pub period_of_report: Option<String>,
116    pub primary_doc: Option<String>,
117    /// Full URL to the filing's primary document (or index) on EDGAR.
118    pub url: String,
119    pub description: Option<String>,
120    /// For an 8-K, the reported item codes, comma-separated as EDGAR lists them
121    /// (e.g. `5.02,9.01`); `None` for other forms. Item 5.02 is the
122    /// officer/director change the leadership-changes feed keys on (Phase 14).
123    pub items: Option<String>,
124}
125
126/// Company fundamentals and filing history from SEC EDGAR. Implemented by
127/// `SecProvider`. Stocks only; ETFs and indexes do not file.
128#[async_trait]
129pub trait FundamentalsProvider: Send + Sync {
130    fn name(&self) -> &'static str;
131
132    /// The whole-market ticker -> CIK map, from one bulk request. Keys are
133    /// tickers normalised to bare uppercase alphanumerics (so our `BRK.B`
134    /// matches EDGAR's `BRK-B`); values are 10-digit zero-padded CIKs.
135    async fn cik_map(&self) -> Result<HashMap<String, String>>;
136
137    /// XBRL fundamental facts for one company, by its 10-digit CIK.
138    async fn facts(&self, cik: &str) -> Result<Vec<Fact>>;
139
140    /// Recent filing history for one company, by its 10-digit CIK.
141    async fn filings(&self, cik: &str) -> Result<Vec<FilingRecord>>;
142}
143
144// ── company leadership (Phase 14) ──────────────────────────────────────────
145//
146// A company's officers and board come from SEC Form 3/4/5 ownership filings:
147// every director and Section-16 officer must file these, and each carries a
148// structured `reportingOwnerRelationship`. Like the N-PORT fund methods, the
149// leadership methods are inherent to `SecProvider` (this is wholly EDGAR
150// territory), but their data types sit here beside `FilingRecord`.
151
152/// One Form 3/4/5 ownership filing in a company's submission history — the
153/// pointer the scheduler needs to fetch and parse the ownership XML itself.
154#[derive(Debug, Clone)]
155pub struct OwnershipFiling {
156    pub accession: String,
157    /// Filing date, `YYYY-MM-DD`.
158    pub filed_at: String,
159    /// The ownership XML's file name within the filing's Archives directory.
160    pub primary_doc: String,
161}
162
163/// One insider parsed from an ownership XML's `reportingOwnerRelationship`.
164#[derive(Debug, Clone)]
165pub struct OwnershipPerson {
166    /// Name as filed: last-name-first and upper-case (`COOK TIMOTHY D`).
167    pub name: String,
168    pub is_director: bool,
169    pub is_officer: bool,
170    /// The officer title, present when `is_officer` and the filer gave one.
171    pub officer_title: Option<String>,
172}
173
174// ── ETF fund profiles (Phase 18) ───────────────────────────────────────────
175//
176// ETFs file as registered funds: their portfolio comes from quarterly N-PORT
177// filings, not the XBRL companyfacts behind the stock fundamentals above. The
178// fund methods live on `SecProvider` as inherent methods (N-PORT is wholly
179// SEC-specific, with no second source to abstract over), but their data types
180// sit here next to `Fact` / `FilingRecord` for the scheduler and routes.
181
182/// Identifies one ETF to the SEC's fund endpoints.
183#[derive(Debug, Clone)]
184pub struct FundId {
185    /// 10-digit zero-padded registrant CIK.
186    pub cik: String,
187    /// SEC series id (e.g. `S000002839`), present when the registrant hosts
188    /// more than one fund — then it, not the CIK, pins a lookup to this ETF.
189    pub series_id: Option<String>,
190}
191
192/// One portfolio holding parsed from an N-PORT filing.
193#[derive(Debug, Clone)]
194pub struct FundHolding {
195    /// Issuer / security name as the fund reported it.
196    pub name: String,
197    /// Percent of the fund's net assets, e.g. `8.4`.
198    pub pct: Option<f64>,
199    /// Market value of the position, USD.
200    pub value_usd: Option<f64>,
201    /// N-PORT asset-category code (`EC` equity, `DBT` debt, ...), for the mix.
202    pub asset_cat: Option<String>,
203}
204
205/// What a fund's filing history reveals about how to read its portfolio.
206#[derive(Debug, Clone)]
207pub enum FundShape {
208    /// A fund that files N-PORT: fetch this filing for its holdings. The value
209    /// is the filing's EDGAR index-page URL, whose directory also holds the
210    /// N-PORT XML — and which carries the registrant CIK even when a filing
211    /// agent (not the fund) is the named filer on the accession number.
212    Portfolio { nport_href: String },
213    /// A physical-commodity grantor trust (GLD, SLV): no N-PORT — it holds
214    /// bullion, not a securities portfolio — so AUM comes from its 10-K.
215    CommodityTrust,
216    /// Neither pattern matched; the page can still show the filing list.
217    Unknown,
218}
219
220/// The filing list for an ETF plus what it implies about the fund's shape.
221#[derive(Debug, Clone)]
222pub struct FundFilings {
223    pub filings: Vec<FilingRecord>,
224    pub shape: FundShape,
225}
226
227/// A fund's portfolio snapshot, parsed from one N-PORT filing.
228#[derive(Debug, Clone, Default)]
229pub struct PortfolioData {
230    /// Total net assets (AUM), USD.
231    pub net_assets: Option<f64>,
232    /// Gross assets, USD.
233    pub total_assets: Option<f64>,
234    /// The date the holdings are reported as of, `YYYY-MM-DD`.
235    pub report_date: Option<String>,
236    /// Positions in the full portfolio (not just the top slice kept below).
237    pub holdings_count: i64,
238    /// The largest holdings by weight, largest first.
239    pub top_holdings: Vec<FundHolding>,
240    /// Asset-class mix as `(bucket, percent)` pairs, largest bucket first.
241    pub asset_mix: Vec<(String, f64)>,
242    /// Sector mix derived from each holding's N-PORT `industryCode` (or
243    /// `assetCat` fallback for non-equity buckets), aggregated as
244    /// `(label, percent)` pairs largest first. Phase 28; empty on a
245    /// commodity-trust fund (no N-PORT).
246    pub sector_mix: Vec<(String, f64)>,
247    /// Geography mix derived from each holding's issuer country, same shape
248    /// as `sector_mix`. Phase 28; empty on a commodity trust.
249    pub geography_mix: Vec<(String, f64)>,
250}
251
252// ── dividend events (Phase 26) ─────────────────────────────────────────────
253//
254// Per-payout dividend history comes from Yahoo's chart endpoint, which carries
255// an `events.dividends` series alongside the price bars when asked for
256// `events=div`. SEC XBRL's `DividendsPerShare` (already in `fundamentals`) is
257// per fiscal period, not per payout date, so it does not stand in. The fetch
258// lives on `YahooProvider` as an inherent method (one source); the type sits
259// here next to `Quote`/`IntradayBar` for the scheduler and routes.
260
261/// One declared dividend payment, as carried by Yahoo's chart event series.
262#[derive(Debug, Clone)]
263pub struct DividendEvent {
264    /// Ex-dividend date, `YYYY-MM-DD`. The first trading day a new buyer does
265    /// NOT receive the upcoming payment — Yahoo timestamps each event by it.
266    pub ex_date: String,
267    /// Per-share amount, in the symbol's reporting currency.
268    pub amount: f64,
269}
270
271// ── ETF fund metadata (Phase 28) ───────────────────────────────────────────
272//
273// The slow-moving figures the prospectus carries that N-PORT does not — expense
274// ratio, distribution yield, inception, category, fund family, the issuer's
275// strategy paragraph — plus the intraday NAV used for the premium / discount
276// read. Yahoo's `v10/finance/quoteSummary` endpoint serves all of them in one
277// request behind the `fundProfile + defaultKeyStatistics + summaryDetail +
278// price + assetProfile` modules. The fetch lives on `YahooProvider` as an
279// inherent method (one source); the type sits here next to `Quote` /
280// `DividendEvent` for the scheduler and routes.
281
282/// One ETF's Yahoo `quoteSummary` snapshot. Every field is optional: Yahoo's
283/// coverage is uneven and a small fund may carry only a subset, but a partial
284/// snapshot is still useful, so the parser keeps what it has rather than
285/// rejecting the row.
286#[derive(Debug, Clone, Default)]
287pub struct FundMetadata {
288    /// Annual expense ratio as a decimal, e.g. `0.0003` = 0.03%. From
289    /// `fundProfile.feesExpensesInvestment.annualReportExpenseRatio`.
290    pub expense_ratio: Option<f64>,
291    /// Forward / trailing distribution yield as a decimal. From
292    /// `summaryDetail.yield` (preferred) or `defaultKeyStatistics.yield`.
293    pub yield_pct: Option<f64>,
294    /// Trailing-twelve-month distribution yield as a decimal. From
295    /// `summaryDetail.trailingAnnualDividendYield`.
296    pub trailing_yield_pct: Option<f64>,
297    /// Latest NAV from `price.navPrice` or `summaryDetail.navPrice`. USD.
298    pub nav_price: Option<f64>,
299    /// Inception / first trade date as `YYYY-MM-DD`. From
300    /// `defaultKeyStatistics.fundInceptionDate` or `price.firstTradeDateEpochUtc`.
301    pub inception_date: Option<String>,
302    /// Morningstar-style fund category, e.g. "Large Blend". From
303    /// `fundProfile.categoryName`.
304    pub category: Option<String>,
305    /// Sponsor family, e.g. "Vanguard". From `fundProfile.family`.
306    pub fund_family: Option<String>,
307    /// The fund's strategy paragraph as the issuer writes it. From
308    /// `assetProfile.longBusinessSummary` (preferred) or
309    /// `summaryProfile.longBusinessSummary`.
310    pub strategy_summary: Option<String>,
311}
312
313/// One stock's Yahoo `assetProfile` classification (Phase 15). Both fields
314/// are optional: small-cap and foreign tickers occasionally carry only one,
315/// and a request that returned an `assetProfile` module Yahoo populated only
316/// partially leaves the other side `None` rather than rejecting the row.
317#[derive(Debug, Clone, Default)]
318pub struct AssetProfile {
319    /// GICS-style sector ("Technology"). From `assetProfile.sector`.
320    pub sector: Option<String>,
321    /// GICS-style industry ("Consumer Electronics"). From
322    /// `assetProfile.industry`.
323    pub industry: Option<String>,
324}
325
326/// An upstream rejected a request with an explicit rate-limit signal (HTTP 429
327/// or 503). A provider returns this as the source of its `anyhow::Error` so the
328/// `EndpointGuard` (see `src/guard.rs`) can recognise it by downcast and trip
329/// the circuit breaker immediately, rather than waiting for a failure streak.
330#[derive(Debug)]
331pub struct RateLimited {
332    /// The HTTP status that carried the signal.
333    pub status: u16,
334    /// `Retry-After` from the response, in seconds, when the upstream sent one
335    /// in the numeric form. The HTTP-date form is not parsed (the guard's own
336    /// exponential backoff covers it), so this is `None` then.
337    pub retry_after_secs: Option<i64>,
338}
339
340impl std::fmt::Display for RateLimited {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        write!(f, "upstream rate-limited (HTTP {})", self.status)?;
343        if let Some(s) = self.retry_after_secs {
344            write!(f, ", Retry-After {s}s")?;
345        }
346        Ok(())
347    }
348}
349
350impl std::error::Error for RateLimited {}