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

8.6 KB · 221 lines · Rust Raw History
  1//! Shared `sqlx` row structs and small cross-route view models.
  2
  3use std::collections::HashMap;
  4
  5use serde::Serialize;
  6use sqlx::FromRow;
  7
  8use crate::compute;
  9
 10/// A full row of the `symbols` table.
 11#[derive(Debug, Clone, FromRow, Serialize)]
 12pub struct SymbolRow {
 13    pub ticker: String,
 14    pub name: String,
 15    pub kind: String,
 16    pub exchange: Option<String>,
 17    pub currency: String,
 18    pub cik: Option<String>,
 19    /// SEC fund series id; set for an ETF, NULL otherwise (see migration 0005).
 20    pub series_id: Option<String>,
 21    pub sector: Option<String>,
 22    pub industry: Option<String>,
 23    pub is_seeded: i64,
 24    pub is_watched: i64,
 25    pub history_synced_at: Option<i64>,
 26    pub history_first_date: Option<String>,
 27    pub history_last_date: Option<String>,
 28    pub fundamentals_synced_at: Option<i64>,
 29    pub filings_synced_at: Option<i64>,
 30    /// When this ETF's fund profile was last refreshed from SEC.
 31    pub fund_synced_at: Option<i64>,
 32    /// When this stock's leadership roster was last refreshed from SEC.
 33    pub leadership_synced_at: Option<i64>,
 34    /// When this stock or ETF's dividend / distribution history was last
 35    /// refreshed from Yahoo (Phase 26 + Phase 28). NULL for indexes / futures
 36    /// and any not-yet-swept symbol.
 37    pub dividends_synced_at: Option<i64>,
 38    /// When this ETF's Yahoo `quoteSummary` snapshot was last refreshed
 39    /// (Phase 28). NULL on every non-ETF row and on ETFs not yet swept.
 40    pub fund_metadata_synced_at: Option<i64>,
 41    /// Curated benchmark index ticker for an ETF (e.g. `^SPX`), populated
 42    /// from `universe/starter.csv` (Phase 28). The symbol page's chart
 43    /// shows the relative-performance overlay only when this is set.
 44    pub benchmark: Option<String>,
 45    /// Next-expected earnings date from Yahoo's `quoteSummary.calendarEvents`
 46    /// (Phase 25), epoch-ms. NULL when Yahoo has no upcoming date for this
 47    /// stock or the symbol has not been swept yet; the symbol page then
 48    /// falls back to a cadence estimate from past 8-K item-2.02 filings.
 49    pub next_earnings_at: Option<i64>,
 50    /// When this stock's Yahoo earnings-calendar snapshot was last
 51    /// refreshed (Phase 25). NULL = never swept. Stocks only.
 52    pub earnings_synced_at: Option<i64>,
 53    /// When this stock's Yahoo `quoteSummary.assetProfile` snapshot was
 54    /// last refreshed (Phase 15). Backs the `sector` / `industry` columns.
 55    /// NULL = never swept. Stocks only — non-stock rows stay NULL forever.
 56    pub asset_profile_synced_at: Option<i64>,
 57    pub last_price: Option<f64>,
 58    pub prev_close: Option<f64>,
 59    pub last_quote_at: Option<i64>,
 60    pub created_at: i64,
 61    pub updated_at: i64,
 62}
 63
 64/// A symbol's price row as selected for a card grid: ticker, name, kind, the
 65/// price to show, and the close it is changing against.
 66pub type SymbolCardRow = (String, String, String, Option<f64>, Option<f64>);
 67
 68/// A compact symbol tile, rendered by the `ticker_card` macro. Shared by the
 69/// Markets dashboard and the Search page.
 70#[derive(Serialize)]
 71pub struct Card {
 72    pub ticker: String,
 73    pub name: String,
 74    pub kind: String,
 75    pub price: Option<f64>,
 76    pub change_abs: Option<f64>,
 77    pub change_pct: Option<f64>,
 78    /// The rolled-up strong / fair / weak verdict badge (Phase 20). Stocks
 79    /// only, and only once SEC fundamentals have synced; `None` otherwise.
 80    pub strength: Option<compute::Standing>,
 81}
 82
 83/// Build a [`Card`] from a selected price row, computing the change off the
 84/// price and its prior close. The `strength` badge is left unset; a caller
 85/// with fundamentals on hand fills it in.
 86pub fn to_card((ticker, name, kind, last, prev): SymbolCardRow) -> Card {
 87    let (change_abs, change_pct) = match (last, prev) {
 88        (Some(l), Some(p)) => {
 89            let c = compute::change(l, p);
 90            (Some(c.abs), Some(c.pct))
 91        }
 92        _ => (None, None),
 93    };
 94    Card {
 95        ticker,
 96        name,
 97        kind,
 98        price: last,
 99        change_abs,
100        change_pct,
101        strength: None,
102    }
103}
104
105/// One fundamentals fact as stored: a metric's value for one fiscal period.
106#[derive(Debug, Clone, FromRow)]
107pub struct FundFact {
108    pub metric: String,
109    pub period: String,
110    pub fiscal_year: i64,
111    pub fiscal_qtr: Option<i64>,
112    pub value: f64,
113    /// `YYYY-MM-DD` end-of-period date. Dates the fundamentals-anomaly feed
114    /// (the FY in which a revenue / net-income move landed).
115    pub period_end: String,
116}
117
118/// Assemble [`compute::RatioInputs`] for a company's most recent full fiscal
119/// year from its stored facts plus a price. Annual rows only; the prior year's
120/// figures (for the growth ratios) come from `latest_fy - 1`. `None` when the
121/// company has no annual facts. Shared by the symbol page and the home quality
122/// leaderboard so both grade a stock identically.
123pub fn latest_annual_inputs(facts: &[FundFact], price: Option<f64>) -> Option<compute::RatioInputs> {
124    latest_annual_inputs_filtered(facts, price, |_| true)
125}
126
127/// YoY change threshold (25%) on annual revenue or net-income above which a
128/// fundamentals anomaly event is emitted.
129const FUND_YOY_THRESHOLD: f64 = 0.25;
130
131/// Walk a company's stored facts and emit one anomaly event per (metric,
132/// fiscal year) whose YoY change exceeds ±25%. Only annual `revenue` and
133/// `net_income` are surfaced — the two top-line figures whose moves are
134/// readable without further context. The event's date is the fiscal year's
135/// `period_end` (the year that ended), so the feed reads as "FY2024
136/// revenue ‒32% YoY" on the day that fiscal year closed.
137pub fn fundamentals_anomalies(facts: &[FundFact]) -> Vec<compute::AnomalyEvent> {
138    let mut annual: HashMap<(&str, i64), (f64, &str)> = HashMap::new();
139    for f in facts {
140        if f.fiscal_qtr.is_none() && (f.metric == "revenue" || f.metric == "net_income") {
141            annual.insert(
142                (f.metric.as_str(), f.fiscal_year),
143                (f.value, f.period_end.as_str()),
144            );
145        }
146    }
147    let mut out: Vec<compute::AnomalyEvent> = Vec::new();
148    for ((metric, year), (val, period_end)) in &annual {
149        let prev = match annual.get(&(metric, year - 1)) {
150            Some((v, _)) => *v,
151            None => continue,
152        };
153        if prev.abs() < 1e-9 {
154            continue;
155        }
156        let change = (val - prev) / prev.abs();
157        if change.abs() < FUND_YOY_THRESHOLD {
158            continue;
159        }
160        let pct = change * 100.0;
161        let label = match *metric {
162            "revenue" => "revenue",
163            "net_income" => "net income",
164            _ => metric,
165        };
166        let (glyph, polarity, sign) = if pct >= 0.0 {
167            ("fund-up", "good", "+")
168        } else {
169            ("fund-down", "bad", "\u{2212}")
170        };
171        out.push(compute::AnomalyEvent {
172            date: period_end.to_string(),
173            glyph,
174            polarity,
175            headline: format!("FY{year} {label} {sign}{:.0}% YoY", pct.abs()),
176            url: None,
177            severity: pct.abs(),
178        });
179    }
180    out
181}
182
183fn latest_annual_inputs_filtered(
184    facts: &[FundFact],
185    price: Option<f64>,
186    keep: impl Fn(&FundFact) -> bool,
187) -> Option<compute::RatioInputs> {
188    // (metric, fiscal_year) -> value, annual rows only.
189    let mut annual: HashMap<(&str, i64), f64> = HashMap::new();
190    let mut latest_fy: Option<i64> = None;
191    for f in facts {
192        if f.fiscal_qtr.is_none() && keep(f) {
193            annual.insert((f.metric.as_str(), f.fiscal_year), f.value);
194            // Only an income-statement metric may advance the "latest fiscal
195            // year" the ratios key off. A stray balance-sheet or dividend figure
196            // tagged a year ahead (common right around a filing) would otherwise
197            // make `latest_fy` a year the core figures aren't in yet, blanking
198            // every ratio instead of reading the most recent complete year.
199            if matches!(f.metric.as_str(), "revenue" | "net_income" | "eps_diluted") {
200                latest_fy = Some(latest_fy.map_or(f.fiscal_year, |y| y.max(f.fiscal_year)));
201            }
202        }
203    }
204    let fy = latest_fy?;
205    let av = |m: &str, y: i64| annual.get(&(m, y)).copied();
206    Some(compute::RatioInputs {
207        price,
208        eps_diluted: av("eps_diluted", fy),
209        dividends_per_share: av("dividends_per_share", fy),
210        revenue: av("revenue", fy),
211        net_income: av("net_income", fy),
212        assets: av("assets", fy),
213        liabilities: av("liabilities", fy),
214        equity: av("equity", fy),
215        assets_current: av("assets_current", fy),
216        liabilities_current: av("liabilities_current", fy),
217        prev_revenue: av("revenue", fy - 1),
218        prev_net_income: av("net_income", fy - 1),
219    })
220}