Single-binary self-hosted market watcher for stocks, ETFs, indexes, and futures: live charts, key stats, fundamentals, SEC filings, and SSE streaming.
axumdockerfinancerustself-hostedsqlitestocksvite
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 {}