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

78.0 KB · 1921 lines · Rust Raw History
   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}