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//! Derived market figures. Ratios live here, not in the database, so they
2//! always reflect the latest price.
3
4use serde::Serialize;
5
6/// Placeholder shown for a figure that cannot be computed — an em dash, the
7/// unambiguous "no data" mark used for every empty value across the app.
8const DASH: &str = "\u{2014}";
9
10/// An absolute and percentage change between two prices.
11#[derive(Debug, Clone, Copy)]
12pub struct Change {
13 pub abs: f64,
14 pub pct: f64,
15}
16
17/// Change of `last` relative to `prev` (a prior close).
18pub fn change(last: f64, prev: f64) -> Change {
19 let abs = last - prev;
20 let pct = if prev != 0.0 { abs / prev * 100.0 } else { 0.0 };
21 Change { abs, pct }
22}
23
24/// A VIX level read into one plain word, for the dashboard's volatility tone.
25/// The bands suit the ^VIX cash gauge: sub-14 is a placid tape, the teens are
26/// normal, the low-20s start to show stress, and 28+ is outright fear.
27pub fn vix_tone(level: f64) -> &'static str {
28 match level {
29 v if v < 14.0 => "calm",
30 v if v < 20.0 => "steady",
31 v if v < 28.0 => "elevated",
32 _ => "stressed",
33 }
34}
35
36/// The S&P's drawdown from its record close, read into a tone + zone label for
37/// the dashboard's crash-response gauge. `dd` is the percent below the high
38/// (`<= 0`). The zones use the usual market vocabulary — a slight dip, a
39/// pullback, a correction (`-10%`), a bear market (`-20%`). For a DCA investor
40/// the deeper zones are the *add* zone, and the labels say so, because the
41/// research is clear that timing damage is done on the sell side, not the buy.
42pub fn drawdown_read(dd: f64) -> (&'static str, &'static str) {
43 match dd {
44 d if d >= -2.0 => ("up", "At highs"),
45 d if d >= -5.0 => ("steady", "Slight dip"),
46 d if d >= -10.0 => ("warn", "Pullback"),
47 d if d >= -20.0 => ("down", "Correction · add zone"),
48 _ => ("down", "Bear market · add zone"),
49 }
50}
51
52/// A credit-stress read from the high-yield ETF's day move (HYG). Falling
53/// high-yield = risk-off / widening spreads; rising = risk appetite. A confirming
54/// gauge beside the VIX and the drawdown — the bond market's stress tell.
55pub fn credit_read(pct: f64) -> (&'static str, &'static str) {
56 match pct {
57 p if p <= -0.6 => ("down", "Stressing"),
58 p if p >= 0.3 => ("up", "Easing"),
59 _ => ("steady", "Steady"),
60 }
61}
62
63/// Position of `value` along the `[lo, hi]` range, as a 0..100 percent for
64/// placing a marker on a track. Clamped to the ends; a zero-width range maps
65/// to the midpoint. Rounded to 2 dp so it inlines cleanly into a `style`.
66pub fn pos(value: f64, lo: f64, hi: f64) -> f64 {
67 if hi <= lo {
68 return 50.0;
69 }
70 let p = (value - lo) / (hi - lo) * 100.0;
71 (p.clamp(0.0, 100.0) * 100.0).round() / 100.0
72}
73
74// ─────────────────────────── computed ratios ───────────────────────────────
75//
76// Phase 7. Each ratio is computed from the latest full fiscal year's SEC
77// figures plus the latest price, graded good / ok / bad against sensible
78// thresholds, and paired with plain-English text so a non-expert can read it.
79// Nothing here is stored: a fresh price re-grades the price-based ratios.
80
81/// A computed fundamental ratio's quality, for the symbol page's semantic
82/// green / amber / red.
83#[derive(Debug, Clone, Copy, Serialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Grade {
86 Good,
87 Ok,
88 Bad,
89 /// Inputs missing or the ratio is not meaningful (e.g. negative equity):
90 /// shown neutrally, not coloured.
91 Unknown,
92}
93
94impl Grade {
95 /// One-word verdict for the ratio card's badge.
96 pub fn verdict(self) -> &'static str {
97 match self {
98 Grade::Good => "Strong",
99 Grade::Ok => "Fair",
100 Grade::Bad => "Weak",
101 Grade::Unknown => "No data",
102 }
103 }
104}
105
106/// One computed ratio, ready for the symbol page: a graded value plus
107/// plain-English text so a non-expert can tell good from concerning.
108#[derive(Debug, Clone, Serialize)]
109pub struct Ratio {
110 /// Stable identifier, also a CSS hook.
111 pub key: &'static str,
112 pub label: &'static str,
113 /// Formatted value, e.g. `28.4x`, `1.6%`; a middle dot when unknown.
114 pub display: String,
115 pub grade: Grade,
116 /// One-word badge text derived from `grade`.
117 pub verdict: &'static str,
118 /// Plain-English reading of this company's particular value.
119 pub reading: String,
120 /// What the metric means and how to read it, the same for every company.
121 pub explain: &'static str,
122}
123
124/// The figures a full set of ratios is computed from: the latest full fiscal
125/// year's values, the prior year's (for the growth ratios), and a price. Every
126/// field is optional, since a company may simply not report a given concept.
127#[derive(Debug, Default, Clone)]
128pub struct RatioInputs {
129 pub price: Option<f64>,
130 pub eps_diluted: Option<f64>,
131 pub dividends_per_share: Option<f64>,
132 pub revenue: Option<f64>,
133 pub net_income: Option<f64>,
134 pub assets: Option<f64>,
135 pub liabilities: Option<f64>,
136 pub equity: Option<f64>,
137 pub assets_current: Option<f64>,
138 pub liabilities_current: Option<f64>,
139 pub prev_revenue: Option<f64>,
140 pub prev_net_income: Option<f64>,
141}
142
143/// Assemble a `Ratio`, deriving the badge verdict from the grade.
144fn mk(
145 key: &'static str,
146 label: &'static str,
147 explain: &'static str,
148 display: String,
149 grade: Grade,
150 reading: String,
151) -> Ratio {
152 Ratio {
153 key,
154 label,
155 display,
156 grade,
157 verdict: grade.verdict(),
158 reading,
159 explain,
160 }
161}
162
163/// An `Unknown` ratio: inputs missing or the ratio not meaningful.
164fn unknown(
165 key: &'static str,
166 label: &'static str,
167 explain: &'static str,
168 reading: &str,
169) -> Ratio {
170 mk(key, label, explain, DASH.to_string(), Grade::Unknown, reading.to_string())
171}
172
173/// The nine ratios shown on a stock's symbol page, in display order.
174pub fn compute_ratios(i: &RatioInputs) -> Vec<Ratio> {
175 vec![
176 pe(i.price, i.eps_diluted),
177 dividend_yield(i.price, i.dividends_per_share),
178 profit_margin(i.net_income, i.revenue),
179 return_on_equity(i.net_income, i.equity),
180 return_on_assets(i.net_income, i.assets),
181 debt_to_equity(i.liabilities, i.equity),
182 current_ratio(i.assets_current, i.liabilities_current),
183 revenue_growth(i.revenue, i.prev_revenue),
184 earnings_growth(i.net_income, i.prev_net_income),
185 ]
186}
187
188fn pe(price: Option<f64>, eps: Option<f64>) -> Ratio {
189 const KEY: &str = "pe";
190 const LABEL: &str = "P/E ratio";
191 const EXPLAIN: &str = "Share price divided by earnings per share: what you \
192 pay for each $1 of yearly profit. Roughly 15 to 25 is typical, above 40 \
193 is richly priced, and negative means the company is losing money.";
194 let (Some(price), Some(eps)) = (price, eps) else {
195 return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute a price-to-earnings ratio.");
196 };
197 if eps <= 0.0 {
198 return unknown(
199 KEY, LABEL, EXPLAIN,
200 "Earnings per share were negative, so a P/E cannot be formed; the company was unprofitable over the period.",
201 );
202 }
203 let v = price / eps;
204 // Below 10x the stock is cheap (a bargain, or a warning); 10-25x is the
205 // healthy band; 25-40x is paying up for growth; above 40x is steep.
206 let (grade, reading) = if v < 10.0 {
207 (Grade::Ok, format!("At {v:.1}x, the stock is priced cheaply against its profits: sometimes a bargain, sometimes a sign of trouble ahead."))
208 } else if v <= 25.0 {
209 (Grade::Good, format!("At {v:.1}x, the price is a reasonable multiple of the company's annual profit."))
210 } else if v < 40.0 {
211 (Grade::Ok, format!("At {v:.1}x, investors are paying up; a fair amount of future growth is already in the price."))
212 } else {
213 (Grade::Bad, format!("At {v:.1}x, the price is steep relative to profit; the stock leans heavily on growth that has yet to arrive."))
214 };
215 mk(KEY, LABEL, EXPLAIN, format!("{v:.1}x"), grade, reading)
216}
217
218fn dividend_yield(price: Option<f64>, dps: Option<f64>) -> Ratio {
219 const KEY: &str = "div_yield";
220 const LABEL: &str = "Dividend yield";
221 const EXPLAIN: &str = "The yearly dividend as a percent of the share price: \
222 the cash income each share pays out. Around 2 to 6% is healthy; above \
223 roughly 8% often signals the payout may be cut.";
224 let Some(price) = price.filter(|p| *p > 0.0) else {
225 return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute a dividend yield.");
226 };
227 // A company that pays no dividend simply never reports the concept; treat
228 // a missing figure as a genuine zero.
229 let dps = dps.unwrap_or(0.0);
230 let v = dps / price * 100.0;
231 let (grade, reading) = if v <= 0.0 {
232 (Grade::Ok, "This company pays no dividend, common for firms reinvesting their profits back into growth.".to_string())
233 } else if v < 2.0 {
234 (Grade::Ok, format!("A {v:.1}% yield is modest: a small income on top of whatever the share price does."))
235 } else if v <= 6.0 {
236 (Grade::Good, format!("A {v:.1}% yield is a healthy, generally sustainable level of cash income."))
237 } else if v <= 10.0 {
238 (Grade::Ok, format!("A {v:.1}% yield is high; it is worth checking the payout is covered by profit."))
239 } else {
240 (Grade::Bad, format!("A {v:.1}% yield is unusually high, often a sign the market expects the dividend to be cut."))
241 };
242 mk(KEY, LABEL, EXPLAIN, format!("{v:.2}%"), grade, reading)
243}
244
245fn profit_margin(net_income: Option<f64>, revenue: Option<f64>) -> Ratio {
246 const KEY: &str = "profit_margin";
247 const LABEL: &str = "Profit margin";
248 const EXPLAIN: &str = "The share of revenue left as profit once every cost \
249 is paid. Above 15% is strong; below 5% leaves little cushion against a \
250 bad year.";
251 let (Some(ni), Some(rev)) = (net_income, revenue) else {
252 return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute a profit margin.");
253 };
254 if rev <= 0.0 {
255 return unknown(KEY, LABEL, EXPLAIN, "No revenue was reported, so a margin cannot be computed.");
256 }
257 let v = ni / rev * 100.0;
258 let (grade, reading) = if v < 5.0 {
259 (Grade::Bad, format!("A {v:.1}% margin is thin; little of each revenue dollar survives as profit."))
260 } else if v <= 15.0 {
261 (Grade::Ok, format!("A {v:.1}% margin is solid, in the ordinary range for a profitable company."))
262 } else {
263 (Grade::Good, format!("A {v:.1}% margin is strong; the company keeps a healthy slice of every revenue dollar."))
264 };
265 mk(KEY, LABEL, EXPLAIN, format!("{v:.1}%"), grade, reading)
266}
267
268fn return_on_equity(net_income: Option<f64>, equity: Option<f64>) -> Ratio {
269 const KEY: &str = "roe";
270 const LABEL: &str = "Return on equity";
271 const EXPLAIN: &str = "Profit earned on each dollar of shareholder equity: \
272 how well the company compounds its owners' capital. Above 15% is strong.";
273 let (Some(ni), Some(eq)) = (net_income, equity) else {
274 return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute return on equity.");
275 };
276 if eq <= 0.0 {
277 return unknown(KEY, LABEL, EXPLAIN, "Shareholder equity is negative, so return on equity is not meaningful.");
278 }
279 let v = ni / eq * 100.0;
280 let (grade, reading) = if v < 5.0 {
281 (Grade::Bad, format!("A {v:.1}% return on equity is weak; owners' capital is barely being put to work."))
282 } else if v <= 15.0 {
283 (Grade::Ok, format!("A {v:.1}% return on equity is respectable, in the normal range."))
284 } else {
285 (Grade::Good, format!("A {v:.1}% return on equity is strong; the company compounds owners' capital well."))
286 };
287 mk(KEY, LABEL, EXPLAIN, format!("{v:.1}%"), grade, reading)
288}
289
290fn return_on_assets(net_income: Option<f64>, assets: Option<f64>) -> Ratio {
291 const KEY: &str = "roa";
292 const LABEL: &str = "Return on assets";
293 const EXPLAIN: &str = "Profit earned on each dollar of assets: how \
294 efficiently the whole asset base is used. Above 8% is strong.";
295 let (Some(ni), Some(assets)) = (net_income, assets) else {
296 return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute return on assets.");
297 };
298 if assets <= 0.0 {
299 return unknown(KEY, LABEL, EXPLAIN, "No asset total was reported, so return on assets cannot be computed.");
300 }
301 let v = ni / assets * 100.0;
302 let (grade, reading) = if v < 2.0 {
303 (Grade::Bad, format!("A {v:.1}% return on assets is low; the asset base is generating little profit."))
304 } else if v <= 8.0 {
305 (Grade::Ok, format!("A {v:.1}% return on assets is reasonable for a company of this kind."))
306 } else {
307 (Grade::Good, format!("A {v:.1}% return on assets is strong; the company squeezes good profit from its assets."))
308 };
309 mk(KEY, LABEL, EXPLAIN, format!("{v:.1}%"), grade, reading)
310}
311
312fn debt_to_equity(liabilities: Option<f64>, equity: Option<f64>) -> Ratio {
313 const KEY: &str = "debt_equity";
314 const LABEL: &str = "Debt-to-equity";
315 const EXPLAIN: &str = "Total liabilities divided by shareholder equity: how \
316 heavily the company leans on borrowing. Below 1 is conservative; above \
317 2 is highly leveraged.";
318 let (Some(liab), Some(eq)) = (liabilities, equity) else {
319 return unknown(KEY, LABEL, EXPLAIN, "Not enough data to compute debt-to-equity.");
320 };
321 if eq <= 0.0 {
322 return mk(
323 KEY, LABEL, EXPLAIN, DASH.to_string(), Grade::Bad,
324 "Shareholder equity is negative; liabilities exceed everything the company owns.".to_string(),
325 );
326 }
327 let v = liab / eq;
328 let (grade, reading) = if v < 1.0 {
329 (Grade::Good, format!("At {v:.2}, the company carries less in liabilities than in equity, a conservative balance sheet."))
330 } else if v <= 2.0 {
331 (Grade::Ok, format!("At {v:.2}, the company carries a moderate, manageable amount of debt."))
332 } else {
333 (Grade::Bad, format!("At {v:.2}, the company leans heavily on borrowing, which adds risk if results weaken."))
334 };
335 mk(KEY, LABEL, EXPLAIN, format!("{v:.2}"), grade, reading)
336}
337
338fn current_ratio(assets_current: Option<f64>, liabilities_current: Option<f64>) -> Ratio {
339 const KEY: &str = "current_ratio";
340 const LABEL: &str = "Current ratio";
341 const EXPLAIN: &str = "Current assets divided by current liabilities: \
342 whether short-term resources cover short-term bills. Above 1.5 is \
343 comfortable; below 1 is tight.";
344 let (Some(ca), Some(cl)) = (assets_current, liabilities_current) else {
345 return unknown(KEY, LABEL, EXPLAIN, "This company does not report a current-assets breakdown, so the ratio cannot be computed.");
346 };
347 if cl <= 0.0 {
348 return unknown(KEY, LABEL, EXPLAIN, "No current liabilities were reported, so the ratio cannot be computed.");
349 }
350 let v = ca / cl;
351 let (grade, reading) = if v < 1.0 {
352 (Grade::Bad, format!("At {v:.2}, short-term assets fall short of short-term bills; liquidity is tight."))
353 } else if v < 1.5 {
354 (Grade::Ok, format!("At {v:.2}, short-term assets cover short-term bills with a little room to spare."))
355 } else {
356 (Grade::Good, format!("At {v:.2}, short-term assets comfortably cover short-term bills."))
357 };
358 mk(KEY, LABEL, EXPLAIN, format!("{v:.2}"), grade, reading)
359}
360
361fn revenue_growth(revenue: Option<f64>, prev_revenue: Option<f64>) -> Ratio {
362 const KEY: &str = "revenue_growth";
363 const LABEL: &str = "Revenue growth";
364 const EXPLAIN: &str = "Change in annual revenue from the prior fiscal year: \
365 whether the top line is expanding. Above 10% is strong growth; below 0 \
366 means revenue is shrinking.";
367 let (Some(rev), Some(prev)) = (revenue, prev_revenue) else {
368 return unknown(KEY, LABEL, EXPLAIN, "Two fiscal years of revenue are needed to compute growth.");
369 };
370 if prev <= 0.0 {
371 return unknown(KEY, LABEL, EXPLAIN, "Prior-year revenue was not positive, so a growth rate is not meaningful.");
372 }
373 let v = (rev - prev) / prev * 100.0;
374 let (grade, reading) = if v < 0.0 {
375 (Grade::Bad, format!("Revenue fell {:.1}% from the prior year; the top line is contracting.", v.abs()))
376 } else if v <= 10.0 {
377 (Grade::Ok, format!("Revenue grew {v:.1}% from the prior year: steady, modest expansion."))
378 } else {
379 (Grade::Good, format!("Revenue grew {v:.1}% from the prior year: strong top-line expansion."))
380 };
381 mk(KEY, LABEL, EXPLAIN, format!("{v:+.1}%"), grade, reading)
382}
383
384// ─────────────────────────── chart indicators ──────────────────────────────
385//
386// Phase 8. Overlay/indicator series for the price chart: simple and
387// exponential moving averages plus a Relative Strength Index. Each takes a
388// slice of closing prices (oldest first) and returns one `Option<f64>` per
389// input bar — `None` until enough history has accumulated for the figure to
390// be meaningful — so a caller can align the result to its bar list by index
391// and drop the leading `None`s. The maths lives here, not in SQL or the
392// browser, so it stays in one place the rest of the app already trusts.
393
394/// Simple moving average over `period` bars: `out[i]` is the mean of
395/// `closes[i+1-period ..= i]`, and `None` for the first `period-1` bars.
396/// A running sum keeps it one pass regardless of `period`.
397pub fn sma(closes: &[f64], period: usize) -> Vec<Option<f64>> {
398 if period == 0 {
399 return vec![None; closes.len()];
400 }
401 let mut out = Vec::with_capacity(closes.len());
402 let mut sum = 0.0;
403 for i in 0..closes.len() {
404 sum += closes[i];
405 if i >= period {
406 sum -= closes[i - period];
407 }
408 out.push((i + 1 >= period).then(|| sum / period as f64));
409 }
410 out
411}
412
413/// Exponential moving average over `period` bars. Seeded at index `period-1`
414/// with the simple average of the first window, then each step weights the
415/// newest close by `2/(period+1)`. `None` before the seed bar.
416pub fn ema(closes: &[f64], period: usize) -> Vec<Option<f64>> {
417 let mut out = vec![None; closes.len()];
418 if period == 0 || closes.len() < period {
419 return out;
420 }
421 let k = 2.0 / (period as f64 + 1.0);
422 let mut prev = closes[..period].iter().sum::<f64>() / period as f64;
423 out[period - 1] = Some(prev);
424 for i in period..closes.len() {
425 prev = closes[i] * k + prev * (1.0 - k);
426 out[i] = Some(prev);
427 }
428 out
429}
430
431/// Wilder's Relative Strength Index over `period` bars (classically 14): a
432/// 0..100 momentum reading, `None` until `period` price changes have
433/// accumulated. Above ~70 is conventionally "overbought", below ~30
434/// "oversold". The seed averages the first `period` gains and losses; every
435/// later bar applies Wilder's smoothing.
436pub fn rsi(closes: &[f64], period: usize) -> Vec<Option<f64>> {
437 let mut out = vec![None; closes.len()];
438 if period == 0 || closes.len() <= period {
439 return out;
440 }
441 let (mut gain, mut loss) = (0.0, 0.0);
442 for i in 1..=period {
443 let ch = closes[i] - closes[i - 1];
444 if ch >= 0.0 {
445 gain += ch;
446 } else {
447 loss -= ch;
448 }
449 }
450 let mut avg_gain = gain / period as f64;
451 let mut avg_loss = loss / period as f64;
452 out[period] = Some(rsi_from(avg_gain, avg_loss));
453 for i in period + 1..closes.len() {
454 let ch = closes[i] - closes[i - 1];
455 let (g, l) = if ch >= 0.0 { (ch, 0.0) } else { (0.0, -ch) };
456 avg_gain = (avg_gain * (period as f64 - 1.0) + g) / period as f64;
457 avg_loss = (avg_loss * (period as f64 - 1.0) + l) / period as f64;
458 out[i] = Some(rsi_from(avg_gain, avg_loss));
459 }
460 out
461}
462
463/// One RSI reading from a smoothed average gain and loss; an all-gains
464/// window (no losses) reads a flat 100.
465fn rsi_from(avg_gain: f64, avg_loss: f64) -> f64 {
466 if avg_loss == 0.0 {
467 return 100.0;
468 }
469 let rs = avg_gain / avg_loss;
470 100.0 - 100.0 / (1.0 + rs)
471}
472
473/// One Supertrend bar: the band value to plot and which side of price it sits
474/// on. `up` is an uptrend — the band is trailing *below* price as support;
475/// `!up` is a downtrend, the band riding *above* price as resistance. The line
476/// flips sides when price closes through it, which is the whole signal.
477#[derive(Debug, Clone, Copy)]
478pub struct SuperTrend {
479 pub value: f64,
480 pub up: bool,
481}
482
483/// The standard Supertrend defaults: a 10-bar ATR scaled by 3. Used by both the
484/// chart overlay and the symbol page's indicator read so the two always agree.
485pub const SUPERTREND_PERIOD: usize = 10;
486pub const SUPERTREND_MULT: f64 = 3.0;
487
488/// Supertrend over `period` bars at `mult`× ATR (classically 10 / 3.0). An
489/// ATR-banded trend follower: a single line that trails below price in an
490/// uptrend and above it in a downtrend, flipping when a close breaks through.
491/// Takes parallel high / low / close slices (oldest first) and returns one
492/// `Option<SuperTrend>` per bar — `None` until the ATR has warmed up — so the
493/// caller aligns it to the bar list by index like the other indicators.
494///
495/// ATR uses Wilder smoothing (matching `rsi`), and the bands carry forward with
496/// the standard rule: each final band only tightens toward price unless the
497/// prior close pierced it, which keeps the line from whipping on a quiet bar.
498pub fn supertrend(highs: &[f64], lows: &[f64], closes: &[f64], period: usize, mult: f64) -> Vec<Option<SuperTrend>> {
499 let n = closes.len();
500 let mut out = vec![None; n];
501 // Need `period` true ranges (each from a bar and its predecessor) to seed
502 // the ATR, so the first reading lands at index `period`.
503 if period == 0 || n <= period {
504 return out;
505 }
506 // True range at bar `i`: the greatest of today's span and the two gaps to
507 // yesterday's close. `i == 0` has no predecessor, so it is the bar span.
508 let tr = |i: usize| -> f64 {
509 let hl = highs[i] - lows[i];
510 if i == 0 {
511 return hl;
512 }
513 hl.max((highs[i] - closes[i - 1]).abs())
514 .max((lows[i] - closes[i - 1]).abs())
515 };
516 let hl2 = |i: usize| (highs[i] + lows[i]) / 2.0;
517
518 // Wilder-seeded ATR: the mean of the first `period` true ranges, then
519 // smoothed one bar at a time.
520 let mut atr = (1..=period).map(tr).sum::<f64>() / period as f64;
521 let mut final_upper = hl2(period) + mult * atr;
522 let mut final_lower = hl2(period) - mult * atr;
523 // Seed the trend from where the close sits in its first band: above the
524 // upper band reads as an uptrend, otherwise a downtrend.
525 let mut up = closes[period] > final_upper;
526 let mut st = if up { final_lower } else { final_upper };
527 out[period] = Some(SuperTrend { value: st, up });
528
529 for i in period + 1..n {
530 atr = (atr * (period as f64 - 1.0) + tr(i)) / period as f64;
531 let basic_upper = hl2(i) + mult * atr;
532 let basic_lower = hl2(i) - mult * atr;
533 // A final band only moves toward price unless the *prior* close pierced
534 // it, in which case it resets to the fresh basic band.
535 final_upper = if basic_upper < final_upper || closes[i - 1] > final_upper {
536 basic_upper
537 } else {
538 final_upper
539 };
540 final_lower = if basic_lower > final_lower || closes[i - 1] < final_lower {
541 basic_lower
542 } else {
543 final_lower
544 };
545 // Stay in the current trend until a close crosses the trailing band;
546 // then flip to the opposite band.
547 up = if up {
548 closes[i] >= final_lower
549 } else {
550 closes[i] > final_upper
551 };
552 st = if up { final_lower } else { final_upper };
553 out[i] = Some(SuperTrend { value: st, up });
554 }
555 out
556}
557
558fn earnings_growth(net_income: Option<f64>, prev_net_income: Option<f64>) -> Ratio {
559 const KEY: &str = "earnings_growth";
560 const LABEL: &str = "Earnings growth";
561 const EXPLAIN: &str = "Change in annual net income from the prior fiscal \
562 year: whether profit is expanding. Above 10% is strong; below 0 means \
563 profit is falling.";
564 let (Some(ni), Some(prev)) = (net_income, prev_net_income) else {
565 return unknown(KEY, LABEL, EXPLAIN, "Two fiscal years of net income are needed to compute growth.");
566 };
567 if prev <= 0.0 {
568 // A growth percentage off a loss-making base is meaningless; describe
569 // the turn instead of computing a rate.
570 let reading = if ni > 0.0 {
571 "The company returned to profit after a loss-making prior year."
572 } else {
573 "The company was unprofitable in both years, so an earnings growth rate is not meaningful."
574 };
575 return unknown(KEY, LABEL, EXPLAIN, reading);
576 }
577 let v = (ni - prev) / prev * 100.0;
578 let (grade, reading) = if ni < 0.0 {
579 (Grade::Bad, "Profit swung to a loss from a profitable prior year.".to_string())
580 } else if v < 0.0 {
581 (Grade::Bad, format!("Net income fell {:.1}% from the prior year; profit is shrinking.", v.abs()))
582 } else if v <= 10.0 {
583 (Grade::Ok, format!("Net income grew {v:.1}% from the prior year: steady profit expansion."))
584 } else {
585 (Grade::Good, format!("Net income grew {v:.1}% from the prior year: strong profit expansion."))
586 };
587 mk(KEY, LABEL, EXPLAIN, format!("{v:+.1}%"), grade, reading)
588}
589
590// ──────────────────────── dividend pace (Phase 26) ─────────────────────────
591//
592// Inferred cadence + an on-track read for a stock's dividend payouts. Inputs
593// are sorted (ex_date, amount) pairs and a reference "today" date. Pure code,
594// kept here next to the other graded reads; the route formats display.
595
596/// How frequently a stock pays out — inferred from the median gap between its
597/// recent ex-dividend dates. Drives both the page's "Pays …" caption and the
598/// count-tempered projection of the current year's total.
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
600#[serde(rename_all = "lowercase")]
601pub enum Cadence {
602 /// One payment per year.
603 Annual,
604 /// Two per year (~180-day gap), e.g. some European dual-listings.
605 SemiAnnual,
606 /// Four per year (~90-day gap), the US norm.
607 Quarterly,
608 /// Twelve per year, e.g. monthly-paying real-estate trusts.
609 Monthly,
610 /// Cadence does not fit a clean pattern (special one-off, etc.).
611 Irregular,
612 /// No payouts to read from.
613 None,
614}
615
616impl Cadence {
617 /// A short caption for the page header, e.g. `Pays quarterly`.
618 pub fn caption(self) -> &'static str {
619 match self {
620 Cadence::Annual => "Pays annually",
621 Cadence::SemiAnnual => "Pays twice a year",
622 Cadence::Quarterly => "Pays quarterly",
623 Cadence::Monthly => "Pays monthly",
624 Cadence::Irregular => "Irregular cadence",
625 Cadence::None => "No dividends recorded",
626 }
627 }
628
629 /// Expected number of payouts per calendar year, for the projection.
630 /// `None` for `Irregular` / `None`, where a clean projection is misleading.
631 fn expected_per_year(self) -> Option<u32> {
632 match self {
633 Cadence::Annual => Some(1),
634 Cadence::SemiAnnual => Some(2),
635 Cadence::Quarterly => Some(4),
636 Cadence::Monthly => Some(12),
637 Cadence::Irregular | Cadence::None => None,
638 }
639 }
640}
641
642/// Infer cadence from the most recent payouts' ex-date gaps. Takes a sorted
643/// (oldest first) slice of dates as `YYYY-MM-DD` strings; reads the median
644/// gap across the last few payments so a single irregular one-off does not
645/// throw the classification. Returns `Irregular` when the median lands outside
646/// every clean band, and `None` when there is too little to infer from.
647pub fn infer_cadence(ex_dates_oldest_first: &[String]) -> Cadence {
648 if ex_dates_oldest_first.is_empty() {
649 return Cadence::None;
650 }
651 if ex_dates_oldest_first.len() == 1 {
652 // One payout: not enough to infer a cadence, but better to flag it as
653 // irregular than claim a clean annual.
654 return Cadence::Irregular;
655 }
656 // The most recent up-to-8 payouts give a stable median while still
657 // reflecting any recent change in cadence.
658 let tail = &ex_dates_oldest_first[ex_dates_oldest_first.len().saturating_sub(8)..];
659 let parsed: Vec<chrono::NaiveDate> = tail
660 .iter()
661 .filter_map(|d| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
662 .collect();
663 if parsed.len() < 2 {
664 return Cadence::Irregular;
665 }
666 let mut gaps: Vec<i64> = parsed
667 .windows(2)
668 .map(|w| (w[1] - w[0]).num_days())
669 .collect();
670 gaps.sort();
671 // Median rather than mean so a single irregular gap does not skew it.
672 let median = gaps[gaps.len() / 2];
673 match median {
674 // Each band leaves comfortable slack: a quarterly payer's gaps range
675 // ~80-95d in practice depending on the calendar.
676 d if d <= 45 => Cadence::Monthly,
677 d if d <= 130 => Cadence::Quarterly,
678 d if d <= 220 => Cadence::SemiAnnual,
679 d if d <= 450 => Cadence::Annual,
680 _ => Cadence::Irregular,
681 }
682}
683
684/// The on-track read for a stock's dividends: prior-year and YTD totals, the
685/// projected current-year total, and a graded verdict on whether the company
686/// is tracking ahead of, on, or behind its prior-year payout.
687#[derive(Debug, Clone, Serialize)]
688pub struct DividendPace {
689 pub cadence: Cadence,
690 /// Short caption derived from `cadence`, e.g. `Pays quarterly`. Carried
691 /// so the template renders it without poking at the method.
692 pub cadence_caption: &'static str,
693 /// Sum of payouts in the previous calendar year, per share.
694 pub prior_year_total: f64,
695 /// Sum of payouts in the current calendar year so far, per share.
696 pub ytd_total: f64,
697 /// Number of payouts declared so far this calendar year.
698 pub ytd_count: u32,
699 /// Projected current-year total per share, scaling YTD by the count-
700 /// tempered factor (`expected_n / declared_n_so_far`). `None` when the
701 /// cadence is unclear, no payouts have landed this year, or there is no
702 /// prior-year baseline to compare against.
703 pub projection: Option<f64>,
704 /// Projection vs prior-year, as a percent change. `None` whenever
705 /// `projection` is.
706 pub pct_change: Option<f64>,
707 /// On-track verdict (rise is good for dividends, matching the Phase 24
708 /// trend reading): `Good` for a clear rise, `Bad` for a clear fall, `Ok`
709 /// for a small move or a flat year, `Unknown` whenever `projection` is.
710 pub grade: Grade,
711 /// One-word badge text derived from `grade` — `Strong` / `Fair` / `Weak`,
712 /// or `No data` when there is nothing to read.
713 pub verdict: &'static str,
714}
715
716/// A small one-week-each-side band around prior-year that reads as flat, so a
717/// rounding-grade payment increase does not register as "growing" /
718/// "shrinking".
719const PACE_FLAT_BAND: f64 = 2.0;
720
721/// Build a [`DividendPace`] from dividend events oldest first. `today` carries
722/// the date the YTD window closes at (taken from the route's clock). Returns a
723/// `DividendPace` even when there is little to say, so the page can show the
724/// raw cadence and totals alone; the verdict downgrades to `Unknown` when a
725/// pace projection is not meaningful.
726pub fn dividend_pace(events: &[(String, f64)], today: chrono::NaiveDate) -> DividendPace {
727 use chrono::Datelike;
728 let year = today.year();
729 let prior = year - 1;
730 let (mut prior_total, mut ytd_total, mut ytd_count) = (0.0_f64, 0.0_f64, 0_u32);
731 for (date, amount) in events {
732 let Ok(d) = chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") else {
733 continue;
734 };
735 if d.year() == year && d <= today {
736 ytd_total += amount;
737 ytd_count += 1;
738 } else if d.year() == prior {
739 prior_total += amount;
740 }
741 }
742
743 let dates: Vec<String> = events.iter().map(|(d, _)| d.clone()).collect();
744 let cadence = infer_cadence(&dates);
745
746 // Count-tempered projection: scale YTD by (expected_n / declared_n_so_far).
747 // A quarterly payer at end-of-Q1 thus projects ×4, not ×~4 by elapsed days,
748 // which is what the user picked over a calendar-elapsed-fraction approach.
749 let (projection, pct_change, grade) = match (
750 cadence.expected_per_year(),
751 ytd_count,
752 prior_total,
753 ) {
754 (Some(expected), declared, prior_year) if declared > 0 && prior_year > 0.0 => {
755 let p = ytd_total * f64::from(expected) / f64::from(declared);
756 let pct = (p - prior_year) / prior_year * 100.0;
757 let grade = if pct > PACE_FLAT_BAND {
758 Grade::Good
759 } else if pct < -PACE_FLAT_BAND {
760 Grade::Bad
761 } else {
762 Grade::Ok
763 };
764 (Some(p), Some(pct), grade)
765 }
766 _ => (None, None, Grade::Unknown),
767 };
768
769 DividendPace {
770 cadence,
771 cadence_caption: cadence.caption(),
772 prior_year_total: prior_total,
773 ytd_total,
774 ytd_count,
775 projection,
776 pct_change,
777 grade,
778 verdict: grade.verdict(),
779 }
780}
781
782// ─────────────────────── company standing (Phase 20) ───────────────────────
783//
784// A stock's overall standing rolls its nine graded ratios into a single
785// strong / fair / weak verdict — the badge shown across the app — and combines
786// that fundamental strength with a price-and-growth trajectory into one score
787// the home page ranks the strongest and weakest stocks by. Everything here is
788// pure: it derives only from the Phase 7 ratios and a daily-close series, with
789// no new data source.
790
791/// Of the nine ratios, how many must carry a real grade (not `Unknown`) before
792/// a strength verdict is meaningful. A company reporting almost nothing gets no
793/// badge rather than one resting on one or two figures.
794const MIN_GRADED: usize = 5;
795
796/// Strength-score cutoffs for the strong / fair / weak verdict. The score is a
797/// mean of per-ratio values in [-1, 1]; a curated large-cap typically lands
798/// near zero, so the band is deliberately narrow. Tunable.
799const STRONG_CUTOFF: f64 = 0.2;
800const WEAK_CUTOFF: f64 = -0.2;
801
802/// Weight of fundamental strength in the combined score; trajectory takes the
803/// rest. ~2:1 in favour of fundamentals (a user steer — the ranking should
804/// lean on how well a company is built over how its price has lately moved).
805const STRENGTH_WEIGHT: f64 = 2.0 / 3.0;
806
807/// Trading days in the trailing-year price-trend window (~12 months).
808const TREND_WINDOW: usize = 252;
809/// Trading days per sub-block when measuring how steady the climb was (~1mo).
810const TREND_BLOCK: usize = 21;
811/// Minimum history (~3 months) before a price trend is read at all.
812const TREND_MIN: usize = TREND_BLOCK * 3;
813/// A trailing return of this magnitude (a fraction, so 0.25 = ±25%) saturates
814/// the return component of the price-trend score.
815const TREND_SATURATION: f64 = 0.25;
816
817/// A stock's rolled-up standing: the strong / fair / weak verdict shown as a
818/// badge across the app, plus a combined strength-and-trajectory score the
819/// home "Strongest & weakest" panels rank by.
820#[derive(Debug, Clone, Copy, Serialize)]
821pub struct Standing {
822 /// CSS hook for the badge: `good` | `ok` | `bad`. Mirrors `Grade`, so it
823 /// reuses the per-ratio badge colours.
824 pub grade: Grade,
825 /// Badge text derived from `grade`: `Strong` | `Fair` | `Weak`.
826 pub verdict: &'static str,
827 /// Combined score in [-1, 1]; the home panels sort by it. The verdict
828 /// above reflects fundamental strength alone (it sits over the ratio
829 /// cards); this score additionally folds in trajectory.
830 pub score: f64,
831}
832
833/// A grade's numeric value for averaging: `Good` +1, `Ok` 0, `Bad` −1.
834/// `Unknown` carries no value and is skipped by the mean.
835fn grade_value(g: Grade) -> Option<f64> {
836 match g {
837 Grade::Good => Some(1.0),
838 Grade::Ok => Some(0.0),
839 Grade::Bad => Some(-1.0),
840 Grade::Unknown => None,
841 }
842}
843
844/// Mean of the graded values in `grades`, ignoring `Unknown`. `None` when
845/// fewer than `min` of them carried a grade.
846fn graded_mean(grades: impl Iterator<Item = Grade>, min: usize) -> Option<f64> {
847 let vals: Vec<f64> = grades.filter_map(grade_value).collect();
848 (vals.len() >= min).then(|| vals.iter().sum::<f64>() / vals.len() as f64)
849}
850
851/// Map a score in [-1, 1] to a strong / fair / weak `Grade`.
852fn score_grade(score: f64) -> Grade {
853 if score >= STRONG_CUTOFF {
854 Grade::Good
855 } else if score <= WEAK_CUTOFF {
856 Grade::Bad
857 } else {
858 Grade::Ok
859 }
860}
861
862/// Score the trailing-year price trend in [-1, 1]: a trailing return blended
863/// with how steady the climb was — the share of ~monthly sub-blocks that did
864/// not fall. `None` with too little history to judge.
865fn price_trend_score(closes: &[f64]) -> Option<f64> {
866 if closes.len() < TREND_MIN {
867 return None;
868 }
869 let window = &closes[closes.len().saturating_sub(TREND_WINDOW)..];
870 let (&first, &last) = (window.first()?, window.last()?);
871 if first <= 0.0 {
872 return None;
873 }
874 // Return component: a move of ±TREND_SATURATION over the window saturates.
875 let ret = (last - first) / first;
876 let ret_comp = (ret / TREND_SATURATION).clamp(-1.0, 1.0);
877 // Steadiness: the fraction of ~monthly blocks that closed up, recentred to
878 // [-1, 1] so an all-up year reads +1 and an all-down year −1.
879 let (mut blocks, mut up) = (0u32, 0u32);
880 let mut i = 0;
881 while i + TREND_BLOCK < window.len() {
882 blocks += 1;
883 if window[i + TREND_BLOCK] >= window[i] {
884 up += 1;
885 }
886 i += TREND_BLOCK;
887 }
888 let steady_comp = if blocks > 0 {
889 (f64::from(up) / f64::from(blocks) - 0.5) * 2.0
890 } else {
891 ret_comp
892 };
893 // The return carries most of the weight; steadiness only refines it.
894 Some(0.7 * ret_comp + 0.3 * steady_comp)
895}
896
897/// Trajectory score in [-1, 1]: the recent price trend blended equally with
898/// fundamental growth (the revenue- and earnings-growth ratio grades). `None`
899/// when neither half can be computed.
900fn trajectory_score(ratios: &[Ratio], closes: &[f64]) -> Option<f64> {
901 let price = price_trend_score(closes);
902 let growth = graded_mean(
903 ratios
904 .iter()
905 .filter(|r| matches!(r.key, "revenue_growth" | "earnings_growth"))
906 .map(|r| r.grade),
907 1,
908 );
909 match (price, growth) {
910 (Some(p), Some(g)) => Some((p + g) / 2.0),
911 (Some(v), None) | (None, Some(v)) => Some(v),
912 (None, None) => None,
913 }
914}
915
916/// Roll a stock's nine graded ratios and its price trajectory into a single
917/// [`Standing`]. `ratios` is the output of [`compute_ratios`]; `closes` is a
918/// daily-close series (oldest first) over roughly the trailing year, which may
919/// be empty. `None` when too few ratios graded to judge.
920pub fn standing(ratios: &[Ratio], closes: &[f64]) -> Option<Standing> {
921 // Fundamental strength: the mean grade across all nine ratios. The badge's
922 // verdict reflects this alone, since it sits over the ratio cards.
923 let strength = graded_mean(ratios.iter().map(|r| r.grade), MIN_GRADED)?;
924 let grade = score_grade(strength);
925 // Combined score: fundamentals weighted ~2:1 over trajectory. With no
926 // trajectory to read, strength stands alone.
927 let score = match trajectory_score(ratios, closes) {
928 Some(t) => STRENGTH_WEIGHT * strength + (1.0 - STRENGTH_WEIGHT) * t,
929 None => strength,
930 };
931 Some(Standing {
932 grade,
933 verdict: grade.verdict(),
934 score,
935 })
936}
937
938// ─────────────────────── stock health read (Phase 17) ──────────────────────
939//
940// The health read layers a leadership-stability signal over the Phase 20
941// strength + trajectory composite to give a single non-advice summary of
942// whether a stock looks healthy. Pure derivation: ratios from Phase 7, a
943// daily-close series, and a count of recent 8-K item-5.02 leadership changes
944// from Phase 14. Industry context (Phase 15) is intentionally not folded in
945// yet; this phase ships without it and a later pass will layer it on.
946
947/// Trailing window in days for counting 8-K item-5.02 leadership changes that
948/// feed the stability score (~24 months). Long enough to capture an annual
949/// change pattern, short enough that years-old turnover ages out.
950pub const LEADERSHIP_STABILITY_DAYS: i64 = 730;
951
952/// Health-composite weights. Strength carries the most weight (a healthy
953/// company is first built well), trajectory next, stability last. They sum
954/// to 1; the weighting mirrors the Phase 20 user steer that the ranking
955/// should lean on how the company is *built* over how its price has *moved*.
956const HEALTH_W_STRENGTH: f64 = 0.55;
957const HEALTH_W_TRAJECTORY: f64 = 0.30;
958const HEALTH_W_STABILITY: f64 = 0.15;
959
960/// A stock's health read: an overall healthy / mixed / concerning verdict,
961/// the composite score the home panels rank by, and the three sub-components
962/// behind it so the symbol page can show the breakdown.
963#[derive(Debug, Clone, Copy, Serialize)]
964pub struct HealthRead {
965 /// CSS hook for the overall badge: `good` | `ok` | `bad`.
966 pub overall: Grade,
967 /// Badge text derived from `overall`: `Healthy` | `Mixed` | `Concerning`.
968 pub verdict: &'static str,
969 /// Composite score in [-1, 1]; home panels sort by it.
970 pub score: f64,
971 /// `score` mapped linearly to a 0-100 percentage for the header badge:
972 /// `-1.0` reads 0%, `0.0` reads 50%, `+1.0` reads 100%.
973 pub percent: u8,
974 pub strength: Grade,
975 pub strength_label: &'static str,
976 pub trajectory: Grade,
977 pub trajectory_label: &'static str,
978 pub stability: Grade,
979 pub stability_label: &'static str,
980 /// 8-K item-5.02 filings counted inside `LEADERSHIP_STABILITY_DAYS`.
981 pub recent_changes: usize,
982}
983
984/// Map an overall health grade to its display verdict. Distinct from the
985/// per-ratio `Strong/Fair/Weak` so the panel reads as a synthesis, not a
986/// ratio rollup.
987fn health_verdict(g: Grade) -> &'static str {
988 match g {
989 Grade::Good => "Healthy",
990 Grade::Ok => "Mixed",
991 Grade::Bad => "Concerning",
992 Grade::Unknown => "Unread",
993 }
994}
995
996/// Trajectory sub-component label.
997fn trajectory_label(g: Grade) -> &'static str {
998 match g {
999 Grade::Good => "Climbing",
1000 Grade::Ok => "Steady",
1001 Grade::Bad => "Slipping",
1002 Grade::Unknown => "—",
1003 }
1004}
1005
1006/// Leadership-stability sub-component label.
1007fn stability_label(g: Grade) -> &'static str {
1008 match g {
1009 Grade::Good => "Stable",
1010 Grade::Ok => "Normal",
1011 Grade::Bad => "Churning",
1012 Grade::Unknown => "—",
1013 }
1014}
1015
1016/// Grade leadership stability from the count of recent 8-K item-5.02 changes
1017/// inside `LEADERSHIP_STABILITY_DAYS`. Returns `None` when the leadership
1018/// sweep has not reached the stock yet (the caller passes `None`), so the
1019/// component drops out of the composite cleanly instead of penalising
1020/// an unsynced stock. Three discrete bands rather than a linear scale: the
1021/// signal is coarse and the bands keep it from drifting on small counts.
1022///
1023/// - 0-1 change → `Good` / `+1.0` — stable
1024/// - 2-3 changes → `Ok` / `0.0` — normal
1025/// - 4+ changes → `Bad` / `-1.0` — churn
1026///
1027/// Big companies routinely file ~one planned-succession 5.02 a year, so the
1028/// bands are deliberately lenient.
1029pub fn stability_grade(recent_changes: Option<usize>) -> Option<(Grade, f64)> {
1030 match recent_changes? {
1031 0 | 1 => Some((Grade::Good, 1.0)),
1032 2 | 3 => Some((Grade::Ok, 0.0)),
1033 _ => Some((Grade::Bad, -1.0)),
1034 }
1035}
1036
1037/// Roll a stock's ratios, trajectory and leadership-change count into a
1038/// single [`HealthRead`]. `ratios` is the output of [`compute_ratios`];
1039/// `closes` is a daily-close series (oldest first) over roughly the trailing
1040/// year (may be empty); `recent_changes` is the count of 8-K item-5.02
1041/// filings in the last `LEADERSHIP_STABILITY_DAYS`, or `None` if the
1042/// leadership sweep has not reached this stock. `None` when too few ratios
1043/// graded to judge (same gate as [`standing`]).
1044///
1045/// The composite renormalises over the components that landed — a stock with
1046/// no leadership data yet is read on strength + trajectory alone, not penalised.
1047pub fn health_read(
1048 ratios: &[Ratio],
1049 closes: &[f64],
1050 recent_changes: Option<usize>,
1051) -> Option<HealthRead> {
1052 let strength_raw = graded_mean(ratios.iter().map(|r| r.grade), MIN_GRADED)?;
1053 let trajectory_raw = trajectory_score(ratios, closes);
1054 let stability_pair = stability_grade(recent_changes);
1055
1056 let mut weighted = HEALTH_W_STRENGTH * strength_raw;
1057 let mut total = HEALTH_W_STRENGTH;
1058 if let Some(t) = trajectory_raw {
1059 weighted += HEALTH_W_TRAJECTORY * t;
1060 total += HEALTH_W_TRAJECTORY;
1061 }
1062 if let Some((_, s)) = stability_pair {
1063 weighted += HEALTH_W_STABILITY * s;
1064 total += HEALTH_W_STABILITY;
1065 }
1066 let score = weighted / total;
1067 let overall = score_grade(score);
1068 // Linear map [-1, 1] → [0, 100] for the header badge. Clamped because a
1069 // small floating-point drift past ±1 should not blow past 0% / 100%.
1070 let percent = (((score + 1.0) / 2.0 * 100.0).round() as i32).clamp(0, 100) as u8;
1071
1072 let strength = score_grade(strength_raw);
1073 let trajectory = trajectory_raw.map(score_grade).unwrap_or(Grade::Unknown);
1074 let stability = stability_pair.map(|(g, _)| g).unwrap_or(Grade::Unknown);
1075
1076 Some(HealthRead {
1077 overall,
1078 verdict: health_verdict(overall),
1079 score,
1080 percent,
1081 strength,
1082 strength_label: strength.verdict(),
1083 trajectory,
1084 trajectory_label: trajectory_label(trajectory),
1085 stability,
1086 stability_label: stability_label(stability),
1087 recent_changes: recent_changes.unwrap_or(0),
1088 })
1089}
1090
1091// ────────────────────── ETF trailing returns (Phase 28) ────────────────────
1092//
1093// Trailing total returns from a fund's daily-close series. Distributions are
1094// not folded in (we have them in the `dividends` table from Phase 26, but the
1095// price-return shown here is the most common convention; the distribution
1096// yield rides separately on the page). Periods over a year are annualised so
1097// every figure reads on the same scale.
1098
1099/// One trailing return — both the simple cumulative figure and the
1100/// annualised one (the same number for periods of a year or less).
1101#[derive(Debug, Clone, Copy, Serialize)]
1102pub struct TrailingReturn {
1103 /// Cumulative percent move over the window.
1104 pub pct: f64,
1105 /// CAGR. Equal to `pct` for windows ≤ 1 year; geometrically annualised
1106 /// past that.
1107 pub annualised_pct: f64,
1108}
1109
1110/// The full set of trailing returns the ETF page shows. Each is `None` when
1111/// the price history does not reach back that far.
1112#[derive(Debug, Clone, Default, Serialize)]
1113pub struct TrailingReturns {
1114 pub m1: Option<TrailingReturn>,
1115 pub m3: Option<TrailingReturn>,
1116 pub ytd: Option<TrailingReturn>,
1117 pub y1: Option<TrailingReturn>,
1118 pub y3: Option<TrailingReturn>,
1119 pub y5: Option<TrailingReturn>,
1120 pub y10: Option<TrailingReturn>,
1121 pub since_inception: Option<TrailingReturn>,
1122}
1123
1124/// One bar of the daily-close series the trailing-return / growth functions
1125/// consume: a `YYYY-MM-DD` date and the close. Oldest first.
1126#[derive(Debug, Clone)]
1127pub struct DatedClose<'a> {
1128 pub date: &'a str,
1129 pub close: f64,
1130}
1131
1132/// Compute the full trailing-return set from a `bars` series (oldest first)
1133/// against the latest available close (its tail). Empty / single-bar input
1134/// returns an all-`None` set. `today` is `YYYY-MM-DD` and anchors the YTD
1135/// window to the current calendar year — passing the latest bar's date keeps
1136/// the figure deterministic across requests.
1137pub fn trailing_returns(bars: &[DatedClose<'_>], today: &str) -> TrailingReturns {
1138 if bars.len() < 2 {
1139 return TrailingReturns::default();
1140 }
1141 let latest = bars[bars.len() - 1].close;
1142 if latest <= 0.0 {
1143 return TrailingReturns::default();
1144 }
1145
1146 // Bar at or just before a target date, by walking back from the tail. The
1147 // series is calendar-irregular (weekends, holidays), so an exact match is
1148 // rare; "or just before" is the convention for trailing returns.
1149 let close_at_or_before = |target: &str| -> Option<f64> {
1150 bars.iter()
1151 .rev()
1152 .find(|b| b.date <= target)
1153 .map(|b| b.close)
1154 .filter(|c| *c > 0.0)
1155 };
1156
1157 let ret = |prev: f64, years: f64| -> TrailingReturn {
1158 let cum = (latest / prev - 1.0) * 100.0;
1159 let ann = if years > 1.0 {
1160 ((latest / prev).powf(1.0 / years) - 1.0) * 100.0
1161 } else {
1162 cum
1163 };
1164 TrailingReturn {
1165 pct: cum,
1166 annualised_pct: ann,
1167 }
1168 };
1169
1170 // Approximate-calendar offsets keyed to `today`'s YMD. `chrono` is already
1171 // a dependency, so use it rather than fudging day counts.
1172 let parse = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok();
1173 let today_d = parse(today);
1174
1175 let target = |months: i64| -> Option<String> {
1176 let t = today_d?;
1177 let ym = t.year() as i64 * 12 + (t.month0() as i64) - months;
1178 let (ty, tm0) = (ym.div_euclid(12) as i32, ym.rem_euclid(12) as u32);
1179 let day = t.day().min(28); // a 28th always exists in every month
1180 chrono::NaiveDate::from_ymd_opt(ty, tm0 + 1, day).map(|d| d.format("%Y-%m-%d").to_string())
1181 };
1182 let years_target = |years: i64| target(years * 12);
1183 let ytd_target = || -> Option<String> {
1184 // The last close of the prior calendar year — i.e. the bar at or
1185 // before "Jan 1 of this year" — is the YTD anchor.
1186 let t = today_d?;
1187 Some(format!("{}-01-01", t.year()))
1188 };
1189
1190 let r = |target_date: Option<String>, years: f64| -> Option<TrailingReturn> {
1191 let prev = close_at_or_before(&target_date?)?;
1192 Some(ret(prev, years))
1193 };
1194
1195 let m1 = r(target(1), 1.0 / 12.0);
1196 let m3 = r(target(3), 0.25);
1197 let ytd = r(ytd_target(), 1.0); // YTD is reported cumulative, not annualised
1198 let y1 = r(years_target(1), 1.0);
1199 let y3 = r(years_target(3), 3.0);
1200 let y5 = r(years_target(5), 5.0);
1201 let y10 = r(years_target(10), 10.0);
1202
1203 // Since inception: the very first bar. Years span from its date to today,
1204 // measured in actual days / 365.25 to capture leap-year drift.
1205 let since_inception = (|| {
1206 let first = bars.first()?;
1207 let f = parse(first.date)?;
1208 let t = today_d?;
1209 let days = (t - f).num_days() as f64;
1210 if days <= 0.0 || first.close <= 0.0 {
1211 return None;
1212 }
1213 let years = (days / 365.25).max(1.0 / 12.0);
1214 Some(ret(first.close, years))
1215 })();
1216
1217 TrailingReturns {
1218 m1,
1219 m3,
1220 ytd,
1221 y1,
1222 y3,
1223 y5,
1224 y10,
1225 since_inception,
1226 }
1227}
1228
1229/// Use `chrono::Datelike` for the date arithmetic above.
1230use chrono::Datelike;
1231
1232// ────────────────────── growth-of-$10,000 chart (Phase 28) ─────────────────
1233
1234/// One point of the growth-of-$10k series rendered on the ETF page.
1235#[derive(Debug, Clone, Serialize)]
1236pub struct GrowthPoint {
1237 /// Trading date, `YYYY-MM-DD`.
1238 pub date: String,
1239 /// Dollar value of $10,000 invested at the series' start, on this date.
1240 pub value: f64,
1241}
1242
1243/// Scale a daily-close series so the first bar reads as $10,000. Returns the
1244/// full series — the caller is responsible for downsampling if it would
1245/// render too densely. Empty / single-bar / zero-anchor input returns an
1246/// empty series.
1247pub fn growth_of_10k(bars: &[DatedClose<'_>]) -> Vec<GrowthPoint> {
1248 if bars.len() < 2 {
1249 return Vec::new();
1250 }
1251 let anchor = bars[0].close;
1252 if anchor <= 0.0 {
1253 return Vec::new();
1254 }
1255 bars.iter()
1256 .map(|b| GrowthPoint {
1257 date: b.date.to_string(),
1258 value: 10_000.0 * b.close / anchor,
1259 })
1260 .collect()
1261}
1262
1263// ────────────────────── ETF NAV premium / discount (Phase 28) ──────────────
1264
1265/// Premium or discount of `price` to `nav`, as a percent. A positive value is
1266/// a premium (price > NAV), negative a discount. `None` when NAV is unknown
1267/// or non-positive.
1268pub fn premium_discount_pct(price: f64, nav: Option<f64>) -> Option<f64> {
1269 let nav = nav?;
1270 if nav <= 0.0 {
1271 return None;
1272 }
1273 Some((price - nav) / nav * 100.0)
1274}
1275
1276/// A small good/ok/bad band on the premium/discount figure. A persistently
1277/// large premium is a yellow flag (buying above NAV); a normal ETF stays
1278/// inside ±25 bps. Symmetric — a deep discount is also notable.
1279pub fn premium_grade(premium_pct: f64) -> Grade {
1280 const TIGHT: f64 = 0.25; // ±0.25% is normal for liquid ETFs
1281 const LOOSE: f64 = 1.00; // ±1% is a yellow flag
1282 let abs = premium_pct.abs();
1283 if abs <= TIGHT {
1284 Grade::Good
1285 } else if abs <= LOOSE {
1286 Grade::Ok
1287 } else {
1288 Grade::Bad
1289 }
1290}
1291
1292// ───────────────────────── ETF quality read (Phase 4) ──────────────────────
1293//
1294// An ETF's quality read mirrors the stock health donut: four graded factors —
1295// cost, tracking, diversification, size — rolled into one good/ok/bad verdict
1296// with a 0-100 badge percent and the four sub-readings behind it. It reads the
1297// quality of the *wrapper* (is it cheap, does it hug fair value, is it broad,
1298// is it durable), explicitly not a buy/sell call. Pure: derives only from the
1299// fund's expense ratio, price/NAV premium, top-holdings concentration, and net
1300// assets — all already loaded for the ETF symbol page.
1301
1302/// Cost-weighted blend (a user steer). Cost is the one guaranteed, perpetual
1303/// drag so it carries the most weight; tracking next, then diversification,
1304/// then size. The composite renormalises over whichever factors graded, so a
1305/// commodity trust with no holdings is read on the other three, not penalised.
1306const ETF_W_COST: f64 = 0.40;
1307const ETF_W_TRACKING: f64 = 0.25;
1308const ETF_W_DIVERSIFICATION: f64 = 0.20;
1309const ETF_W_SIZE: f64 = 0.15;
1310
1311/// At least this many of the four factors must grade before the badge shows, so
1312/// a fund we know almost nothing about gets no read rather than a hollow one.
1313const ETF_MIN_GRADED: usize = 2;
1314
1315/// An ETF's quality read: an overall strong / fair / weak verdict, the
1316/// composite score, the 0-100 badge percent, and the four sub-components behind
1317/// it so the symbol page can show the breakdown. Structurally a sibling of
1318/// [`HealthRead`] (stocks), with ETF-appropriate factors.
1319#[derive(Debug, Clone, Copy, Serialize)]
1320pub struct EtfQuality {
1321 /// CSS hook for the overall badge: `good` | `ok` | `bad`.
1322 pub overall: Grade,
1323 /// Badge text derived from `overall`: `Strong` | `Fair` | `Weak`.
1324 pub verdict: &'static str,
1325 /// Composite score in [-1, 1].
1326 pub score: f64,
1327 /// `score` mapped linearly to a 0-100 percent for the header badge.
1328 pub percent: u8,
1329 pub cost: Grade,
1330 pub cost_label: &'static str,
1331 pub tracking: Grade,
1332 pub tracking_label: &'static str,
1333 pub diversification: Grade,
1334 pub diversification_label: &'static str,
1335 pub size: Grade,
1336 pub size_label: &'static str,
1337 /// How many of the four factors carried a grade (the rest dropped out of
1338 /// the blend). Not displayed; useful for debugging a thin read.
1339 pub graded: usize,
1340}
1341
1342/// Cost sub-score from the expense ratio (a decimal, e.g. `0.0003` = 0.03%).
1343/// Cheaper is better: ≤0.05% saturates at +1, ≥0.75% at −1, linear between.
1344/// Bands suit the curated iShares/Vanguard roster (core funds ~0.03-0.10%; the
1345/// priciest commodity/thematic ones ~0.40-0.75%). `None` when not reported.
1346fn etf_cost_score(expense_ratio: Option<f64>) -> Option<f64> {
1347 let pct = expense_ratio? * 100.0;
1348 if pct < 0.0 {
1349 return None;
1350 }
1351 const CHEAP: f64 = 0.05;
1352 const PRICEY: f64 = 0.75;
1353 Some((1.0 - (pct - CHEAP) / (PRICEY - CHEAP) * 2.0).clamp(-1.0, 1.0))
1354}
1355
1356/// Tracking sub-score from the price's premium/discount to NAV (a signed
1357/// percent). Reuses the page's [`premium_grade`] bands mapped to a numeric:
1358/// tight ≤±0.25% ≈ +1, wide ≥±1% ≈ −1, linear in the absolute gap. `None` when
1359/// NAV is unknown (no premium to read).
1360fn etf_tracking_score(premium_pct: Option<f64>) -> Option<f64> {
1361 let abs = premium_pct?.abs();
1362 const TIGHT: f64 = 0.25;
1363 const WIDE: f64 = 1.00;
1364 Some((1.0 - (abs - TIGHT) / (WIDE - TIGHT) * 2.0).clamp(-1.0, 1.0))
1365}
1366
1367/// Diversification sub-score from the top-10 holdings' combined weight (a
1368/// percent, e.g. `27.5` = 27.5%). Lower concentration is broader/safer: ≤20%
1369/// saturates at +1, ≥60% at −1. `None` for funds with no reported holdings
1370/// (commodity trusts), dropping the factor from the blend.
1371fn etf_diversification_score(top10_pct: Option<f64>) -> Option<f64> {
1372 let c = top10_pct?;
1373 if c <= 0.0 {
1374 return None;
1375 }
1376 const BROAD: f64 = 20.0;
1377 const CONCENTRATED: f64 = 60.0;
1378 Some((1.0 - (c - BROAD) / (CONCENTRATED - BROAD) * 2.0).clamp(-1.0, 1.0))
1379}
1380
1381/// Size sub-score from net assets (AUM, USD), on a log scale: small funds carry
1382/// closure / liquidity risk, large ones are durable. Each 10× in AUM is ±1
1383/// centred on ~$2B (so ~$200M ≈ −1, ~$2B ≈ 0, ≥~$20B ≈ +1). `None` when AUM is
1384/// unknown or non-positive.
1385fn etf_size_score(net_assets: Option<f64>) -> Option<f64> {
1386 let aum = net_assets?;
1387 if aum <= 0.0 {
1388 return None;
1389 }
1390 const MID_LOG: f64 = 9.3; // log10($2B) ≈ 9.30
1391 Some((aum.log10() - MID_LOG).clamp(-1.0, 1.0))
1392}
1393
1394fn etf_cost_label(g: Grade) -> &'static str {
1395 match g {
1396 Grade::Good => "Cheap",
1397 Grade::Ok => "Moderate",
1398 Grade::Bad => "Pricey",
1399 Grade::Unknown => "—",
1400 }
1401}
1402
1403fn etf_tracking_label(g: Grade) -> &'static str {
1404 match g {
1405 Grade::Good => "Tight",
1406 Grade::Ok => "Slight drift",
1407 Grade::Bad => "Wide gap",
1408 Grade::Unknown => "—",
1409 }
1410}
1411
1412fn etf_diversification_label(g: Grade) -> &'static str {
1413 match g {
1414 Grade::Good => "Broad",
1415 Grade::Ok => "Moderate",
1416 Grade::Bad => "Concentrated",
1417 Grade::Unknown => "—",
1418 }
1419}
1420
1421fn etf_size_label(g: Grade) -> &'static str {
1422 match g {
1423 Grade::Good => "Large",
1424 Grade::Ok => "Mid-size",
1425 Grade::Bad => "Small",
1426 Grade::Unknown => "—",
1427 }
1428}
1429
1430/// Roll an ETF's four factors into a single [`EtfQuality`]. `expense_ratio` is a
1431/// decimal; `premium_pct` is the signed price-vs-NAV percent (from
1432/// [`premium_discount_pct`]); `top10_pct` is the summed weight of the ten
1433/// largest holdings (percent), `None` for a fund with no holdings; `net_assets`
1434/// is AUM in USD. Returns `None` until at least [`ETF_MIN_GRADED`] factors
1435/// grade, so a barely-known fund gets no badge rather than a hollow one. The
1436/// composite renormalises over the factors that landed.
1437pub fn etf_quality(
1438 expense_ratio: Option<f64>,
1439 premium_pct: Option<f64>,
1440 top10_pct: Option<f64>,
1441 net_assets: Option<f64>,
1442) -> Option<EtfQuality> {
1443 let cost_raw = etf_cost_score(expense_ratio);
1444 let tracking_raw = etf_tracking_score(premium_pct);
1445 let div_raw = etf_diversification_score(top10_pct);
1446 let size_raw = etf_size_score(net_assets);
1447
1448 let factors = [
1449 (cost_raw, ETF_W_COST),
1450 (tracking_raw, ETF_W_TRACKING),
1451 (div_raw, ETF_W_DIVERSIFICATION),
1452 (size_raw, ETF_W_SIZE),
1453 ];
1454 let graded = factors.iter().filter(|(r, _)| r.is_some()).count();
1455 if graded < ETF_MIN_GRADED {
1456 return None;
1457 }
1458 let (mut weighted, mut total) = (0.0, 0.0);
1459 for (r, w) in factors {
1460 if let Some(v) = r {
1461 weighted += w * v;
1462 total += w;
1463 }
1464 }
1465 let score = weighted / total;
1466 let overall = score_grade(score);
1467 let percent = (((score + 1.0) / 2.0 * 100.0).round() as i32).clamp(0, 100) as u8;
1468
1469 let cost = cost_raw.map_or(Grade::Unknown, score_grade);
1470 let tracking = tracking_raw.map_or(Grade::Unknown, score_grade);
1471 let diversification = div_raw.map_or(Grade::Unknown, score_grade);
1472 let size = size_raw.map_or(Grade::Unknown, score_grade);
1473
1474 Some(EtfQuality {
1475 overall,
1476 // `overall` is never Unknown (score_grade only yields Good/Ok/Bad), so
1477 // Grade::verdict's Strong/Fair/Weak reads cleanly as a quality grade.
1478 verdict: overall.verdict(),
1479 score,
1480 percent,
1481 cost,
1482 cost_label: etf_cost_label(cost),
1483 tracking,
1484 tracking_label: etf_tracking_label(tracking),
1485 diversification,
1486 diversification_label: etf_diversification_label(diversification),
1487 size,
1488 size_label: etf_size_label(size),
1489 graded,
1490 })
1491}
1492
1493// ── Phase 16: per-ticker anomaly feed ─────────────────────────────────────
1494
1495/// One row in the symbol-page anomaly feed. Built either here (price events,
1496/// drawdowns) or in `models.rs` (fundamentals events) or directly in the
1497/// symbol route (leadership events, reused from Phase 14's filings SELECT).
1498#[derive(Debug, Clone, Serialize)]
1499pub struct AnomalyEvent {
1500 /// `YYYY-MM-DD`.
1501 pub date: String,
1502 /// Glyph key the template maps to an icon — one of `up`, `down`,
1503 /// `drawdown`, `fund-up`, `fund-down`, `leader`.
1504 pub glyph: &'static str,
1505 /// `good` | `bad` | `neutral` — drives the row's background tint in the
1506 /// feed so a one-glance scan reveals whether recent events skew positive
1507 /// or negative. Up / fund-up are good, down / drawdown / fund-down are
1508 /// bad, leadership changes are neutral (an officer change is not itself
1509 /// good or bad news).
1510 pub polarity: &'static str,
1511 /// Human one-line headline, e.g. `+8.2% one-day move`.
1512 pub headline: String,
1513 /// Outbound link (set on leadership events; the row becomes an anchor).
1514 pub url: Option<String>,
1515 /// Sort tiebreaker; larger = more notable. Not displayed, just used to
1516 /// keep the top N when the merged feed overflows the display cap.
1517 pub severity: f64,
1518}
1519
1520/// Trailing-volatility window for the price-move detector (~6 months trading days).
1521const PRICE_VOL_WINDOW: usize = 90;
1522/// Daily-return magnitude threshold below which we never flag, even for a
1523/// very low-vol stock where 2σ would come in tiny.
1524const PRICE_MIN_MOVE: f64 = 0.05;
1525/// Standard-deviation multiplier — a move must clear both this and PRICE_MIN_MOVE.
1526const PRICE_SIGMA_MULT: f64 = 2.0;
1527/// Drawdown lookback (~6 months trading days).
1528const DRAWDOWN_WINDOW: usize = 126;
1529/// Min gap (trading days) between drawdown events so a long slide doesn't
1530/// emit every bar.
1531const DRAWDOWN_DEDUPE_BARS: usize = 30;
1532
1533/// Walk `closes` (oldest-first, aligned to `dates`) and emit one event for
1534/// every bar whose close-to-close return is both `> 5%` in magnitude and
1535/// `> 2σ` of the trailing 90-day daily returns. The pair of thresholds
1536/// keeps a low-vol stock's modest move from qualifying just because its
1537/// σ is tiny, and a high-vol name's daily wobble from qualifying just
1538/// because 5% is its normal range.
1539pub fn price_anomalies(closes: &[f64], dates: &[&str]) -> Vec<AnomalyEvent> {
1540 debug_assert_eq!(closes.len(), dates.len());
1541 let n = closes.len();
1542 if n <= PRICE_VOL_WINDOW + 1 {
1543 return Vec::new();
1544 }
1545 let mut out = Vec::new();
1546 for i in (PRICE_VOL_WINDOW + 1)..n {
1547 let prev = closes[i - 1];
1548 let cur = closes[i];
1549 if prev <= 0.0 {
1550 continue;
1551 }
1552 let r = (cur - prev) / prev;
1553 // Trailing daily returns over the prior PRICE_VOL_WINDOW bars
1554 // (returns r_j for j in start..i, anchored to closes[j-1]).
1555 let start = i - PRICE_VOL_WINDOW;
1556 let (mut sum, mut sum2, mut n_ret) = (0.0_f64, 0.0_f64, 0usize);
1557 for j in start..i {
1558 let p = closes[j - 1];
1559 if p <= 0.0 {
1560 continue;
1561 }
1562 let rr = (closes[j] - p) / p;
1563 sum += rr;
1564 sum2 += rr * rr;
1565 n_ret += 1;
1566 }
1567 if n_ret < PRICE_VOL_WINDOW / 2 {
1568 continue;
1569 }
1570 let mean = sum / n_ret as f64;
1571 let var = (sum2 / n_ret as f64 - mean * mean).max(0.0);
1572 let sigma = var.sqrt();
1573 if r.abs() >= PRICE_MIN_MOVE && r.abs() >= PRICE_SIGMA_MULT * sigma {
1574 let pct = r * 100.0;
1575 let (glyph, polarity, sign) = if pct >= 0.0 {
1576 ("up", "good", "+")
1577 } else {
1578 ("down", "bad", "\u{2212}")
1579 };
1580 out.push(AnomalyEvent {
1581 date: dates[i].to_string(),
1582 glyph,
1583 polarity,
1584 headline: format!("{sign}{:.1}% one-day move", pct.abs()),
1585 url: None,
1586 severity: pct.abs(),
1587 });
1588 }
1589 }
1590 out
1591}
1592
1593/// Emit one event each time `close` prints a fresh 6-month low — a strict
1594/// minimum below the prior 126 bars' range. A long slide that keeps
1595/// printing lower lows is collapsed to one event per
1596/// `DRAWDOWN_DEDUPE_BARS`-bar window so the feed does not stream daily.
1597/// Headline carries the drop from the trailing window's peak as the
1598/// magnitude.
1599pub fn drawdown_anomalies(closes: &[f64], dates: &[&str]) -> Vec<AnomalyEvent> {
1600 debug_assert_eq!(closes.len(), dates.len());
1601 let n = closes.len();
1602 if n <= DRAWDOWN_WINDOW {
1603 return Vec::new();
1604 }
1605 let mut out: Vec<AnomalyEvent> = Vec::new();
1606 let mut last_emit_i: Option<usize> = None;
1607 for i in DRAWDOWN_WINDOW..n {
1608 let cur = closes[i];
1609 if cur <= 0.0 {
1610 continue;
1611 }
1612 let start = i - DRAWDOWN_WINDOW;
1613 let prior_min = closes[start..i].iter().copied().fold(f64::INFINITY, f64::min);
1614 let prior_max = closes[start..i].iter().copied().fold(f64::NEG_INFINITY, f64::max);
1615 if cur < prior_min {
1616 if let Some(j) = last_emit_i {
1617 if i - j < DRAWDOWN_DEDUPE_BARS {
1618 continue;
1619 }
1620 }
1621 let drop = if prior_max > 0.0 {
1622 (cur - prior_max) / prior_max * 100.0
1623 } else {
1624 0.0
1625 };
1626 out.push(AnomalyEvent {
1627 date: dates[i].to_string(),
1628 glyph: "drawdown",
1629 polarity: "bad",
1630 headline: format!("New 6-month low ({:.0}% off peak)", drop),
1631 url: None,
1632 severity: drop.abs(),
1633 });
1634 last_emit_i = Some(i);
1635 }
1636 }
1637 out
1638}
1639
1640// ── Phase 25: earnings dates ──────────────────────────────────────────────
1641
1642/// Estimate the next earnings date from a stock's recent earnings cadence.
1643/// Used as the fallback when Yahoo's `calendarEvents` has no upcoming date
1644/// for the stock (its coverage is uneven on small caps), built from the
1645/// last up-to-four 8-K item-2.02 dates the existing `filings` table already
1646/// carries (Phase 14).
1647///
1648/// `dates` is newest-first (matching how the symbol route's `ORDER BY
1649/// filed_at DESC` SELECT returns them). Returns `None` when fewer than two
1650/// priors exist or the spacing reads degenerate (a same-day correction).
1651/// Less reliable than Yahoo when a company moves its reporting calendar,
1652/// but better than no date when Yahoo is empty.
1653pub fn next_earnings_estimate(dates: &[&str]) -> Option<String> {
1654 if dates.len() < 2 {
1655 return None;
1656 }
1657 // Parse the newest-first slice into `NaiveDate`s; drop any unparsable
1658 // entries (defensive — these come from SEC, but the column is TEXT).
1659 let parsed: Vec<chrono::NaiveDate> = dates
1660 .iter()
1661 .filter_map(|s| chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").ok())
1662 .take(4)
1663 .collect();
1664 if parsed.len() < 2 {
1665 return None;
1666 }
1667 // Gaps between consecutive earnings prints, oldest-to-newest order.
1668 let mut gaps: Vec<i64> = parsed
1669 .windows(2)
1670 .map(|w| (w[0] - w[1]).num_days())
1671 .collect();
1672 if gaps.iter().all(|g| *g <= 1) {
1673 return None;
1674 }
1675 gaps.sort();
1676 let median = gaps[gaps.len() / 2];
1677 // Clamp the median into a sane quarterly band so a stale dataset with
1678 // a few-day gap (multiple 8-Ks tagged 2.02 in one cycle) does not
1679 // project a date in the next week. Most US large-caps file ~91 days
1680 // apart; semi-annual filers ~182.
1681 let median = median.clamp(60, 200);
1682 let next = parsed[0] + chrono::Duration::days(median);
1683 Some(next.format("%Y-%m-%d").to_string())
1684}
1685
1686#[cfg(test)]
1687mod phase25_tests {
1688 use super::*;
1689
1690 #[test]
1691 fn estimates_a_quarterly_cadence() {
1692 // Four prints roughly 91 days apart, newest-first.
1693 let dates = &["2026-05-01", "2026-02-01", "2025-10-30", "2025-08-01"];
1694 let next = next_earnings_estimate(dates).unwrap();
1695 // Median gap ≈ 90 days, so next ≈ 2026-07-30 (give or take a day
1696 // depending on the exact gaps).
1697 let parsed = chrono::NaiveDate::parse_from_str(&next, "%Y-%m-%d").unwrap();
1698 let baseline = chrono::NaiveDate::parse_from_str("2026-05-01", "%Y-%m-%d").unwrap();
1699 let gap = (parsed - baseline).num_days();
1700 assert!((85..=95).contains(&gap), "next gap was {gap}d");
1701 }
1702
1703 #[test]
1704 fn returns_none_on_too_few_priors() {
1705 assert!(next_earnings_estimate(&[]).is_none());
1706 assert!(next_earnings_estimate(&["2026-05-01"]).is_none());
1707 }
1708
1709 #[test]
1710 fn handles_same_day_corrections() {
1711 // Two filings on adjacent days (a press-release and a follow-up): the
1712 // 1-day gap is degenerate, so the estimate is rejected.
1713 let dates = &["2026-05-02", "2026-05-01"];
1714 assert!(next_earnings_estimate(dates).is_none());
1715 }
1716}
1717
1718#[cfg(test)]
1719mod phase28_tests {
1720 use super::*;
1721
1722 fn bars(samples: &[(&str, f64)]) -> Vec<DatedClose<'static>> {
1723 samples
1724 .iter()
1725 .map(|(d, c)| DatedClose {
1726 date: Box::leak(d.to_string().into_boxed_str()),
1727 close: *c,
1728 })
1729 .collect()
1730 }
1731
1732 #[test]
1733 fn trailing_returns_basic() {
1734 // A simple flat-then-spike series for 1y/3y windows.
1735 let b = bars(&[
1736 ("2023-01-02", 100.0),
1737 ("2024-01-02", 110.0),
1738 ("2025-01-02", 121.0),
1739 ("2026-01-02", 133.1),
1740 ("2026-05-22", 140.0),
1741 ]);
1742 let r = trailing_returns(&b, "2026-05-22");
1743 // 1y from 2025-05-22 onwards: closest bar at or before is 2025-01-02 (121.0).
1744 let y1 = r.y1.expect("y1");
1745 assert!((y1.pct - ((140.0 / 121.0 - 1.0) * 100.0)).abs() < 1e-6);
1746 // 3y annualised: anchor at 2023-05-22, closest bar at or before is
1747 // 2023-01-02 (100.0). 140/100 over 3y -> (1.4)^(1/3) - 1.
1748 let y3 = r.y3.expect("y3");
1749 let want = ((140.0_f64 / 100.0).powf(1.0 / 3.0) - 1.0) * 100.0;
1750 assert!((y3.annualised_pct - want).abs() < 1e-6);
1751 // YTD: anchor at "2026-01-01" → closest bar at or before is 2025-01-02
1752 // (no 2026 bar yet for 01-01), then walks past to 2026-01-02 (133.1).
1753 // Actually 2025-01-02 is at-or-before 2026-01-01, so YTD anchors there.
1754 // That's a known edge: when the chart has a print on Jan 2 but not Jan 1,
1755 // YTD overlaps the new year cleanly enough for a tolerance check.
1756 assert!(r.ytd.is_some());
1757 }
1758
1759 #[test]
1760 fn growth_scales_to_10k_anchor() {
1761 let b = bars(&[
1762 ("2020-01-02", 50.0),
1763 ("2021-01-04", 60.0),
1764 ("2022-01-03", 75.0),
1765 ]);
1766 let g = growth_of_10k(&b);
1767 assert_eq!(g.len(), 3);
1768 assert!((g[0].value - 10_000.0).abs() < 1e-6);
1769 assert!((g[1].value - 12_000.0).abs() < 1e-6);
1770 assert!((g[2].value - 15_000.0).abs() < 1e-6);
1771 }
1772
1773 #[test]
1774 fn premium_discount_grades() {
1775 assert!(matches!(premium_grade(0.10), Grade::Good));
1776 assert!(matches!(premium_grade(0.50), Grade::Ok));
1777 assert!(matches!(premium_grade(-2.00), Grade::Bad));
1778 assert!(premium_discount_pct(101.0, Some(100.0)).unwrap().abs() - 1.0 < 1e-9);
1779 assert!(premium_discount_pct(100.0, None).is_none());
1780 assert!(premium_discount_pct(100.0, Some(0.0)).is_none());
1781 }
1782
1783 // ── Phase 16 anomaly-feed tests ────────────────────────────────────────
1784
1785 fn flat_series(n: usize, value: f64) -> (Vec<f64>, Vec<String>) {
1786 let dates: Vec<String> = (0..n)
1787 .map(|i| {
1788 let d = chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()
1789 + chrono::Duration::days(i as i64);
1790 d.format("%Y-%m-%d").to_string()
1791 })
1792 .collect();
1793 (vec![value; n], dates)
1794 }
1795
1796 #[test]
1797 fn price_anomaly_flags_a_big_spike_against_flat_history() {
1798 let (mut closes, dates) = flat_series(120, 100.0);
1799 // Inject 5 tiny wobbles so σ is not exactly zero — a 7% jump still
1800 // qualifies on σ. (A literally-flat history makes σ=0, in which case
1801 // any nonzero move trivially exceeds 2σ; the 5%-floor still gates it.)
1802 closes[60] = 100.5;
1803 closes[80] = 99.5;
1804 closes[119] = 107.0;
1805 let date_refs: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();
1806 let evs = price_anomalies(&closes, &date_refs);
1807 assert!(evs.iter().any(|e| e.date == dates[119]
1808 && e.glyph == "up"
1809 && e.headline.contains("7.0")));
1810 }
1811
1812 #[test]
1813 fn price_anomaly_ignores_a_tiny_move_even_when_above_2_sigma() {
1814 let (mut closes, dates) = flat_series(120, 100.0);
1815 // 1% bump against a literally-flat history would trip 2σ but not the
1816 // 5% floor — the feed should stay empty.
1817 closes[119] = 101.0;
1818 let date_refs: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();
1819 let evs = price_anomalies(&closes, &date_refs);
1820 assert!(evs.is_empty());
1821 }
1822
1823 #[test]
1824 fn drawdown_anomaly_flags_a_fresh_six_month_low() {
1825 // 150 flat bars, then one bar prints a strict new low.
1826 let (mut closes, dates) = flat_series(150, 100.0);
1827 closes[140] = 80.0;
1828 let date_refs: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();
1829 let evs = drawdown_anomalies(&closes, &date_refs);
1830 assert!(evs.iter().any(|e| e.date == dates[140] && e.glyph == "drawdown"));
1831 }
1832
1833 #[test]
1834 fn drawdown_anomaly_dedupes_a_long_slide() {
1835 let (mut closes, dates) = flat_series(180, 100.0);
1836 // Each later bar prints a lower low; without dedupe we'd emit every
1837 // single bar. With a 30-bar cooldown we emit a handful, not 30+.
1838 for i in 130..180 {
1839 closes[i] = 100.0 - (i - 129) as f64;
1840 }
1841 let date_refs: Vec<&str> = dates.iter().map(|s| s.as_str()).collect();
1842 let evs = drawdown_anomalies(&closes, &date_refs);
1843 assert!(evs.len() <= 5, "expected dedupe to keep events sparse, got {}", evs.len());
1844 }
1845
1846 // ── fundamental ratios (the figures the owner most distrusts) ──────────────
1847
1848 #[test]
1849 fn change_is_signed_percent_of_prior() {
1850 let c = change(110.0, 100.0);
1851 assert!((c.abs - 10.0).abs() < 1e-9);
1852 assert!((c.pct - 10.0).abs() < 1e-9);
1853 // A zero prior never divides by zero.
1854 assert_eq!(change(5.0, 0.0).pct, 0.0);
1855 }
1856
1857 #[test]
1858 fn pe_bands_and_reading_precision() {
1859 // A healthy multiple grades Good; the display carries one decimal.
1860 let r = pe(Some(192.0), Some(9.6)); // 20.0x
1861 assert!(matches!(r.grade, Grade::Good));
1862 assert_eq!(r.display, "20.0x");
1863 // Negative earnings → no P/E at all.
1864 assert!(matches!(pe(Some(100.0), Some(-1.0)).grade, Grade::Unknown));
1865 // The plain-English reading echoes the one-decimal value, not a rounded
1866 // whole multiple (the bug where 9.6x read "At 10x …").
1867 let cheap = pe(Some(96.0), Some(10.0)); // 9.6x
1868 assert!(cheap.reading.contains("9.6x"), "reading was: {}", cheap.reading);
1869 }
1870
1871 #[test]
1872 fn revenue_growth_grades_direction() {
1873 assert!(matches!(revenue_growth(Some(120.0), Some(100.0)).grade, Grade::Good)); // +20%
1874 assert!(matches!(revenue_growth(Some(95.0), Some(100.0)).grade, Grade::Bad)); // shrinking
1875 assert!(matches!(revenue_growth(Some(120.0), None).grade, Grade::Unknown));
1876 assert!(matches!(revenue_growth(Some(120.0), Some(0.0)).grade, Grade::Unknown));
1877 }
1878
1879 #[test]
1880 fn earnings_growth_handles_loss_bases() {
1881 // A growth % off a loss-making prior year is meaningless → Unknown.
1882 assert!(matches!(earnings_growth(Some(50.0), Some(-10.0)).grade, Grade::Unknown));
1883 // A swing to a loss from a profitable year is Bad.
1884 assert!(matches!(earnings_growth(Some(-5.0), Some(100.0)).grade, Grade::Bad));
1885 // Healthy profit growth is Good.
1886 assert!(matches!(earnings_growth(Some(130.0), Some(100.0)).grade, Grade::Good));
1887 }
1888
1889 #[test]
1890 fn profit_margin_needs_positive_revenue() {
1891 assert!(matches!(profit_margin(Some(10.0), Some(0.0)).grade, Grade::Unknown));
1892 assert!(matches!(profit_margin(Some(20.0), Some(100.0)).grade, Grade::Good)); // 20%
1893 assert!(matches!(profit_margin(Some(2.0), Some(100.0)).grade, Grade::Bad)); // 2%
1894 }
1895
1896 // ── chart indicators ──────────────────────────────────────────────────────
1897
1898 #[test]
1899 fn moving_averages_warm_up_then_track() {
1900 let xs = [1.0, 2.0, 3.0, 4.0, 5.0];
1901 let s = sma(&xs, 3);
1902 assert_eq!(s[0], None);
1903 assert_eq!(s[1], None);
1904 assert_eq!(s[2], Some(2.0)); // (1+2+3)/3
1905 assert_eq!(s[4], Some(4.0)); // (3+4+5)/3
1906 // EMA seeds at the first full window's simple mean, then rises with the
1907 // (monotonically increasing) series.
1908 let e = ema(&xs, 3);
1909 assert_eq!(e[1], None);
1910 assert_eq!(e[2], Some(2.0));
1911 assert!(e[4].unwrap() > e[2].unwrap());
1912 }
1913
1914 #[test]
1915 fn rsi_pegs_at_100_on_an_all_gains_window() {
1916 let xs: Vec<f64> = (0..20).map(|i| 100.0 + i as f64).collect();
1917 let r = rsi(&xs, 14);
1918 assert_eq!(r[14], Some(100.0)); // no losses → RSI is 100
1919 }
1920}