101.0 KB
raw
use std::collections::HashMap;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::sse::Sse,
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use chrono::Datelike as _;
use serde::{Deserialize, Serialize};
use crate::compute;
use crate::db::now_ms;
use crate::guard::{EndpointGuard, Permit};
use crate::market;
use crate::models::{self, SymbolRow};
use crate::providers::http;
use crate::providers::yahoo::{SymbolLookup, YahooProvider};
use crate::render::{not_found, render};
use crate::{scheduler, AppState};
pub fn router() -> Router<AppState> {
Router::new()
.route("/s/{ticker}", get(symbol_page))
.route("/api/symbols", post(add_symbol))
.route("/api/symbols/{ticker}/history", get(history_api))
.route("/api/symbols/{ticker}/growth", get(growth_api))
.route("/api/symbols/{ticker}/refresh", get(refresh_stream))
}
/// Stats for the symbol page header and key-stats visualizations. Until
/// intraday data exists (Phase 5), "day" figures come from the most recent
/// daily bar. The `*_pos` fields are 0..100 marker positions for the Paper
/// Ledger range bars; they are derived here so the template stays declarative.
#[derive(Debug, Serialize)]
struct Stats {
date: String,
open: f64,
high: f64,
low: f64,
close: f64,
volume: i64,
prev_close: Option<f64>,
change_abs: Option<f64>,
change_pct: Option<f64>,
/// Open relative to the prior close (the overnight gap).
open_change_pct: Option<f64>,
high_52w: f64,
low_52w: f64,
/// Mean daily volume over the recent ~3-month window.
avg_volume: i64,
/// Marker positions on the day's low..high range bar.
day_open_pos: f64,
day_close_pos: f64,
/// Marker positions on the 52-week low..high range bar.
yr_close_pos: f64,
yr_prev_pos: Option<f64>,
/// Today's volume on a 0..2x-average scale (the average sits at 50).
vol_fill_pct: f64,
/// Today's volume as a multiple of the average, e.g. 1.3 for 1.3x.
vol_ratio: Option<f64>,
}
/// The chart's indicators distilled into a colour-coded read: an overall
/// trend verdict, an RSI momentum gauge, and one signal tile per moving average
/// (where the price sits vs it) plus the 50/200 cross. Built from the daily
/// closes; `None` until a symbol has enough history.
#[derive(Serialize)]
struct IndicatorRead {
/// Overall trend verdict ("Bullish" / "Mixed" / "Bearish") + its tone and a
/// one-line tally ("3 of 4 trend signals bullish").
verdict: String,
verdict_tone: String,
verdict_note: String,
/// RSI(14): the value, its 0–100 position (for the gauge), bucket label,
/// tone, and a plain-language verdict.
rsi: f64,
rsi_pos: f64,
rsi_label: String,
rsi_tone: String,
rsi_note: String,
/// One colour-coded tile per moving-average signal.
signals: Vec<IndicatorSignal>,
}
/// One signal tile: the indicator label, its current value, a short status word
/// (Above / Below / Golden cross / …), the tone colour, and a plain meaning.
#[derive(Serialize)]
struct IndicatorSignal {
label: String,
value: String,
status: String,
tone: String,
note: String,
}
/// Build the colour-coded indicator read from the daily closes (oldest first),
/// the current price, and whether the symbol is dollar-priced. Needs enough
/// history for RSI(14)/EMA(21); the 50/200 averages join once they exist.
fn build_indicator_read(
highs: &[f64],
lows: &[f64],
closes: &[f64],
price: f64,
dollar: bool,
) -> Option<IndicatorRead> {
if closes.len() < 30 || price <= 0.0 {
return None;
}
let rsi = compute::rsi(closes, 14).last().copied().flatten()?;
let fmt = |v: f64| {
let n = format!("{v:.2}");
if dollar {
format!("${n}")
} else {
n
}
};
// RSI verdict: 70+/30- are the textbook overbought/oversold extremes; the
// 45–55 middle is balanced, with a "leaning" read on either side.
let (rsi_label, rsi_tone, rsi_note) = if rsi >= 70.0 {
("Overbought", "down",
format!("RSI {rsi:.0} — overbought: momentum is stretched and may be due for a pullback."))
} else if rsi <= 30.0 {
("Oversold", "up",
format!("RSI {rsi:.0} — oversold: selling looks stretched and may be due for a bounce."))
} else if rsi >= 55.0 {
("Leaning bullish", "up",
format!("RSI {rsi:.0} — firm momentum, not yet overbought."))
} else if rsi <= 45.0 {
("Leaning bearish", "down",
format!("RSI {rsi:.0} — soft momentum, not yet oversold."))
} else {
("Neutral", "steady",
format!("RSI {rsi:.0} — momentum is balanced between buyers and sellers."))
};
let ema21 = compute::ema(closes, 21).last().copied().flatten();
let sma50 = compute::sma(closes, 50).last().copied().flatten();
let sma200 = compute::sma(closes, 200).last().copied().flatten();
// One tile per average that exists, each relating the current price to it.
let mut signals = Vec::new();
let mut bull = 0u32;
let mut total = 0u32;
let mut ma_tile = |label: &str, val: f64, span: &str| {
let above = price >= val;
if above {
bull += 1;
}
total += 1;
signals.push(IndicatorSignal {
label: label.to_string(),
value: fmt(val),
status: if above { "Above" } else { "Below" }.to_string(),
tone: if above { "up" } else { "down" }.to_string(),
note: format!("{span} trend is {}", if above { "up" } else { "down" }),
});
};
if let Some(v) = ema21 {
ma_tile("EMA 21", v, "Near-term");
}
if let Some(v) = sma50 {
ma_tile("SMA 50", v, "Medium-term");
}
if let Some(v) = sma200 {
ma_tile("SMA 200", v, "Long-term");
}
// The 50-vs-200 posture (the golden/death-cross regime).
if let (Some(f), Some(s)) = (sma50, sma200) {
let golden = f >= s;
if golden {
bull += 1;
}
total += 1;
signals.push(IndicatorSignal {
label: "50 / 200-day".to_string(),
value: String::new(),
status: if golden { "Golden cross" } else { "Death cross" }.to_string(),
tone: if golden { "up" } else { "down" }.to_string(),
note: if golden {
"50-day above the 200-day — bullish".to_string()
} else {
"50-day below the 200-day — bearish".to_string()
},
});
}
// Supertrend posture: which side of the ATR band price closed on. Folds
// into the same bullish/bearish tally as the moving-average signals.
let st = compute::supertrend(highs, lows, closes, compute::SUPERTREND_PERIOD, compute::SUPERTREND_MULT)
.last()
.copied()
.flatten();
if let Some(p) = st {
if p.up {
bull += 1;
}
total += 1;
signals.push(IndicatorSignal {
label: "Supertrend".to_string(),
value: fmt(p.value),
status: if p.up { "Uptrend" } else { "Downtrend" }.to_string(),
tone: if p.up { "up" } else { "down" }.to_string(),
note: if p.up {
"Price is holding above the Supertrend band — bullish".to_string()
} else {
"Price is below the Supertrend band — bearish".to_string()
},
});
}
// Overall verdict from the trend tally.
let (verdict, verdict_tone) = if total == 0 {
("No signal", "steady")
} else if bull == total {
("Bullish", "up")
} else if bull == 0 {
("Bearish", "down")
} else {
("Mixed", "warn")
};
let verdict_note = format!("{bull} of {total} trend signals bullish");
Some(IndicatorRead {
verdict: verdict.to_string(),
verdict_tone: verdict_tone.to_string(),
verdict_note,
rsi,
rsi_pos: rsi.clamp(0.0, 100.0),
rsi_label: rsi_label.to_string(),
rsi_tone: rsi_tone.to_string(),
rsi_note,
signals,
})
}
/// The live quote shown in the symbol header, when one exists. The header
/// carries `data-field` hooks, so the stream client patches these in place as
/// fresh quotes arrive; this is just the server-rendered starting point.
#[derive(Debug, Serialize)]
struct HeaderQuote {
price: f64,
change_abs: Option<f64>,
change_pct: Option<f64>,
/// A short human label for the quote's freshness, e.g. "Live", "At close".
state_label: String,
}
#[derive(sqlx::FromRow)]
struct QuoteRow {
price: f64,
prev_close: Option<f64>,
/// When this quote was sourced (epoch-ms), so the header's freshness label
/// reflects the quote's real age, not just the wall-clock session.
fetched_at: Option<i64>,
}
/// How recent a stored quote must be to read as current during an open session.
/// The intraday poll refreshes watched symbols about every 5 minutes and the
/// home sweep every 15; past this a quote is no longer "live", and the header
/// says so rather than asserting a freshness the number does not have.
const QUOTE_FRESH_MS: i64 = 15 * 60 * 1000;
/// A short freshness label for the symbol header, honest about the quote's age.
/// Yahoo's chart endpoint carries no market-state field, so the *session* comes
/// from our own clock (`market.rs`); the *freshness* comes from how long ago the
/// quote was actually sourced (`quoted_at`, epoch-ms). A stale quote during an
/// open session reads "Delayed" instead of a false "Live". After the close the
/// shown number IS the day's close, so its age does not change the "At close"
/// reading.
fn quote_state_label(quoted_at: Option<i64>) -> &'static str {
use market::Session::{Closed, Post, Pre, Regular};
let fresh = quoted_at.is_some_and(|t| now_ms() - t <= QUOTE_FRESH_MS);
match market::session_at(chrono::Utc::now()) {
Pre => if fresh { "Pre-market" } else { "Delayed" },
Regular => if fresh { "Live" } else { "Delayed" },
Post => if fresh { "After hours" } else { "Delayed" },
Closed => "At close",
}
}
// ── fundamentals + filings (Phase 7) ──────────────────────────────────────
/// Placeholder glyph for a value the company did not report — an em dash, an
/// unambiguous "no data" mark (a middle dot read as a stray decimal point).
const DASH: &str = "\u{2014}";
/// How fresh a struck NAV must be before a price-vs-NAV premium/discount is
/// trustworthy. NAV is struck once per trading day, so a NAV older than this is
/// stale and any "premium" against it is really just price drift since then; the
/// premium drops to `None` rather than assert a bogus figure. Shared by the
/// "About this fund" premium line and the ETF quality read's tracking factor.
const NAV_FRESH_MS: i64 = 3 * 24 * 3600 * 1000;
/// A symbol whose most recent daily bar is older than this (calendar days) is
/// treated as dormant: likely delisted, renamed, or halted. Comfortably past a
/// stacked holiday weekend so a normally-trading symbol never trips it. Yahoo
/// still serves a frozen chart for a dead ticker (so the page would otherwise
/// look live), which is exactly why this banner exists.
const STALE_DATA_DAYS: i64 = 7;
/// A "no recent trading data" banner for the symbol header: the date of the last
/// bar we hold and how many days stale it is, so a dormant/delisted symbol reads
/// honestly instead of showing a frozen chart as if it were current.
#[derive(Serialize)]
struct StaleData {
last_date: String,
days: i64,
}
/// Whether a period-over-period rise in a metric is good news, for the
/// financials-table growth cue.
#[derive(Clone, Copy)]
enum Trend {
/// A rise reads as good: revenue, earnings, dividends.
RiseGood,
/// A rise reads as bad: liabilities.
RiseBad,
/// No good/bad reading — total assets and equity, where a rise can be
/// debt-funded or a fall can be a shareholder-friendly buyback. The cue
/// still shows the direction, just without a colour.
Neutral,
}
impl Trend {
/// How a rise vs the prior period reads: `good`, `bad`, or `` (no colour).
fn rise(self) -> &'static str {
match self {
Trend::RiseGood => "good",
Trend::RiseBad => "bad",
Trend::Neutral => "",
}
}
/// How a fall vs the prior period reads.
fn fall(self) -> &'static str {
match self {
Trend::RiseGood => "bad",
Trend::RiseBad => "good",
Trend::Neutral => "",
}
}
}
/// One cell of a financials table: a formatted figure and its period-over-
/// period growth cue.
#[derive(Serialize)]
struct FundCell {
/// The formatted figure, or [`DASH`] where nothing was reported.
display: String,
/// Direction vs the column to its left: `up`, `down`, or `` (the first
/// column, a flat figure, or a missing value on either side).
dir: &'static str,
/// How that move reads for this metric: `good`, `bad`, or `` (no colour).
sense: &'static str,
}
/// One row of a financials table: a metric label and one cell per period.
#[derive(Serialize)]
struct FundRow {
label: String,
cells: Vec<FundCell>,
}
/// A financials table (annual or quarterly) as period columns and metric rows.
#[derive(Serialize)]
struct FundTable {
/// Column headers, oldest period first.
periods: Vec<String>,
rows: Vec<FundRow>,
}
/// Everything the symbol page's fundamentals + financials sections need.
#[derive(Serialize)]
struct FundamentalsView {
/// Fiscal period the annual ratios are based on, e.g. `FY2024`.
basis: Option<String>,
/// What the P/E's earnings figure is, e.g. `TTM through Jun 2025` (trailing
/// twelve months) or `FY2024` when fewer than four quarters are available.
pe_basis: Option<String>,
/// The most recent reported period (e.g. `Jun 2025`), for the freshness note.
earnings_period: Option<String>,
/// True when the latest reported quarter is old enough that the price-based
/// ratios may lag a more recent quarter, so the page can flag it.
earnings_stale: bool,
ratios: Vec<compute::Ratio>,
annual: FundTable,
quarterly: FundTable,
has_annual: bool,
has_quarterly: bool,
}
/// Trailing-twelve-month diluted EPS: the sum of the four most recent quarterly
/// diluted-EPS facts (the derived Q4 included), with the latest quarter's period
/// end. `None` with fewer than four quarters. Diluted EPS does not decompose
/// perfectly quarter to quarter, but a TTM sum tracks current earnings far better
/// than a fiscal-year figure that can be 6-12 months stale — which is exactly what
/// makes a live-price P/E drift. Returns `(ttm_eps, latest_period_end)`.
fn ttm_eps_diluted(facts: &[models::FundFact]) -> Option<(f64, String)> {
let mut q: Vec<(&str, f64)> = facts
.iter()
.filter(|f| f.fiscal_qtr.is_some() && f.metric == "eps_diluted")
.map(|f| (f.period_end.as_str(), f.value))
.collect();
// Most recent period first, one row per quarter.
q.sort_by(|a, b| b.0.cmp(a.0));
q.dedup_by(|a, b| a.0 == b.0);
if q.len() < 4 {
return None;
}
let four = &q[..4];
// The four quarters must be *consecutive*, or their sum is not a real
// trailing twelve months: a missing quarter would splice together periods
// spanning more than a year and still label it "TTM". Require each adjacent
// pair of period-ends to sit about one quarter apart (~80–100 days); when
// they don't, return None so the caller falls back to full-year EPS.
let parse = |d: &str| chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok();
for pair in four.windows(2) {
let gap = (parse(pair[0].0)? - parse(pair[1].0)?).num_days();
if !(80..=100).contains(&gap) {
return None;
}
}
Some((four.iter().map(|(_, v)| *v).sum(), four[0].0.to_string()))
}
/// A `YYYY-MM-DD` period end as a short `Mon YYYY` label (e.g. `Jun 2025`).
fn fmt_period(end: &str) -> String {
chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d")
.map(|d| d.format("%b %Y").to_string())
.unwrap_or_else(|_| end.to_string())
}
/// Days since a `YYYY-MM-DD` period end. Large numbers mean a missing or stale
/// recent filing. Returns `i64::MAX` when the date does not parse (treated stale).
fn period_age_days(end: &str) -> i64 {
match chrono::NaiveDate::parse_from_str(end, "%Y-%m-%d") {
Ok(d) => (chrono::Utc::now().date_naive() - d).num_days(),
Err(_) => i64::MAX,
}
}
/// One SEC filing shaped for the page.
#[derive(Serialize)]
struct FilingView {
/// The raw form type, e.g. `10-K`, shown as a badge.
form: String,
/// A plain-English title derived from the form.
title: String,
filed_at: String,
period_of_report: Option<String>,
url: String,
}
#[derive(sqlx::FromRow)]
struct FilingRow {
form: String,
filed_at: String,
period_of_report: Option<String>,
url: String,
}
/// The metrics shown as rows of the financials table, in order. `is_money` is
/// `true` for a whole-dollar figure (shown compact, e.g. `$391.0B`) and
/// `false` for a per-share figure (shown as plain dollars, e.g. `$6.08`).
/// `in_quarterly` is `false` for the balance-sheet rows: only the fiscal
/// year-end balance is collected, so those rows appear in the annual table
/// only (see `providers::sec::classify`). `trend` sets the period-over-period
/// growth cue's good/bad reading.
struct TableMetric {
metric: &'static str,
label: &'static str,
is_money: bool,
in_quarterly: bool,
trend: Trend,
}
const FUND_TABLE_METRICS: &[TableMetric] = &[
TableMetric { metric: "revenue", label: "Revenue", is_money: true, in_quarterly: true, trend: Trend::RiseGood },
TableMetric { metric: "net_income", label: "Net income", is_money: true, in_quarterly: true, trend: Trend::RiseGood },
TableMetric { metric: "eps_diluted", label: "Diluted EPS", is_money: false, in_quarterly: true, trend: Trend::RiseGood },
TableMetric { metric: "dividends_per_share", label: "Dividend / share", is_money: false, in_quarterly: true, trend: Trend::RiseGood },
TableMetric { metric: "assets", label: "Total assets", is_money: true, in_quarterly: false, trend: Trend::Neutral },
TableMetric { metric: "liabilities", label: "Total liabilities", is_money: true, in_quarterly: false, trend: Trend::RiseBad },
TableMetric { metric: "equity", label: "Shareholder equity", is_money: true, in_quarterly: false, trend: Trend::Neutral },
];
/// Format a whole-dollar figure compactly: `391035000000.0` -> `$391.0B`.
fn fmt_usd_compact(v: f64) -> String {
let sign = if v < 0.0 { "-" } else { "" };
let a = v.abs();
let (n, suffix) = if a >= 1e12 {
(a / 1e12, "T")
} else if a >= 1e9 {
(a / 1e9, "B")
} else if a >= 1e6 {
(a / 1e6, "M")
} else if a >= 1e3 {
(a / 1e3, "K")
} else {
(a, "")
};
if suffix.is_empty() {
format!("{sign}${n:.0}")
} else {
format!("{sign}${n:.1}{suffix}")
}
}
/// Format a per-share figure: `6.08` -> `$6.08`.
fn fmt_per_share(v: f64) -> String {
format!("${v:.2}")
}
/// A plain-English title for a filing, derived from its form type.
fn filing_title(form: &str) -> String {
let base = if form.starts_with("10-K") {
"Annual report"
} else if form.starts_with("10-Q") {
"Quarterly report"
} else if form.starts_with("8-K") {
"Current report"
} else if form.starts_with("DEF 14A") {
"Proxy statement"
} else if form.starts_with("20-F") || form.starts_with("40-F") {
"Annual report"
} else if form.starts_with("6-K") {
"Interim report"
} else if form.starts_with("NPORT") {
"Portfolio holdings report"
} else if form.starts_with("N-CEN") {
"Annual fund census"
} else if form.starts_with("N-CSR") {
"Shareholder report"
} else if form.starts_with("485") {
"Prospectus"
} else {
"Filing"
};
if form.ends_with("/A") {
format!("{base} (amended)")
} else {
base.to_string()
}
}
/// Build one financials table for the given periods (each `(fiscal_year,
/// period_label)`, oldest first), pulling formatted cells from `lookup`. The
/// quarterly table omits the balance-sheet rows, which are only collected per
/// fiscal year. Each cell also carries a period-over-period growth cue
/// computed against the column to its left.
fn fund_table(
periods: &[(i64, String)],
lookup: &HashMap<(String, String), f64>,
quarterly: bool,
) -> FundTable {
let rows = FUND_TABLE_METRICS
.iter()
.filter(|m| !quarterly || m.in_quarterly)
.map(|m| {
// Walk periods oldest-first, carrying the prior period's value so
// each cell can be marked up / down against the one before it.
let mut prev: Option<f64> = None;
let cells = periods
.iter()
.map(|(_, period)| {
let value = lookup
.get(&(m.metric.to_string(), period.clone()))
.copied();
let display = match value {
Some(v) if m.is_money => fmt_usd_compact(v),
Some(v) => fmt_per_share(v),
None => DASH.to_string(),
};
let (dir, sense) = match (prev, value) {
(Some(p), Some(v)) if v > p => ("up", m.trend.rise()),
(Some(p), Some(v)) if v < p => ("down", m.trend.fall()),
_ => ("", ""),
};
prev = value;
FundCell { display, dir, sense }
})
.collect();
FundRow {
label: m.label.to_string(),
cells,
}
})
.collect();
FundTable {
periods: periods.iter().map(|(_, p)| p.clone()).collect(),
rows,
}
}
/// The flow / per-share metrics whose Q4 can be derived as the full fiscal
/// year minus its first three quarters. The balance-sheet metrics are excluded
/// — a year-end balance is a snapshot, not a sum of quarters.
const Q4_DERIVABLE: &[&str] = &["revenue", "net_income", "eps_diluted", "dividends_per_share"];
/// Derive the missing Q4 facts. SEC XBRL carries no discrete fourth quarter:
/// there is no Q4 10-Q, so Q4 lives only inside the 10-K's full-year figure.
/// For every fiscal year with the full year and all three prior quarters
/// present, Q4 is `FY - (Q1 + Q2 + Q3)`. Diluted EPS does
/// not decompose perfectly — the diluted share count drifts quarter to quarter
/// — but the residual is small and the plan calls for showing it.
fn derive_q4(facts: &[models::FundFact]) -> Vec<models::FundFact> {
// (metric, fiscal_year) -> fiscal_qtr (None = full year) -> (value, period_end).
let mut by: HashMap<(&str, i64), HashMap<Option<i64>, (f64, String)>> = HashMap::new();
for f in facts {
by.entry((f.metric.as_str(), f.fiscal_year))
.or_default()
.insert(f.fiscal_qtr, (f.value, f.period_end.clone()));
}
let mut derived = Vec::new();
for ((metric, year), vals) in by {
// A genuine Q4 row (rare, but XBRL does carry a few) always wins.
if !Q4_DERIVABLE.contains(&metric) || vals.contains_key(&Some(4)) {
continue;
}
let (Some(fy), Some(q1), Some(q2), Some(q3)) = (
vals.get(&None),
vals.get(&Some(1)),
vals.get(&Some(2)),
vals.get(&Some(3)),
) else {
continue;
};
derived.push(models::FundFact {
metric: metric.to_string(),
period: format!("Q4-{year}"),
fiscal_year: year,
fiscal_qtr: Some(4),
value: fy.0 - q1.0 - q2.0 - q3.0,
// The synthetic Q4 ends on the FY's period end (Q4 closes the fiscal year).
period_end: fy.1.clone(),
});
}
derived
}
/// Assemble the fundamentals view from a company's stored facts plus the
/// latest price. `None` when the company has no fundamentals stored yet.
fn build_fundamentals(facts: &[models::FundFact], price: Option<f64>) -> Option<FundamentalsView> {
if facts.is_empty() {
return None;
}
// SEC XBRL has no discrete Q4; derive it and fold the
// derived rows in, so the quarterly periods and the cell lookup below pick
// them up exactly like a stored fact.
let derived = derive_q4(facts);
let facts: Vec<models::FundFact> = facts.iter().cloned().chain(derived).collect();
let facts: &[models::FundFact] = &facts;
// (metric, period) -> value, for table-cell lookup.
let mut lookup: HashMap<(String, String), f64> = HashMap::new();
for f in facts {
lookup.insert((f.metric.clone(), f.period.clone()), f.value);
}
// Distinct annual periods, oldest first, most recent 5 kept.
let mut annual: Vec<(i64, String)> = facts
.iter()
.filter(|f| f.fiscal_qtr.is_none())
.map(|f| (f.fiscal_year, f.period.clone()))
.collect();
annual.sort();
annual.dedup();
let annual: Vec<(i64, String)> = annual.into_iter().rev().take(5).rev().collect();
// Distinct quarterly periods, oldest first, most recent 8 kept.
let mut quarterly: Vec<(i64, i64, String)> = facts
.iter()
.filter_map(|f| f.fiscal_qtr.map(|q| (f.fiscal_year, q, f.period.clone())))
.collect();
quarterly.sort();
quarterly.dedup();
let quarterly: Vec<(i64, String)> = quarterly
.into_iter()
.rev()
.take(8)
.rev()
.map(|(y, _, p)| (y, p))
.collect();
// Balance-sheet + margin ratios run off the most recent full fiscal year (the
// shared helper, so the home ranking grades stocks the same way). The P/E,
// though, divides a LIVE price, so it gets trailing-twelve-month EPS when we
// have four quarters: dividing today's price by a 6-12-month-old fiscal-year
// EPS is what made the P/E look wrong after a price move.
let latest_fy = annual.last().map(|(y, _)| *y);
let ttm = ttm_eps_diluted(facts);
let inputs = models::latest_annual_inputs(facts, price).map(|mut i| {
if let Some((eps, _)) = ttm {
i.eps_diluted = Some(eps);
}
i
});
let ratios = inputs.map(|i| compute::compute_ratios(&i)).unwrap_or_default();
let pe_basis = match &ttm {
Some((_, end)) => Some(format!("TTM through {}", fmt_period(end))),
None => latest_fy.map(|y| format!("FY{y}")),
};
// The most recent reported period across all facts, and whether it is old
// enough that the price-based ratios may lag a newer quarter. Companies file
// ~45 days after a quarter ends, so a latest period older than ~150 days
// means a recent quarter is missing or the SEC pull is stale.
let latest_period_end = facts.iter().map(|f| f.period_end.as_str()).max();
let earnings_period = latest_period_end.map(fmt_period);
let earnings_stale = latest_period_end.is_some_and(|d| period_age_days(d) > 150);
Some(FundamentalsView {
basis: latest_fy.map(|y| format!("FY{y}")),
pe_basis,
earnings_period,
earnings_stale,
ratios,
has_annual: !annual.is_empty(),
has_quarterly: !quarterly.is_empty(),
annual: fund_table(&annual, &lookup, false),
quarterly: fund_table(&quarterly, &lookup, true),
})
}
// ── ETF fund profile (Phase 18) ────────────────────────────────────────────
/// A `fund_profiles` row as stored.
#[derive(sqlx::FromRow)]
struct FundProfileRow {
/// `portfolio` or `commodity_trust`.
kind: String,
net_assets: Option<f64>,
holdings_count: Option<i64>,
report_date: Option<String>,
/// JSON `[[bucket, percent], ...]`.
asset_mix: Option<String>,
/// JSON `[[label, percent], ...]`, from each holding's N-PORT
/// `<issuerCat>` (Phase 28). Often degenerate on an equity ETF.
sector_mix: Option<String>,
/// JSON `[[label, percent], ...]`, from each holding's N-PORT
/// `<invCountry>` (Phase 28). Often US-dominant.
geography_mix: Option<String>,
}
/// A `fund_holdings` row as stored.
#[derive(sqlx::FromRow)]
struct HoldingRow {
rank: i64,
name: String,
pct: Option<f64>,
value_usd: Option<f64>,
}
/// One asset-class slice of an ETF's portfolio mix.
#[derive(Serialize)]
struct AssetSlice {
label: String,
/// Percent string, e.g. `99.8%`.
pct: String,
/// Segment width 0..100 for the mix bar.
width: f64,
}
/// One holding row shaped for the page.
#[derive(Serialize)]
struct HoldingView {
rank: i64,
name: String,
/// Weight as a percent string, e.g. `8.42%`.
weight: String,
/// Bar width 0..100, scaled so the largest holding shown fills the rail.
bar_pct: f64,
/// Position value, compact USD, e.g. `$3.3B`.
value: String,
}
/// Everything the symbol page's ETF fund-profile section needs.
#[derive(Serialize)]
struct FundView {
/// A physical-commodity grantor trust (GLD, SLV): holds bullion, not a
/// securities portfolio, so it has no holdings and no asset mix.
is_commodity: bool,
/// Net assets / AUM, compact USD. `None` when the fund reported none.
net_assets: Option<String>,
holdings_count: Option<i64>,
/// The N-PORT "as of" date, `YYYY-MM-DD`.
report_date: Option<String>,
asset_mix: Vec<AssetSlice>,
/// N-PORT issuer-category mix (Phase 28). Empty / single-bucket on an
/// equity ETF where everything rolls up to one bucket — the template
/// hides the panel in that case rather than rendering a flat bar.
sector_mix: Vec<AssetSlice>,
/// N-PORT issuer-country mix (Phase 28). Empty / US-only on a domestic
/// ETF; hidden by the template the same way.
geography_mix: Vec<AssetSlice>,
holdings: Vec<HoldingView>,
}
/// Parse a stored mix JSON column into the page's `AssetSlice` row shape.
/// Phase 28 calls this for asset / sector / geography mixes alike.
fn parse_mix(json: Option<&str>) -> Vec<AssetSlice> {
json.and_then(|j| serde_json::from_str::<Vec<(String, f64)>>(j).ok())
.unwrap_or_default()
.into_iter()
.map(|(label, pct)| AssetSlice {
pct: format!("{pct:.1}%"),
width: pct.clamp(0.0, 100.0),
label,
})
.collect()
}
/// Assemble the fund view from a stored profile row and its holdings.
fn build_fund(profile: FundProfileRow, holdings: Vec<HoldingRow>) -> FundView {
let asset_mix = parse_mix(profile.asset_mix.as_deref());
let sector_mix = parse_mix(profile.sector_mix.as_deref());
let geography_mix = parse_mix(profile.geography_mix.as_deref());
// Holdings: each weight bar is scaled to the largest holding shown, so the
// top position fills the rail and the rest read against it.
let max_pct = holdings.iter().filter_map(|h| h.pct).fold(0.0_f64, f64::max);
let holdings = holdings
.into_iter()
.map(|h| HoldingView {
rank: h.rank,
name: h.name,
weight: h.pct.map_or_else(|| DASH.to_string(), |p| format!("{p:.2}%")),
bar_pct: match h.pct {
Some(p) if max_pct > 0.0 => (p / max_pct * 100.0).clamp(0.0, 100.0),
_ => 0.0,
},
value: h.value_usd.map_or_else(|| DASH.to_string(), fmt_usd_compact),
})
.collect();
FundView {
is_commodity: profile.kind == "commodity_trust",
net_assets: profile.net_assets.map(fmt_usd_compact),
holdings_count: profile.holdings_count,
report_date: profile.report_date,
asset_mix,
sector_mix,
geography_mix,
holdings,
}
}
// ── ETF fund metadata + trailing returns (Phase 28) ────────────────────────
/// A `fund_metadata` row as stored.
#[derive(sqlx::FromRow)]
struct FundMetadataRow {
expense_ratio: Option<f64>,
yield_pct: Option<f64>,
trailing_yield_pct: Option<f64>,
nav_price: Option<f64>,
inception_date: Option<String>,
category: Option<String>,
fund_family: Option<String>,
strategy_summary: Option<String>,
/// When the daily `fund_nav` job last refreshed `nav_price` (Phase 4). The
/// quality read's tracking factor is only graded against a fresh NAV; a
/// stale one drops the factor rather than asserting a bogus premium.
nav_synced_at: Option<i64>,
}
/// The "About this fund" section of the ETF symbol page. Every field is
/// pre-formatted, so the template stays declarative; an unpopulated field
/// becomes `—` rather than a hole in the layout.
#[derive(Serialize)]
struct FundMetaView {
expense_ratio: String,
yield_pct: String,
nav_price: Option<f64>,
/// Pre-formatted premium / discount, e.g. `+0.12%`, with a good/ok/bad
/// `Grade` so the template can colour-band it. `None` when no NAV.
premium: Option<PremiumView>,
inception_date: Option<String>,
category: Option<String>,
fund_family: Option<String>,
strategy_summary: Option<String>,
}
#[derive(Serialize)]
struct PremiumView {
/// Signed pre-formatted percent, e.g. `+0.12%` / `-0.45%`.
text: String,
/// Grade for the semantic colour band: Good (tight), Ok, Bad (wide).
grade: compute::Grade,
}
fn build_fund_meta(row: FundMetadataRow, price: Option<f64>) -> FundMetaView {
let pct = |v: Option<f64>, dp: usize| -> String {
v.map_or_else(|| DASH.to_string(), |x| format!("{:.*}%", dp, x * 100.0))
};
// Premium / discount: live price against the latest NAV, but only when that
// NAV is fresh (struck within NAV_FRESH_MS). Comparing a live price to a
// days-old NAV yields a meaningless premium, so a stale NAV drops the line to
// `None` rather than show drift as a premium — the same gate the ETF quality
// read's tracking factor uses, so the two never disagree. Live price falls
// back to the daily close when no quote yet, just as the ratio cards do.
let nav_fresh = row
.nav_synced_at
.is_some_and(|t| now_ms() - t <= NAV_FRESH_MS);
let premium = price
.filter(|_| nav_fresh)
.and_then(|p| compute::premium_discount_pct(p, row.nav_price))
.map(|pct| PremiumView {
text: format!("{:+.2}%", pct),
grade: compute::premium_grade(pct),
});
FundMetaView {
expense_ratio: pct(row.expense_ratio, 2),
yield_pct: pct(row.yield_pct.or(row.trailing_yield_pct), 2),
nav_price: row.nav_price,
premium,
inception_date: row.inception_date,
category: row.category,
fund_family: row.fund_family,
strategy_summary: row.strategy_summary,
}
}
/// One row of the trailing-returns table, pre-formatted.
#[derive(Serialize)]
struct ReturnRow {
label: &'static str,
/// Cumulative percent move, e.g. `+18.27%`. `—` when missing.
pct: String,
/// Annualised percent for periods over 1 year, blank `""` for the YTD /
/// 1m / 3m rows (where annualising is misleading) and `—` when missing.
annualised: String,
/// Whether `pct` is positive (green), negative (red), or unknown (none).
dir: i8,
}
fn fmt_pct(v: Option<f64>) -> (String, i8) {
match v {
Some(v) => {
let dir = if v > 0.0 { 1 } else if v < 0.0 { -1 } else { 0 };
(format!("{:+.2}%", v), dir)
}
None => (DASH.to_string(), 0),
}
}
fn build_returns(r: &compute::TrailingReturns) -> Vec<ReturnRow> {
let row = |label: &'static str, tr: Option<compute::TrailingReturn>, annualised: bool| {
let (pct, dir) = fmt_pct(tr.map(|t| t.pct));
let annualised = if annualised {
match tr {
Some(t) => format!("{:+.2}%", t.annualised_pct),
None => DASH.to_string(),
}
} else {
String::new()
};
ReturnRow {
label,
pct,
annualised,
dir,
}
};
vec![
row("1 month", r.m1, false),
row("3 months", r.m3, false),
row("Year to date", r.ytd, false),
row("1 year", r.y1, false),
row("3 years", r.y3, true),
row("5 years", r.y5, true),
row("10 years", r.y10, true),
row("Since inception", r.since_inception, true),
]
}
// ── dividend payouts (Phase 26) ────────────────────────────────────────────
/// One dividend payment, shaped for the page.
#[derive(Serialize)]
struct DividendRow {
/// Ex-dividend date, `YYYY-MM-DD` (the template's `shortdate` filter
/// formats it for display).
ex_date: String,
/// Per-share amount, formatted as plain dollars, e.g. `$0.24`.
amount: String,
}
/// Everything the symbol page's Dividends section needs.
#[derive(Serialize)]
struct DividendsView {
/// Whether the Yahoo dividend sweep has reached this stock yet — picks the
/// "not synced yet" pending note apart from a genuine no-dividends history.
synced: bool,
/// The inferred pace read: cadence, prior-year and YTD totals, projection,
/// and the on-track grade.
pace: compute::DividendPace,
/// Prior-year total per share, formatted, e.g. `$0.92`. Empty string when
/// there were no payouts in the prior calendar year.
prior_year_display: String,
/// YTD total per share, formatted.
ytd_display: String,
/// Calendar year YTD belongs to (e.g. `2026`).
current_year: i32,
/// Projected current-year total, formatted; `None` when the projection is.
projection_display: Option<String>,
/// Signed percent change vs prior year, e.g. `+4.3%`; `None` when the
/// projection is.
pct_change_display: Option<String>,
/// All payouts on file, newest first.
history: Vec<DividendRow>,
}
/// Load the Dividends section for a stock. Returns `None`
/// when there is nothing to show *and* the sweep has already run: a stock that
/// pays no dividend gets no section. A pending stock (sweep has not reached it
/// yet) still returns a `DividendsView` so the template can render the "not
/// synced yet" note in place.
async fn build_dividends(
pool: &sqlx::SqlitePool,
ticker: &str,
synced: bool,
) -> Option<DividendsView> {
// Newest first for the per-event history; the pace math wants oldest first.
let rows: Vec<(String, f64)> = sqlx::query_as(
"SELECT ex_date, amount FROM dividends WHERE ticker = ? ORDER BY ex_date DESC",
)
.bind(ticker)
.fetch_all(pool)
.await
.unwrap_or_default();
// Pending sweep on a stock with no payouts yet — show the pending note.
if rows.is_empty() && !synced {
let pace = compute::dividend_pace(&[], chrono::Utc::now().date_naive());
return Some(DividendsView {
synced: false,
pace,
prior_year_display: String::new(),
ytd_display: String::new(),
current_year: chrono::Utc::now().date_naive().year(),
projection_display: None,
pct_change_display: None,
history: Vec::new(),
});
}
// A swept stock with no payouts pays no dividend — hide the section
// entirely rather than render a heading over an empty table.
if rows.is_empty() {
return None;
}
let oldest_first: Vec<(String, f64)> = rows.iter().rev().cloned().collect();
let pace = compute::dividend_pace(&oldest_first, chrono::Utc::now().date_naive());
// Per-share dividends are usually quoted to the cent; monthly REITs sometimes
// pay sub-cent amounts (e.g. `$0.0625`), so a sub-cent figure widens to 4dp.
let fmt_div = |v: f64| if v < 0.01 { format!("${v:.4}") } else { format!("${v:.2}") };
let history: Vec<DividendRow> = rows
.iter()
.map(|(d, a)| DividendRow {
ex_date: d.clone(),
amount: fmt_div(*a),
})
.collect();
// Totals and the projection are annual sums of those per-share amounts;
// keep the same precision rule so a small payout's effect is not rounded off.
let fmt_money = fmt_div;
Some(DividendsView {
synced,
prior_year_display: fmt_money(pace.prior_year_total),
ytd_display: fmt_money(pace.ytd_total),
projection_display: pace.projection.map(fmt_money),
pct_change_display: pace.pct_change.map(|p| format!("{p:+.1}%")),
current_year: chrono::Utc::now().date_naive().year(),
history,
pace,
})
}
// ── company leadership (Phase 14) ──────────────────────────────────────────
/// A `leadership` row as stored.
#[derive(sqlx::FromRow)]
struct LeadershipRow {
name: String,
is_director: i64,
is_officer: i64,
officer_title: Option<String>,
}
/// One person on the leadership roster, shaped for the page.
#[derive(Serialize)]
struct LeaderView {
/// Display name, title-cased from the as-filed upper-case form.
name: String,
/// Role line, e.g. `Chief Executive Officer · Director`.
role: String,
/// Sort key only: officers ahead of directors, chiefs first. `serde(skip)`
/// keeps it out of the template context.
#[serde(skip)]
rank: u8,
}
/// One 8-K item-5.02 leadership-change event, shaped for the page.
#[derive(Serialize)]
struct ChangeView {
filed_at: String,
url: String,
}
/// Everything the symbol page's Leadership section needs.
#[derive(Serialize)]
struct LeadershipView {
/// Whether the SEC leadership sweep has reached this stock yet — picks the
/// "not synced yet" pending note apart from a genuine empty roster.
synced: bool,
roster: Vec<LeaderView>,
/// Recent officer/director changes, newest first.
changes: Vec<ChangeView>,
}
/// Title-case a name filed in SEC's upper-case form: `COOK TIMOTHY D` ->
/// `Cook Timothy D`. The first letter of each word, and of each part after an
/// apostrophe or hyphen, is capitalized — so `O'BRIEN` reads `O'Brien`. The
/// order is left as filed (last name first); reordering it is unreliable for
/// compound surnames and generational suffixes.
fn title_case(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut cap_next = true;
for ch in s.chars() {
if ch.is_whitespace() || ch == '\'' || ch == '-' {
out.push(ch);
cap_next = true;
} else if cap_next {
out.extend(ch.to_uppercase());
cap_next = false;
} else {
out.extend(ch.to_lowercase());
}
}
out
}
/// Sort rank for the roster: officers ahead of directors, with the chief
/// executive / financial / operating officers ahead of the other officers.
/// Both the spelled-out titles and the abbreviations are matched, since filers
/// use either (`Chief Executive Officer` or `CEO and Chairman`).
fn role_rank(is_director: bool, is_officer: bool, title: Option<&str>) -> u8 {
let t = title.unwrap_or("").to_lowercase();
let has = |needles: &[&str]| needles.iter().any(|n| t.contains(n));
if has(&["chief executive", "ceo"]) {
0
} else if has(&["chief financial", "cfo"]) {
1
} else if has(&["chief operating", "coo"]) {
2
} else if is_officer {
3
} else if is_director {
4
} else {
5
}
}
/// The role line for a roster row: the officer title (when the filer gave one)
/// and `Director`, joined — e.g. `Chief Financial Officer · Director`.
fn role_text(is_director: bool, is_officer: bool, title: Option<&str>) -> String {
let mut parts: Vec<String> = Vec::new();
match title.map(str::trim).filter(|t| !t.is_empty()) {
Some(t) => parts.push(t.to_string()),
None if is_officer => parts.push("Officer".to_string()),
None => {}
}
if is_director {
parts.push("Director".to_string());
}
if parts.is_empty() {
parts.push("Insider".to_string());
}
parts.join(" \u{00b7} ")
}
/// Load the Leadership section for a stock: the current officer/board roster
/// and the recent 8-K item-5.02 change events. The roster is filtered to
/// insiders seen filing within the recency window, so people who left long ago
/// drop off (ownership filings carry no explicit departure signal).
async fn build_leadership(pool: &sqlx::SqlitePool, ticker: &str, synced: bool) -> LeadershipView {
// ~18 months: long enough that an annually-filing director still shows,
// short enough that a departed insider ages out.
let cutoff = (chrono::Utc::now().date_naive() - chrono::Duration::days(550)).to_string();
let rows: Vec<LeadershipRow> = sqlx::query_as(
"SELECT name, is_director, is_officer, officer_title FROM leadership \
WHERE ticker = ? AND last_seen >= ?",
)
.bind(ticker)
.bind(&cutoff)
.fetch_all(pool)
.await
.unwrap_or_default();
let mut roster: Vec<LeaderView> = rows
.into_iter()
.map(|r| {
let (is_dir, is_off) = (r.is_director != 0, r.is_officer != 0);
LeaderView {
rank: role_rank(is_dir, is_off, r.officer_title.as_deref()),
role: role_text(is_dir, is_off, r.officer_title.as_deref()),
name: title_case(&r.name),
}
})
.collect();
roster.sort_by(|a, b| a.rank.cmp(&b.rank).then_with(|| a.name.cmp(&b.name)));
let changes: Vec<ChangeView> = sqlx::query_as::<_, (String, String)>(
"SELECT filed_at, url FROM filings \
WHERE ticker = ? AND form LIKE '8-K%' AND items LIKE '%5.02%' \
ORDER BY filed_at DESC, accession DESC LIMIT 8",
)
.bind(ticker)
.fetch_all(pool)
.await
.unwrap_or_default()
.into_iter()
.map(|(filed_at, url)| ChangeView { filed_at, url })
.collect();
LeadershipView {
synced,
roster,
changes,
}
}
// ── earnings dates (Phase 25) ─────────────────────────────────────────────
/// One past earnings date shaped for the page.
#[derive(Serialize)]
struct PastEarningsRow {
/// `YYYY-MM-DD`; the template's `shortdate` filter formats it.
date: String,
/// Days from today; positive for past dates.
days_ago: i64,
}
/// Everything the symbol-page Earnings section needs. Stocks only — every
/// caller gates the build on `kind == "stock"`.
#[derive(Serialize)]
struct EarningsView {
/// Most recent past earnings date (`YYYY-MM-DD`), with a days-ago figure.
most_recent: Option<PastEarningsRow>,
/// Next-expected earnings date (`YYYY-MM-DD`) and days-from-today.
next_date: Option<String>,
next_days: Option<i64>,
/// Where the next date came from: `yahoo` (authoritative), `estimate`
/// (cadence projection), or `unknown` (Yahoo has no date and we cannot
/// estimate one — too few priors).
next_source: &'static str,
/// The last few past earnings dates, newest first. Capped to 4 (one
/// trailing year of a quarterly cadence) per the design pass.
past: Vec<PastEarningsRow>,
/// All past earnings dates surfaced to the chart as ink pips above
/// each matching candle. Kept here so the route's history API can
/// echo them into the chart payload.
chart_dates: Vec<String>,
/// When this stock's earnings-calendar sync last ran, for the section
/// caption. NULL when Yahoo has never been hit for this stock; the page
/// then shows the past dates and the cadence estimate without the "as of"
/// line so it does not lie about a sync that did not happen.
earnings_synced_at: Option<i64>,
}
/// How many past earnings dates to list on the page. Four covers one trailing
/// year of a quarterly cadence; the chart pips show all of them up to the
/// chart's visible range.
const EARNINGS_PAST_LIMIT: usize = 4;
/// Load past earnings dates from `filings.items LIKE '%2.02%'` (Phase 14
/// stored 8-K item codes). Newest first; capped to a generous window so a
/// company that moved its reporting day still produces a clean median.
async fn load_past_earnings(pool: &sqlx::SqlitePool, ticker: &str) -> Vec<String> {
sqlx::query_scalar(
"SELECT filed_at FROM filings \
WHERE ticker = ? AND form LIKE '8-K%' AND items LIKE '%2.02%' \
ORDER BY filed_at DESC, accession DESC LIMIT 16",
)
.bind(ticker)
.fetch_all(pool)
.await
.unwrap_or_default()
}
/// Build the Earnings section for a stock. Returns `None` when SEC has not
/// synced yet (no past dates to anchor the section) and Yahoo also carries
/// no next date — the section is hidden cleanly in that case.
async fn build_earnings(
pool: &sqlx::SqlitePool,
ticker: &str,
next_earnings_at: Option<i64>,
earnings_synced_at: Option<i64>,
) -> Option<EarningsView> {
let past_dates = load_past_earnings(pool, ticker).await;
if past_dates.is_empty() && next_earnings_at.is_none() {
return None;
}
let today = chrono::Utc::now().date_naive();
let days_between = |d: &str| -> Option<i64> {
chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.ok()
.map(|nd| (nd - today).num_days())
};
let most_recent = past_dates.first().and_then(|d| {
days_between(d).map(|gap| PastEarningsRow {
date: d.clone(),
days_ago: -gap, // gap is negative for past dates; flip to days-ago.
})
});
// Resolve the next date: Yahoo primary, cadence-estimate fallback.
let (next_date, next_source) = match next_earnings_at {
Some(ts) => {
let date = chrono::DateTime::from_timestamp_millis(ts)
.map(|dt| dt.naive_utc().date().format("%Y-%m-%d").to_string());
(date, "yahoo")
}
None => {
let date_refs: Vec<&str> = past_dates.iter().map(String::as_str).collect();
match compute::next_earnings_estimate(&date_refs) {
Some(d) => (Some(d), "estimate"),
None => (None, "unknown"),
}
}
};
let next_days = next_date.as_deref().and_then(days_between);
let past: Vec<PastEarningsRow> = past_dates
.iter()
.take(EARNINGS_PAST_LIMIT)
.filter_map(|d| {
days_between(d).map(|gap| PastEarningsRow {
date: d.clone(),
days_ago: -gap,
})
})
.collect();
Some(EarningsView {
most_recent,
next_date,
next_days,
next_source,
past,
chart_dates: past_dates,
earnings_synced_at,
})
}
// ── per-ticker anomaly feed (Phase 16) ────────────────────────────────────
/// One row in the anomaly feed, as shaped for the template. Wraps
/// `compute::AnomalyEvent` with no extra fields — re-exposed so the template
/// can iterate a single concrete type regardless of which compute helper
/// (or the leadership-filings SELECT below) produced the row.
type AnomalyRow = compute::AnomalyEvent;
#[derive(Serialize)]
struct AnomalyView {
events: Vec<AnomalyRow>,
}
/// Display cap on the merged feed. Severity-rank-then-newest the four
/// streams together, then trim to this many before rendering.
const ANOMALY_MAX_EVENTS: usize = 20;
/// How far back the feed reaches.
const ANOMALY_WINDOW_DAYS: i64 = 365;
/// Build the symbol-page anomaly feed: large price moves and new 6-month
/// lows for every symbol with a daily history; YoY fundamentals jumps and
/// 8-K item-5.02 leadership changes additionally for stocks. The feed is
/// trimmed to the past year and capped at [`ANOMALY_MAX_EVENTS`]. Returns
/// `None` when no events qualify, so the template hides the section.
async fn build_anomalies(
pool: &sqlx::SqlitePool,
ticker: &str,
kind: &str,
bars_newest_first: &[(String, f64, f64, f64, f64, i64)],
facts: &[models::FundFact],
) -> Option<AnomalyView> {
let today = chrono::Utc::now().date_naive();
let cutoff_date = today - chrono::Duration::days(ANOMALY_WINDOW_DAYS);
let cutoff = cutoff_date.format("%Y-%m-%d").to_string();
// Price + drawdown events want oldest-first closes paired with dates.
let oldest_first: Vec<(String, f64)> = bars_newest_first
.iter()
.rev()
.map(|(d, _, _, _, c, _)| (d.clone(), *c))
.collect();
let closes: Vec<f64> = oldest_first.iter().map(|(_, c)| *c).collect();
let dates_refs: Vec<&str> = oldest_first.iter().map(|(d, _)| d.as_str()).collect();
let mut events: Vec<AnomalyRow> = Vec::new();
events.extend(compute::price_anomalies(&closes, &dates_refs));
events.extend(compute::drawdown_anomalies(&closes, &dates_refs));
// Fundamentals events and leadership events are stocks-only.
if kind == "stock" {
events.extend(models::fundamentals_anomalies(facts));
let lead_rows: Vec<(String, String)> = sqlx::query_as(
"SELECT filed_at, url FROM filings \
WHERE ticker = ? AND form LIKE '8-K%' AND items LIKE '%5.02%' \
AND filed_at >= ? \
ORDER BY filed_at DESC, accession DESC",
)
.bind(ticker)
.bind(&cutoff)
.fetch_all(pool)
.await
.unwrap_or_default();
for (filed_at, url) in lead_rows {
events.push(AnomalyRow {
date: filed_at,
glyph: "leader",
polarity: "neutral",
headline: "Officer or director change reported in an 8-K".to_string(),
url: Some(url),
// Hand-picked: above a typical 5-8% one-day move so a leadership
// change is not crowded off the list, below a major drawdown.
severity: 7.5,
});
}
}
// Trim to the past year window.
events.retain(|e| e.date.as_str() >= cutoff.as_str());
if events.is_empty() {
return None;
}
// Newest first; ties broken by severity so the bigger event of the same
// day reads first. Then cap.
events.sort_by(|a, b| {
b.date
.cmp(&a.date)
.then_with(|| b.severity.partial_cmp(&a.severity).unwrap_or(std::cmp::Ordering::Equal))
});
events.truncate(ANOMALY_MAX_EVENTS);
Some(AnomalyView { events })
}
async fn symbol_page(Path(ticker): Path<String>, State(state): State<AppState>) -> Response {
let ticker = ticker.to_uppercase();
let symbol = sqlx::query_as::<_, SymbolRow>("SELECT * FROM symbols WHERE ticker = ?")
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let Some(symbol) = symbol else {
return not_found(&state);
};
// The latest stored live quote, if the symbol has ever been quoted. The
// header prefers it over the last daily close.
let quote = sqlx::query_as::<_, QuoteRow>(
"SELECT price, prev_close, fetched_at FROM quotes WHERE ticker = ?",
)
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.map(|q| {
let change = q.prev_close.map(|p| compute::change(q.price, p));
HeaderQuote {
price: q.price,
change_abs: change.map(|c| c.abs),
change_pct: change.map(|c| c.pct),
state_label: quote_state_label(q.fetched_at).to_string(),
}
});
// Most recent ~1.5 years of daily bars, newest first.
let bars: Vec<(String, f64, f64, f64, f64, i64)> = sqlx::query_as(
"SELECT d, open, high, low, close, volume FROM daily_prices \
WHERE ticker = ? ORDER BY d DESC LIMIT 400",
)
.bind(&ticker)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let stats = bars.first().map(|latest| {
let (date, open, high, low, close, volume) = latest.clone();
let prev_close = bars.get(1).map(|b| b.4);
let change = prev_close.map(|p| compute::change(close, p));
// 52-week range from the most recent ~252 trading days.
let window = &bars[..bars.len().min(252)];
let high_52w = window.iter().map(|b| b.2).fold(f64::NEG_INFINITY, f64::max);
let low_52w = window.iter().map(|b| b.3).fold(f64::INFINITY, f64::min);
// Average daily volume over the recent ~3-month window (65 sessions).
let vol_window = &bars[..bars.len().min(65)];
let avg_volume =
vol_window.iter().map(|b| b.5).sum::<i64>() / vol_window.len().max(1) as i64;
let vol_ratio = (avg_volume > 0).then(|| volume as f64 / avg_volume as f64);
Stats {
date,
open,
high,
low,
close,
volume,
prev_close,
change_abs: change.map(|c| c.abs),
change_pct: change.map(|c| c.pct),
open_change_pct: prev_close.map(|p| compute::change(open, p).pct),
high_52w,
low_52w,
avg_volume,
day_open_pos: compute::pos(open, low, high),
day_close_pos: compute::pos(close, low, high),
yr_close_pos: compute::pos(close, low_52w, high_52w),
yr_prev_pos: prev_close.map(|p| compute::pos(p, low_52w, high_52w)),
// Cap the bar at 2x the average so an outlier session stays on-rail.
vol_fill_pct: vol_ratio.map_or(0.0, |r| (r / 2.0 * 100.0).clamp(0.0, 100.0)),
vol_ratio,
}
});
// Fundamentals are stocks-only; an ETF gets a fund profile instead; an
// index gets neither. Filings cover both stocks and ETFs.
let is_stock = symbol.kind == "stock";
let is_etf = symbol.kind == "etf";
// Ratios price off the live quote, falling back to the last daily close.
let price = quote
.as_ref()
.map(|q| q.price)
.or_else(|| stats.as_ref().map(|s| s.close));
// Plain-language read of the chart's indicators (RSI verdict + price vs each
// moving average), shown beneath the chart. Built from the daily closes
// (oldest first) against the current price; `None` without enough history.
let indicators = price.and_then(|p| {
// `bars` is newest-first; the indicator maths want oldest-first.
let highs: Vec<f64> = bars.iter().rev().map(|b| b.2).collect();
let lows: Vec<f64> = bars.iter().rev().map(|b| b.3).collect();
let closes: Vec<f64> = bars.iter().rev().map(|b| b.4).collect();
build_indicator_read(&highs, &lows, &closes, p, symbol.kind != "index")
});
// Stock fundamentals are loaded once and shared by the ratio cards
// (`build_fundamentals`) and the anomaly feed's YoY detector
// (`build_anomalies` via `models::fundamentals_anomalies`).
let facts: Vec<models::FundFact> = if is_stock {
sqlx::query_as(
"SELECT metric, period, fiscal_year, fiscal_qtr, value, period_end \
FROM fundamentals WHERE ticker = ?",
)
.bind(&ticker)
.fetch_all(&state.pool)
.await
.unwrap_or_default()
} else {
Vec::new()
};
let fundamentals = if is_stock {
build_fundamentals(&facts, price)
} else {
None
};
// The overall strong / fair / weak standing (Phase 20): the ratios above
// rolled up, with the daily-close trajectory folded into its score. Shown
// as a single badge over the ratio cards. `bars` is newest-first, so it is
// reversed into an oldest-first close series.
let closes_oldest_first: Vec<f64> = if is_stock {
bars.iter().rev().map(|b| b.4).collect()
} else {
Vec::new()
};
let standing = fundamentals
.as_ref()
.and_then(|f| compute::standing(&f.ratios, &closes_oldest_first));
// The stock health read (Phase 17): the ratios + trajectory of the
// standing above, plus a leadership-stability signal read off the recent
// 8-K item-5.02 change count from Phase 14. `None` until the leadership
// sweep has reached this stock; the composite then drops that component
// cleanly instead of penalising an unsynced stock. Stocks only.
let leadership_changes_recent: Option<usize> = if is_stock
&& symbol.leadership_synced_at.is_some()
{
let cutoff = (chrono::Utc::now().date_naive()
- chrono::Duration::days(compute::LEADERSHIP_STABILITY_DAYS))
.to_string();
sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM filings \
WHERE ticker = ? AND form LIKE '8-K%' AND items LIKE '%5.02%' \
AND filed_at >= ?",
)
.bind(&ticker)
.bind(&cutoff)
.fetch_one(&state.pool)
.await
.ok()
.map(|n| n.max(0) as usize)
} else {
None
};
let health = fundamentals.as_ref().and_then(|f| {
compute::health_read(&f.ratios, &closes_oldest_first, leadership_changes_recent)
});
let filings: Vec<FilingView> = if is_stock || is_etf {
sqlx::query_as::<_, FilingRow>(
"SELECT form, filed_at, period_of_report, url FROM filings \
WHERE ticker = ? ORDER BY filed_at DESC, accession DESC LIMIT 18",
)
.bind(&ticker)
.fetch_all(&state.pool)
.await
.unwrap_or_default()
.into_iter()
.map(|r| FilingView {
title: filing_title(&r.form),
form: r.form,
filed_at: r.filed_at,
period_of_report: r.period_of_report,
url: r.url,
})
.collect()
} else {
Vec::new()
};
// Raw ETF figures captured as the fund / metadata blocks build them, then
// rolled into the Phase 4 quality read below. Kept as scalars so the read
// can be computed once both SEC (profile/holdings) and Yahoo (metadata)
// sources are loaded, without re-querying.
let mut etf_net_assets: Option<f64> = None;
let mut etf_top10_pct: Option<f64> = None;
let mut etf_expense_ratio: Option<f64> = None;
let mut etf_nav: Option<f64> = None;
let mut etf_nav_synced_at: Option<i64> = None;
// The ETF fund profile, when the SEC sweep has reached this symbol.
let fund = if is_etf {
let profile = sqlx::query_as::<_, FundProfileRow>(
"SELECT kind, net_assets, holdings_count, report_date, \
asset_mix, sector_mix, geography_mix \
FROM fund_profiles WHERE ticker = ?",
)
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
match profile {
Some(profile) => {
let holdings = sqlx::query_as::<_, HoldingRow>(
"SELECT rank, name, pct, value_usd FROM fund_holdings \
WHERE ticker = ? ORDER BY rank",
)
.bind(&ticker)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
etf_net_assets = profile.net_assets;
// Top-10 concentration for the diversification factor: the
// summed weight of the ten largest holdings (rows are rank-
// ordered, `pct` already in percent units). `None` when the
// fund reported no holdings (a commodity trust), so that factor
// drops out of the blend rather than reading as zero.
let top10: f64 = holdings.iter().take(10).filter_map(|h| h.pct).sum();
etf_top10_pct = (top10 > 0.0).then_some(top10);
Some(build_fund(profile, holdings))
}
None => None,
}
} else {
None
};
// ETF fund metadata + trailing returns (Phase 28). Both keyed by the
// same `is_etf` gate; the fund_metadata row exists once the new Yahoo
// job has swept this symbol. An unswept ETF shows the section's
// "pending" note in the template.
let fund_meta = if is_etf {
let row = sqlx::query_as::<_, FundMetadataRow>(
"SELECT expense_ratio, yield_pct, trailing_yield_pct, nav_price, \
inception_date, category, fund_family, strategy_summary, \
nav_synced_at \
FROM fund_metadata WHERE ticker = ?",
)
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
match row {
Some(row) => {
etf_expense_ratio = row.expense_ratio;
etf_nav = row.nav_price;
etf_nav_synced_at = row.nav_synced_at;
Some(build_fund_meta(row, price))
}
None => None,
}
} else {
None
};
// Phase 4 — ETF quality read: cost-weighted blend of cost, tracking (price
// vs NAV premium), diversification (top-10 concentration), and size (AUM).
// Mirrors the stock health donut. `None` until ≥2 factors grade, so a fund
// the sweeps have barely reached gets no badge rather than a hollow one.
let etf_quality = if is_etf {
// Only read a price-vs-NAV premium (the tracking factor) against a
// *fresh* NAV: NAV is struck daily, so a stale one makes the premium
// meaningless. We let the factor drop out rather than assert a bogus
// tracking verdict. NAV is re-fetched on demand when an ETF page is
// viewed and stale; when it is behind (fresh deploy, guard tripped),
// tracking simply reads "—". The freshness window is shared with the
// "About this fund" premium line (see NAV_FRESH_MS) so they agree.
let nav_fresh =
etf_nav_synced_at.is_some_and(|t| crate::db::now_ms() - t <= NAV_FRESH_MS);
let premium_pct = if nav_fresh {
price.and_then(|p| compute::premium_discount_pct(p, etf_nav))
} else {
None
};
compute::etf_quality(etf_expense_ratio, premium_pct, etf_top10_pct, etf_net_assets)
} else {
None
};
// Trailing returns reach back as far as the fund's daily history goes
// (since inception, ten years, ...), so they pull the *full* series for
// this symbol rather than the 400-bar window the chart's key stats use.
// ETFs only.
let returns = if is_etf {
let full: Vec<(String, f64)> = sqlx::query_as(
"SELECT d, close FROM daily_prices WHERE ticker = ? ORDER BY d ASC",
)
.bind(&ticker)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
if full.len() >= 2 {
let dated: Vec<compute::DatedClose<'_>> = full
.iter()
.map(|(d, c)| compute::DatedClose {
date: d,
close: *c,
})
.collect();
let today = chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string();
Some(build_returns(&compute::trailing_returns(&dated, &today)))
} else {
None
}
} else {
None
};
// The leadership roster + change feed (Phase 14): stocks only, like the
// fundamentals above.
let leadership = if is_stock {
Some(build_leadership(&state.pool, &ticker, symbol.leadership_synced_at.is_some()).await)
} else {
None
};
// Dividend / distribution payouts (Phase 26 + Phase 28): now covers
// stocks AND ETFs. Indexes and futures have no concept and get no
// section; an unswept symbol shows a pending note in place; a swept one
// with no payouts in the past five years hides the section.
let dividends = if is_stock || is_etf {
build_dividends(&state.pool, &ticker, symbol.dividends_synced_at.is_some()).await
} else {
None
};
// Per-ticker anomaly feed (Phase 16). All instruments get price-based
// events (large daily moves, new 6-month lows); stocks additionally get
// YoY fundamentals jumps and 8-K item-5.02 leadership changes. Returns
// `None` when the symbol has no qualifying events in the past year so
// the template hides the section.
let anomalies = build_anomalies(&state.pool, &ticker, &symbol.kind, &bars, &facts).await;
// Earnings dates (Phase 25). Stocks only; the past dates ride for free
// off the existing 8-K item-2.02 filings (Phase 14 stored the `items`
// column), the next date is either Yahoo's `calendarEvents` or a cadence
// estimate from those past dates. The chart pips also read off the past
// dates carried in `earnings.chart_dates`.
let earnings = if is_stock {
build_earnings(
&state.pool,
&ticker,
symbol.next_earnings_at,
symbol.earnings_synced_at,
)
.await
} else {
None
};
// Dormant / delisted signal: the most recent daily bar is well in the past.
// Yahoo serves a frozen chart for a dead ticker, so without this the page
// would look live. `None` for a normally-trading symbol.
let stale_data = symbol.history_last_date.as_deref().and_then(|d| {
let last = chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").ok()?;
let days = (chrono::Utc::now().date_naive() - last).num_days();
(days > STALE_DATA_DAYS).then(|| StaleData {
last_date: d.to_string(),
days,
})
});
let extra = minijinja::context! {
title => ticker,
symbol => symbol,
stale_data => stale_data,
stats => stats,
indicators => indicators,
quote => quote,
fundamentals => fundamentals,
standing => standing,
health => health,
fund => fund,
fund_meta => fund_meta,
etf_quality => etf_quality,
returns => returns,
leadership => leadership,
dividends => dividends,
anomalies => anomalies,
earnings => earnings,
filings => filings,
};
render(&state, "pages/symbol.html", &format!("/s/{ticker}"), extra)
}
#[derive(Deserialize)]
struct HistoryQuery {
range: Option<String>,
}
/// A bar's time on the chart axis. Daily bars are calendar dates
/// (`YYYY-MM-DD`); intraday bars (the 1D / 1W ranges) are UNIX seconds, which
/// is the other form lightweight-charts accepts for an intraday time scale.
/// `#[serde(untagged)]` so each variant serialises as a bare string or number
/// — no tag wrapper the chart would have to unpick.
#[derive(Serialize)]
#[serde(untagged)]
enum BarTime {
Date(String),
Unix(i64),
}
/// One OHLCV point shaped for lightweight-charts.
#[derive(Serialize)]
struct Candle {
time: BarTime,
open: f64,
high: f64,
low: f64,
close: f64,
volume: i64,
}
/// One point of a derived overlay/indicator series, shaped for
/// lightweight-charts (`time` is `YYYY-MM-DD`). Sparse: bars with no value
/// yet (an average's warm-up period) are simply omitted.
#[derive(Serialize)]
struct LinePoint {
time: String,
value: f64,
}
/// One earnings-date marker for the chart. `YYYY-MM-DD` (matches the
/// candle `time` field), so the client can index it against the candle
/// series directly.
#[derive(Serialize)]
struct EarningsMarker {
time: String,
}
/// One Supertrend point for the chart: the band value plus whether the trend is
/// up, so the client can split the single line into a green (uptrend) and a red
/// (downtrend) series with a clean break at each flip.
#[derive(Serialize)]
struct SuperTrendPoint {
time: String,
value: f64,
up: bool,
}
/// The symbol chart payload (Phase 8 + Phase 28): the candles for the
/// selected range plus the indicator overlays, each already trimmed to the
/// visible window. Phase 28 adds an optional benchmark series — the
/// curated index a fund tracks, normalised to the same start point as the
/// fund's first visible close — rendered as a relative-performance line
/// only when the symbol has a `symbols.benchmark` configured.
#[derive(Serialize)]
struct HistoryResponse {
candles: Vec<Candle>,
sma50: Vec<LinePoint>,
sma200: Vec<LinePoint>,
ema21: Vec<LinePoint>,
rsi14: Vec<LinePoint>,
/// Supertrend band (ATR 10 / 3×). Each point carries its trend side so the
/// client draws it green below price in an uptrend, red above in a
/// downtrend. Empty on the intraday ranges (a daily-only overlay).
#[serde(skip_serializing_if = "Vec::is_empty")]
supertrend: Vec<SuperTrendPoint>,
/// Benchmark closes scaled to the same starting price as the visible
/// candles, so the two lines start together and drift apart on relative
/// performance. Empty when no benchmark is configured or no benchmark
/// history overlaps the range.
#[serde(skip_serializing_if = "Vec::is_empty")]
benchmark: Vec<LinePoint>,
/// Curated benchmark ticker label for the chart legend (e.g. `^SPX`).
/// Absent when no benchmark is configured.
#[serde(skip_serializing_if = "Option::is_none")]
benchmark_ticker: Option<String>,
/// Past earnings-date markers for the chart (Phase 25). Each is a
/// `YYYY-MM-DD` matching one of the visible candles; the client draws
/// a small ink dot above each matching bar. Stocks only; empty otherwise.
#[serde(skip_serializing_if = "Vec::is_empty")]
earnings: Vec<EarningsMarker>,
/// The prior daily close (Phase 6). Carried only for the intraday ranges,
/// where the chart draws it as a dashed reference line so the day's move is
/// legible against where the symbol opened the session.
#[serde(skip_serializing_if = "Option::is_none")]
prev_close: Option<f64>,
/// True when `candles` carry intraday (UNIX-seconds) times rather than
/// daily dates (Phase 6). The chart switches its axis/labels accordingly
/// and suppresses the daily-only overlays.
intraday: bool,
}
/// Earliest `YYYY-MM-DD` to *show* for a range button. `None` means no limit.
fn range_cutoff(range: &str) -> Option<String> {
let today = chrono::Utc::now().date_naive();
match range {
"MAX" => None,
"YTD" => Some(format!("{:04}-01-01", today.year())),
"1M" => Some((today - chrono::Duration::days(31)).to_string()),
"3M" => Some((today - chrono::Duration::days(93)).to_string()),
"6M" => Some((today - chrono::Duration::days(186)).to_string()),
"3Y" => Some((today - chrono::Duration::days(1098)).to_string()),
"5Y" => Some((today - chrono::Duration::days(1830)).to_string()),
// 1Y is the default for any unrecognised value.
_ => Some((today - chrono::Duration::days(366)).to_string()),
}
}
/// Trading days of history the longest indicator (the 200-day average) needs
/// before the visible window, expressed as calendar days with comfortable
/// slack for weekends and holidays.
const INDICATOR_LOOKBACK_DAYS: i64 = 320;
async fn history_api(
Path(ticker): Path<String>,
Query(q): Query<HistoryQuery>,
State(state): State<AppState>,
) -> Response {
let ticker = ticker.to_uppercase();
let range = q.range.unwrap_or_else(|| "1Y".to_string());
// The 1D / 1W ranges (Phase 6) draw today's real-time 15-minute bars on an
// intraday axis, live-ticked by the quote stream — a different data source
// (`intraday_bars`) and time format from the daily candles, so they take
// their own path and the daily indicator machinery never runs for them.
if range == "1D" || range == "1W" {
return intraday_history(&state.pool, &ticker, &range).await;
}
let display_cutoff = range_cutoff(&range);
// Indicators need history *before* the visible window or their first
// values would be blank — a 200-day average needs 200 prior bars. Fetch a
// fixed lookback before the range cutoff, compute over the whole set, then
// trim every series back to the visible window.
let fetch_cutoff = display_cutoff.as_deref().map(|c| {
chrono::NaiveDate::parse_from_str(c, "%Y-%m-%d")
.map(|d| (d - chrono::Duration::days(INDICATOR_LOOKBACK_DAYS)).to_string())
.unwrap_or_else(|_| c.to_string())
});
// Daily rows as tuples (date, OHLCV). The indicator maths and the benchmark
// overlay key off the date strings, so they are kept in a parallel `dates`
// vec and the `Candle`s (whose `time` is now the `BarTime` enum) are built
// from the same rows at the end.
let rows: Vec<(String, f64, f64, f64, f64, i64)> = match &fetch_cutoff {
Some(cutoff) => {
sqlx::query_as(
"SELECT d, open, high, low, close, volume FROM daily_prices \
WHERE ticker = ? AND d >= ? ORDER BY d ASC",
)
.bind(&ticker)
.bind(cutoff)
.fetch_all(&state.pool)
.await
}
None => {
sqlx::query_as(
"SELECT d, open, high, low, close, volume FROM daily_prices \
WHERE ticker = ? ORDER BY d ASC",
)
.bind(&ticker)
.fetch_all(&state.pool)
.await
}
}
.unwrap_or_default();
let dates: Vec<String> = rows.iter().map(|r| r.0.clone()).collect();
let highs: Vec<f64> = rows.iter().map(|r| r.2).collect();
let lows: Vec<f64> = rows.iter().map(|r| r.3).collect();
let closes: Vec<f64> = rows.iter().map(|r| r.4).collect();
// First bar inside the visible window; everything before it is lookback,
// fetched only so the indicators are correct from the very first shown bar.
let start = match &display_cutoff {
Some(c) => dates
.iter()
.position(|d| d.as_str() >= c.as_str())
.unwrap_or(dates.len()),
None => 0,
};
// Zip a raw indicator series to its bar dates, dropping warm-up `None`s
// and the lookback bars, leaving only points inside the visible window.
let line = |series: Vec<Option<f64>>| -> Vec<LinePoint> {
series
.into_iter()
.enumerate()
.skip(start)
.filter_map(|(i, v)| {
v.map(|value| LinePoint {
time: dates[i].clone(),
value,
})
})
.collect()
};
// Benchmark overlay (Phase 28). Loaded only when the symbol has a
// curated `symbols.benchmark` set, and only when the visible range
// actually has anchor bars to scale against. The benchmark series is
// pinned to the fund's first visible close so the two lines start
// together and drift apart on relative performance.
let benchmark_ticker: Option<String> = sqlx::query_scalar(
"SELECT benchmark FROM symbols WHERE ticker = ?",
)
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.flatten();
let benchmark = match (&benchmark_ticker, dates.get(start)) {
(Some(bench), Some(_)) => {
load_benchmark_series(&state.pool, bench, &dates[start..], closes[start]).await
}
_ => Vec::new(),
};
// Earnings-date pips (Phase 25). Stocks only; each pip is dated to an
// 8-K item-2.02 filing date (Phase 14 already stored those in
// `filings.items`). The chart maps each `time` to its matching candle.
let kind: Option<String> = sqlx::query_scalar("SELECT kind FROM symbols WHERE ticker = ?")
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let earnings = if kind.as_deref() == Some("stock") {
let dates = load_past_earnings(&state.pool, &ticker).await;
dates
.into_iter()
.map(|time| EarningsMarker { time })
.collect()
} else {
Vec::new()
};
// Supertrend, trimmed to the visible window the same way as the lines but
// keeping each bar's trend side so the client can colour it.
let supertrend: Vec<SuperTrendPoint> =
compute::supertrend(&highs, &lows, &closes, compute::SUPERTREND_PERIOD, compute::SUPERTREND_MULT)
.into_iter()
.enumerate()
.skip(start)
.filter_map(|(i, p)| {
p.map(|p| SuperTrendPoint {
time: dates[i].clone(),
value: p.value,
up: p.up,
})
})
.collect();
let resp = HistoryResponse {
sma50: line(compute::sma(&closes, 50)),
sma200: line(compute::sma(&closes, 200)),
ema21: line(compute::ema(&closes, 21)),
rsi14: line(compute::rsi(&closes, 14)),
supertrend,
candles: rows
.into_iter()
.skip(start)
.map(|(d, open, high, low, close, volume)| Candle {
time: BarTime::Date(d),
open,
high,
low,
close,
volume,
})
.collect(),
benchmark,
benchmark_ticker,
earnings,
prev_close: None,
intraday: false,
};
Json(resp).into_response()
}
/// Calendar days of intraday history the 1W range shows. `intraday_bars` is
/// pruned to a 14-day window (see `INTRADAY_RETENTION_DAYS`), so a week sits
/// comfortably inside what is stored.
const INTRADAY_WEEK_DAYS: i64 = 7;
/// Serve the 1D / 1W intraday ranges (Phase 6) from `intraday_bars`. The bars
/// are stored as UTC epoch-*milliseconds*; lightweight-charts wants UNIX
/// *seconds* for an intraday axis, so each `ts` is divided by 1000. 1D shows
/// the most recent trading day present (so a weekend correctly shows Friday);
/// 1W shows a rolling seven days. None of the daily-only overlays apply, so the
/// indicator series come back empty and the chart hides their toggles.
async fn intraday_history(pool: &sqlx::SqlitePool, ticker: &str, range: &str) -> Response {
use chrono::{TimeZone as _, Utc};
use chrono_tz::America::New_York;
let now_ms = chrono::Utc::now().timestamp_millis();
let cutoff_ms: i64 = if range == "1W" {
now_ms - INTRADAY_WEEK_DAYS * 86_400_000
} else {
// 1D: the New-York midnight that opens the most recent day with bars,
// so the view is exactly that session (and weekends show Friday).
let latest: Option<i64> =
sqlx::query_scalar("SELECT MAX(ts) FROM intraday_bars WHERE ticker = ?")
.bind(ticker)
.fetch_optional(pool)
.await
.ok()
.flatten();
match latest {
Some(ms) => Utc
.timestamp_millis_opt(ms)
.single()
.map(|dt| {
let day = dt.with_timezone(&New_York).date_naive();
New_York
.from_local_datetime(&day.and_hms_opt(0, 0, 0).unwrap())
.single()
.map(|midnight| midnight.timestamp_millis())
.unwrap_or(ms)
})
.unwrap_or(now_ms),
None => now_ms,
}
};
let rows: Vec<(i64, f64, f64, f64, f64, i64)> = sqlx::query_as(
"SELECT ts, open, high, low, close, volume FROM intraday_bars \
WHERE ticker = ? AND ts >= ? ORDER BY ts ASC",
)
.bind(ticker)
.bind(cutoff_ms)
.fetch_all(pool)
.await
.unwrap_or_default();
let candles = rows
.into_iter()
.map(|(ts, open, high, low, close, volume)| Candle {
time: BarTime::Unix(ts / 1000),
open,
high,
low,
close,
volume,
})
.collect();
// The prior daily close anchors the session reference line. During a live
// session today's bar is not yet in `daily_prices`, so the most recent row
// is genuinely the previous close.
let prev_close: Option<f64> =
sqlx::query_scalar("SELECT close FROM daily_prices WHERE ticker = ? ORDER BY d DESC LIMIT 1")
.bind(ticker)
.fetch_optional(pool)
.await
.ok()
.flatten();
let resp = HistoryResponse {
candles,
sma50: Vec::new(),
sma200: Vec::new(),
ema21: Vec::new(),
rsi14: Vec::new(),
supertrend: Vec::new(),
benchmark: Vec::new(),
benchmark_ticker: None,
earnings: Vec::new(),
prev_close,
intraday: true,
};
Json(resp).into_response()
}
/// Load a benchmark index's daily closes across the same date span as
/// `visible_dates` (the fund's visible candle dates), then scale each close so the
/// series starts at `fund_anchor` — the fund's first visible close — and
/// only the *relative* movement past that point is plotted. An empty
/// benchmark history or no overlap returns an empty vec, which the
/// `skip_serializing_if` on the response field then drops cleanly.
async fn load_benchmark_series(
pool: &sqlx::SqlitePool,
benchmark: &str,
visible_dates: &[String],
fund_anchor: f64,
) -> Vec<LinePoint> {
let Some(first) = visible_dates.first() else {
return Vec::new();
};
let last = visible_dates.last().unwrap_or(first);
let rows: Vec<(String, f64)> = sqlx::query_as(
"SELECT d, close FROM daily_prices \
WHERE ticker = ? AND d >= ? AND d <= ? ORDER BY d ASC",
)
.bind(benchmark)
.bind(first)
.bind(last)
.fetch_all(pool)
.await
.unwrap_or_default();
if rows.len() < 2 {
return Vec::new();
}
let bench_anchor = rows[0].1;
if bench_anchor <= 0.0 {
return Vec::new();
}
let scale = fund_anchor / bench_anchor;
rows.into_iter()
.map(|(d, c)| LinePoint {
time: d,
value: c * scale,
})
.collect()
}
// ── ETF growth-of-$10k chart (Phase 28) ────────────────────────────────────
/// Response body for `GET /api/symbols/{ticker}/growth`. Two series scaled
/// so the first point of each reads as $10,000, drawn together so a fund's
/// since-inception path can be eyeballed against its benchmark's.
#[derive(Serialize)]
struct GrowthResponse {
/// Fund growth series, oldest first.
fund: Vec<compute::GrowthPoint>,
/// Benchmark growth series across the same date span, anchored
/// separately to $10,000 at its own first bar. Empty when the fund has
/// no curated benchmark or no benchmark history overlaps.
#[serde(skip_serializing_if = "Vec::is_empty")]
benchmark: Vec<compute::GrowthPoint>,
#[serde(skip_serializing_if = "Option::is_none")]
benchmark_ticker: Option<String>,
}
/// Build the growth-of-$10k series over the longest available daily history
/// for `ticker`. ETF symbol page only; on a symbol with no daily history
/// (e.g. a future) the series is empty and the panel hides itself.
async fn growth_api(Path(ticker): Path<String>, State(state): State<AppState>) -> Response {
let ticker = ticker.to_uppercase();
let rows: Vec<(String, f64)> = sqlx::query_as(
"SELECT d, close FROM daily_prices WHERE ticker = ? ORDER BY d ASC",
)
.bind(&ticker)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let bars: Vec<compute::DatedClose<'_>> = rows
.iter()
.map(|(d, c)| compute::DatedClose {
date: d.as_str(),
close: *c,
})
.collect();
let fund = compute::growth_of_10k(&bars);
let (benchmark_ticker, benchmark) = match (
sqlx::query_scalar::<_, Option<String>>("SELECT benchmark FROM symbols WHERE ticker = ?")
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten()
.flatten(),
fund.first(),
) {
(Some(bench), Some(first)) => {
// Anchor benchmark to the same first-bar date as the fund, so the
// two lines start together; benchmark history before the fund's
// inception is ignored.
let bench_rows: Vec<(String, f64)> = sqlx::query_as(
"SELECT d, close FROM daily_prices \
WHERE ticker = ? AND d >= ? ORDER BY d ASC",
)
.bind(&bench)
.bind(&first.date)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let bench_bars: Vec<compute::DatedClose<'_>> = bench_rows
.iter()
.map(|(d, c)| compute::DatedClose {
date: d.as_str(),
close: *c,
})
.collect();
(Some(bench), compute::growth_of_10k(&bench_bars))
}
_ => (None, Vec::new()),
};
Json(GrowthResponse {
fund,
benchmark,
benchmark_ticker,
})
.into_response()
}
// ── add a symbol to the universe (Phase 9) ─────────────────────────────────
/// `POST /api/symbols` request body.
#[derive(Deserialize)]
struct AddSymbolBody {
ticker: String,
}
/// `POST /api/symbols` response. `ok` is the success flag the Search page's
/// script keys on; `error` carries a human message on a failure.
#[derive(Serialize)]
struct AddSymbolResponse {
ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
ticker: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<String>,
/// True when this call created the symbol; false when it already existed.
added: bool,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
/// Normalise and validate a user-supplied ticker. Accepts uppercase letters,
/// digits and `. - ^ =` (covering `BRK.B`, `^SPX`, the Yahoo future `CL=F` and
/// the like); rejects the empty string, an over-long one, an unexpected
/// character, or one carrying no alphanumeric. Returns the normalised
/// (trimmed, uppercased) form.
///
/// `pub(crate)` so the Search page offers an "Add" affordance for exactly the
/// strings this endpoint would accept.
pub(crate) fn valid_ticker(raw: &str) -> Option<String> {
let t = raw.trim().to_uppercase();
if t.is_empty() || t.len() > 15 {
return None;
}
let charset_ok = t
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '^' | '='));
let has_alnum = t.chars().any(|c| c.is_ascii_alphanumeric());
(charset_ok && has_alnum).then_some(t)
}
/// A failed `POST /api/symbols` response with a status and a human message.
fn add_err(status: StatusCode, msg: impl Into<String>) -> Response {
(
status,
Json(AddSymbolResponse {
ok: false,
ticker: None,
name: None,
kind: None,
added: false,
error: Some(msg.into()),
}),
)
.into_response()
}
/// `POST /api/symbols` — add a symbol to the tracked universe.
///
/// The Search page calls this when a query names a ticker the universe does
/// not hold yet. The ticker is validated against Yahoo — one request that also
/// yields its name, kind, exchange and currency — then the symbol row is
/// inserted, the quote that same lookup returned is stored, and the symbol's
/// full backfill (deep daily history and all SEC data) is pulled synchronously
/// before the response, so its page is complete the moment the add returns
/// (see `scheduler::backfill_symbol`). Every outbound call
/// goes through the shared endpoint guard (see the anti-spam policy).
/// The outcome of ensuring a symbol is in the tracked universe.
pub(crate) struct EnsureOutcome {
pub ticker: String,
pub name: String,
pub kind: String,
/// True when this call created the symbol; false when it already existed.
pub added: bool,
}
/// Ensure `ticker` is a tracked symbol, adding it to the universe if missing.
///
/// Idempotent: an already-tracked symbol returns at once. A new one is validated
/// against Yahoo (one guarded lookup that also yields its name / kind / exchange
/// / currency), inserted as a user-added (`is_seeded = 0`) row, has the lookup's
/// quote + bars stored, and its full backfill (deep history + SEC data) pulled
/// synchronously, so its page is complete on return. Errors come back as a
/// (status, message) pair the caller surfaces. Shared by `POST /api/symbols`
/// (the Search "Add") and the dashboard watchlist add.
pub(crate) async fn ensure_symbol(
state: &AppState,
raw_ticker: &str,
) -> Result<EnsureOutcome, (StatusCode, String)> {
let Some(ticker) = valid_ticker(raw_ticker) else {
return Err((
StatusCode::BAD_REQUEST,
"That does not look like a ticker symbol.".into(),
));
};
// Already tracked? Idempotent — report it so the caller can just navigate.
let existing: Option<(String, String)> =
sqlx::query_as("SELECT name, kind FROM symbols WHERE ticker = ?")
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
if let Some((name, kind)) = existing {
return Ok(EnsureOutcome { ticker, name, kind, added: false });
}
// One guarded Yahoo lookup: validates the symbol and describes it.
let yahoo = YahooProvider::new(http::build_client(&state.config));
let guard = EndpointGuard::with_budget(state.pool.clone(), "yahoo", scheduler::YAHOO_BUDGET);
match guard.acquire().await {
Ok(Permit::Granted) => {}
Ok(Permit::Denied(_)) => {
return Err((
StatusCode::SERVICE_UNAVAILABLE,
"The market data source is busy right now. Try again in a few minutes.".into(),
));
}
Err(e) => {
tracing::error!("ensure_symbol guard for {ticker}: {e:#}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong. Try again shortly.".into(),
));
}
}
let (info, data) = match yahoo.lookup(&ticker).await {
Err(e) => {
let _ = guard.record_failure(&e).await;
tracing::warn!("ensure_symbol lookup {ticker}: {e:#}");
return Err((
StatusCode::BAD_GATEWAY,
"Could not reach the market data source. Try again shortly.".into(),
));
}
Ok(outcome) => {
// The endpoint answered — even an "unknown symbol" is a healthy
// reply, so the guard records a success either way.
let _ = guard.record_success().await;
match outcome {
SymbolLookup::Found { info, data } => (info, data),
SymbolLookup::Unknown => {
return Err((
StatusCode::NOT_FOUND,
format!("No symbol called {ticker} was found."),
));
}
SymbolLookup::Unsupported(raw_kind) => {
let what = raw_kind.to_lowercase();
return Err((StatusCode::UNPROCESSABLE_ENTITY, format!(
"{ticker} is a {what}. Only stocks, ETFs, indexes, and futures can be added right now."
)));
}
}
}
};
// Insert the symbol. User-added, so `is_seeded = 0`.
let now = now_ms();
let inserted = sqlx::query(
"INSERT INTO symbols (ticker, name, kind, exchange, currency, is_seeded, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, 0, ?, ?) ON CONFLICT(ticker) DO NOTHING",
)
.bind(&ticker)
.bind(&info.name)
.bind(&info.kind)
.bind(&info.exchange)
.bind(&info.currency)
.bind(now)
.bind(now)
.execute(&state.pool)
.await;
if let Err(e) = inserted {
tracing::error!("ensure_symbol insert {ticker}: {e:#}");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"Could not save the symbol. Try again shortly.".into(),
));
}
// Store the quote (and bars) the lookup already paid for, then pull the full
// backfill before returning, so the symbol's page is complete at once.
if let Err(e) = scheduler::store_quote(&state.pool, &ticker, &data.quote).await {
tracing::warn!("ensure_symbol store_quote {ticker}: {e:#}");
}
if !data.bars.is_empty() {
if let Err(e) = scheduler::store_intraday(&state.pool, &ticker, &data.bars).await {
tracing::warn!("ensure_symbol store_intraday {ticker}: {e:#}");
}
}
scheduler::backfill_symbol(&state.pool, &state.config, &ticker, &info.kind).await;
tracing::info!("ensure_symbol: added {ticker} ({}, {})", info.name, info.kind);
Ok(EnsureOutcome { ticker, name: info.name, kind: info.kind, added: true })
}
/// `POST /api/symbols` — add a symbol to the tracked universe (the Search "Add").
async fn add_symbol(State(state): State<AppState>, Json(body): Json<AddSymbolBody>) -> Response {
match ensure_symbol(&state, &body.ticker).await {
Ok(o) => Json(AddSymbolResponse {
ok: true,
ticker: Some(o.ticker),
name: Some(o.name),
kind: Some(o.kind),
added: o.added,
error: None,
})
.into_response(),
Err((status, msg)) => add_err(status, msg),
}
}
#[derive(Deserialize)]
struct RefreshQuery {
/// `1` = the manual Refresh button: re-pull every source regardless of
/// staleness. Default `0` = a page load: pull the live price always, the
/// slow SEC / metadata sources only when their stored copy is stale.
#[serde(default)]
force: u8,
}
/// `GET /api/symbols/{ticker}/refresh` — Server-Sent Events driving the symbol
/// page's loading bar. Asks the scheduler which steps to run for this symbol
/// (kind + staleness + `force`), then runs each in turn, emitting a `step`
/// event before and after so the bar advances and names what it is doing. A
/// final `done` event tells the page whether a deep (server-rendered) section
/// changed — if so it reloads to show it; otherwise the live price was already
/// patched in place over the stream and no reload is needed.
async fn refresh_stream(
Path(ticker): Path<String>,
Query(q): Query<RefreshQuery>,
State(state): State<AppState>,
) -> Response {
let ticker = ticker.to_uppercase();
let force = q.force != 0;
let kind: Option<String> = sqlx::query_scalar("SELECT kind FROM symbols WHERE ticker = ?")
.bind(&ticker)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
let Some(kind) = kind else {
return not_found(&state);
};
let steps = scheduler::refresh_plan(&state.pool, &state.config, &ticker, &kind, force).await;
let any_deep = steps.iter().any(|s| s.deep);
let body = async_stream::stream! {
let total = steps.len();
// Announce the plan up front so the bar can size itself.
yield sse_json("plan", &format!("{{\"total\":{total}}}"));
for (i, st) in steps.iter().enumerate() {
yield sse_json(
"step",
&format!(
"{{\"i\":{},\"n\":{},\"label\":{},\"state\":\"running\"}}",
i + 1, total, json_str(st.label)
),
);
let status =
scheduler::refresh_step(&state.pool, &state.config, &state.hub, &ticker, &kind, st.key)
.await;
yield sse_json(
"step",
&format!(
"{{\"i\":{},\"n\":{},\"label\":{},\"state\":{}}}",
i + 1, total, json_str(st.label), json_str(status)
),
);
}
yield sse_json("done", &format!("{{\"reload\":{}}}", any_deep));
};
Sse::new(body)
.keep_alive(axum::response::sse::KeepAlive::default())
.into_response()
}
/// Build a named SSE event with a raw JSON `data` payload.
fn sse_json(event: &str, data: &str) -> Result<axum::response::sse::Event, std::convert::Infallible> {
Ok(axum::response::sse::Event::default().event(event).data(data))
}
/// Minimal JSON string escaper for the short, known labels/statuses streamed
/// above (no control characters in play; just quote the value safely).
fn json_str(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn q(period_end: &str, value: f64) -> models::FundFact {
models::FundFact {
metric: "eps_diluted".to_string(),
period: format!("Q-{period_end}"),
fiscal_year: 2024,
fiscal_qtr: Some(1),
value,
period_end: period_end.to_string(),
}
}
#[test]
fn ttm_eps_sums_four_consecutive_quarters() {
let facts = vec![
q("2024-12-31", 1.0),
q("2024-09-30", 0.9),
q("2024-06-30", 0.8),
q("2024-03-31", 0.7),
];
let ttm = ttm_eps_diluted(&facts).expect("four consecutive quarters → a TTM");
assert!((ttm.0 - 3.4).abs() < 1e-9);
assert_eq!(ttm.1, "2024-12-31"); // labelled through the newest quarter
}
#[test]
fn ttm_eps_rejects_a_gap() {
// A missing quarter (jump from 2024-06-30 back to 2023-09-30) spans more
// than a year, so it must NOT be summed as a trailing twelve months.
let facts = vec![
q("2024-12-31", 1.0),
q("2024-09-30", 0.9),
q("2024-06-30", 0.8),
q("2023-09-30", 0.6),
];
assert!(ttm_eps_diluted(&facts).is_none(), "a non-consecutive run is not a TTM");
}
#[test]
fn ttm_eps_needs_four_quarters() {
let facts = vec![q("2024-12-31", 1.0), q("2024-09-30", 0.9)];
assert!(ttm_eps_diluted(&facts).is_none());
}
}