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//! 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}