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

9.5 KB · 297 lines · Rust Raw History
  1use chrono::Datelike;
  2use minijinja::value::Value;
  3use minijinja::{path_loader, AutoEscape, Environment, Error, Output, State};
  4use serde::Serialize;
  5use serde_json::Value as JsonValue;
  6use std::path::Path;
  7
  8/// Jinja2-faithful HTML formatter. Does NOT escape `/`, so vite asset URLs
  9/// like `/static/base-abc123.js` come through clean instead of `/...`.
 10fn jinja2_html_formatter(out: &mut Output, state: &State, value: &Value) -> Result<(), Error> {
 11    if value.is_safe() {
 12        write!(out, "{value}").map_err(Error::from)?;
 13        return Ok(());
 14    }
 15    let auto_escape = match state.auto_escape() {
 16        AutoEscape::Html => true,
 17        AutoEscape::None => false,
 18        _ => return minijinja::escape_formatter(out, state, value),
 19    };
 20    if !auto_escape {
 21        write!(out, "{value}").map_err(Error::from)?;
 22        return Ok(());
 23    }
 24    if let Some(s) = value.as_str() {
 25        write_jinja2_html(out, s).map_err(Error::from)?;
 26    } else if value.is_undefined() || value.is_none() {
 27        // emit nothing
 28    } else {
 29        let stringified = value.to_string();
 30        write_jinja2_html(out, &stringified).map_err(Error::from)?;
 31    }
 32    Ok(())
 33}
 34
 35fn write_jinja2_html(out: &mut Output, s: &str) -> std::fmt::Result {
 36    let mut last = 0;
 37    for (i, b) in s.bytes().enumerate() {
 38        let escape = match b {
 39            b'&' => "&amp;",
 40            b'<' => "&lt;",
 41            b'>' => "&gt;",
 42            b'"' => "&#34;",
 43            b'\'' => "&#39;",
 44            _ => continue,
 45        };
 46        if last < i {
 47            out.write_str(&s[last..i])?;
 48        }
 49        out.write_str(escape)?;
 50        last = i + 1;
 51    }
 52    if last < s.len() {
 53        out.write_str(&s[last..])?;
 54    }
 55    Ok(())
 56}
 57
 58#[derive(Debug, Clone, Serialize)]
 59pub struct RequestCtx {
 60    pub path: String,
 61}
 62
 63fn read_manifest(path: &Path) -> JsonValue {
 64    let text = std::fs::read_to_string(path).unwrap_or_else(|_| "{}".to_string());
 65    serde_json::from_str(&text).unwrap_or(JsonValue::Null)
 66}
 67
 68fn lookup_asset(manifest: &JsonValue, entry: &str, kind: &str) -> String {
 69    if let Some(chunk) = manifest.get(entry) {
 70        if kind == "css" {
 71            if let Some(css_arr) = chunk.get("css").and_then(|v| v.as_array()) {
 72                if let Some(first) = css_arr.first().and_then(|v| v.as_str()) {
 73                    return format!("/static/{first}");
 74                }
 75            }
 76        }
 77        if let Some(file) = chunk.get("file").and_then(|v| v.as_str()) {
 78            return format!("/static/{file}");
 79        }
 80    }
 81    format!("/static/{entry}")
 82}
 83
 84pub fn build_env(templates_dir: &Path, manifest_path: &Path) -> Environment<'static> {
 85    let mut env = Environment::new();
 86    env.set_loader(path_loader(templates_dir));
 87    env.set_formatter(jinja2_html_formatter);
 88
 89    // Resolve content-hashed Vite asset names. Re-read the manifest per call
 90    // in debug builds (Vite watch rewrites it); cache it once in release.
 91    #[cfg(debug_assertions)]
 92    {
 93        let path = manifest_path.to_path_buf();
 94        env.add_function(
 95            "vite_asset",
 96            move |entry: String, kind: Option<String>| -> Result<String, Error> {
 97                let kind = kind.unwrap_or_else(|| "file".to_string());
 98                Ok(lookup_asset(&read_manifest(&path), &entry, &kind))
 99            },
100        );
101    }
102    #[cfg(not(debug_assertions))]
103    {
104        let manifest = read_manifest(manifest_path);
105        env.add_function(
106            "vite_asset",
107            move |entry: String, kind: Option<String>| -> Result<String, Error> {
108                let kind = kind.unwrap_or_else(|| "file".to_string());
109                Ok(lookup_asset(&manifest, &entry, &kind))
110            },
111        );
112    }
113
114    env.add_filter("money", money_filter);
115    env.add_filter("signed", signed_filter);
116    env.add_filter("pct", pct_filter);
117    env.add_filter("compact", compact_filter);
118    env.add_filter("intcomma", intcomma_filter);
119    env.add_filter("ago", ago_filter);
120    env.add_filter("asof", asof_filter);
121    env.add_filter("shortdate", shortdate_filter);
122    env.add_filter("urlencode", urlencode_filter);
123
124    env
125}
126
127/// Best-effort numeric coercion. Returns None for undefined / null / non-numeric
128/// so filters can fall back to a placeholder.
129fn as_f64(v: &Value) -> Option<f64> {
130    if v.is_none() || v.is_undefined() {
131        return None;
132    }
133    if let Some(i) = v.as_i64() {
134        return Some(i as f64);
135    }
136    v.as_str()
137        .and_then(|s| s.parse().ok())
138        .or_else(|| v.to_string().parse().ok())
139}
140
141/// Format a number with thousands separators and `dp` decimal places.
142fn fmt_grouped(n: f64, dp: usize) -> String {
143    let neg = n.is_sign_negative() && n != 0.0;
144    let s = format!("{:.*}", dp, n.abs());
145    let (int, frac) = match s.split_once('.') {
146        Some((i, f)) => (i.to_string(), Some(f.to_string())),
147        None => (s, None),
148    };
149    let mut grouped = String::new();
150    for (i, ch) in int.chars().rev().enumerate() {
151        if i > 0 && i % 3 == 0 {
152            grouped.insert(0, ',');
153        }
154        grouped.insert(0, ch);
155    }
156    let mut out = String::new();
157    if neg {
158        out.push('-');
159    }
160    out.push_str(&grouped);
161    if let Some(f) = frac {
162        out.push('.');
163        out.push_str(&f);
164    }
165    out
166}
167
168/// Empty-value placeholder shown when a metric is missing — an em dash, an
169/// unambiguous "no data" mark (a middle dot read as a stray decimal point).
170const DASH: &str = "\u{2014}";
171
172/// `1234.5` -> `$1,234.50`
173fn money_filter(value: Value) -> Result<String, Error> {
174    match as_f64(&value) {
175        Some(n) => Ok(format!("${}", fmt_grouped(n, 2))),
176        None => Ok(DASH.to_string()),
177    }
178}
179
180/// `1.2` -> `+1.20`, `-1.2` -> `-1.20`. For absolute price changes.
181fn signed_filter(value: Value) -> Result<String, Error> {
182    match as_f64(&value) {
183        Some(n) => {
184            let sign = if n > 0.0 { "+" } else { "" };
185            Ok(format!("{sign}{}", fmt_grouped(n, 2)))
186        }
187        None => Ok(DASH.to_string()),
188    }
189}
190
191/// `1.234` -> `+1.23%`. For percentage changes.
192fn pct_filter(value: Value) -> Result<String, Error> {
193    match as_f64(&value) {
194        Some(n) => {
195            let sign = if n > 0.0 { "+" } else { "" };
196            Ok(format!("{sign}{:.2}%", n))
197        }
198        None => Ok(DASH.to_string()),
199    }
200}
201
202/// `1_530_000` -> `1.53M`. For volume and market cap.
203fn compact_filter(value: Value) -> Result<String, Error> {
204    let Some(n) = as_f64(&value) else {
205        return Ok(DASH.to_string());
206    };
207    let abs = n.abs();
208    let (scaled, suffix) = if abs >= 1e12 {
209        (n / 1e12, "T")
210    } else if abs >= 1e9 {
211        (n / 1e9, "B")
212    } else if abs >= 1e6 {
213        (n / 1e6, "M")
214    } else if abs >= 1e3 {
215        (n / 1e3, "K")
216    } else {
217        return Ok(fmt_grouped(n, 0));
218    };
219    Ok(format!("{scaled:.2}{suffix}"))
220}
221
222fn intcomma_filter(value: Value) -> Result<String, Error> {
223    match as_f64(&value) {
224        Some(n) => Ok(fmt_grouped(n, 0)),
225        None => Ok(DASH.to_string()),
226    }
227}
228
229/// Epoch-ms -> a short relative string like `4m ago`.
230fn ago_filter(value: Value) -> Result<String, Error> {
231    let Some(ms) = value.as_i64() else {
232        return Ok(DASH.to_string());
233    };
234    let secs = (chrono::Utc::now().timestamp_millis() - ms) / 1000;
235    Ok(if secs < 5 {
236        "just now".to_string()
237    } else if secs < 60 {
238        format!("{secs}s ago")
239    } else if secs < 3600 {
240        format!("{}m ago", secs / 60)
241    } else if secs < 86_400 {
242        format!("{}h ago", secs / 3600)
243    } else {
244        format!("{}d ago", secs / 86_400)
245    })
246}
247
248/// Epoch-ms -> an absolute "as of" anchor on the US market clock: a time of day
249/// when the moment falls on today's date (`2:14pm`), a month and day when it is
250/// earlier this year (`May 21`), else a full date (`May 21, 2024`). Unlike
251/// `ago` it never drifts on screen, so it suits a page-load section header.
252fn asof_filter(value: Value) -> Result<String, Error> {
253    let Some(ms) = value.as_i64() else {
254        return Ok(DASH.to_string());
255    };
256    let Some(instant) = chrono::DateTime::from_timestamp_millis(ms) else {
257        return Ok(DASH.to_string());
258    };
259    // Market data is wall-clock-anchored to the exchange (see market.rs), so a
260    // freshness time reads naturally in New York rather than UTC.
261    let et = instant.with_timezone(&chrono_tz::America::New_York);
262    let now = chrono::Utc::now().with_timezone(&chrono_tz::America::New_York);
263    Ok(if et.date_naive() == now.date_naive() {
264        // lowercase meridiem, no leading zero on the hour: "2:14pm".
265        et.format("%-I:%M%P").to_string()
266    } else if et.year() == now.year() {
267        et.format("%b %-d").to_string()
268    } else {
269        et.format("%b %-d, %Y").to_string()
270    })
271}
272
273/// A `YYYY-MM-DD` date string -> `May 21`, or `May 21, 2024` when the year is
274/// not the current one. For dated data: a last daily close, a holdings date, a
275/// filing date. A string that does not parse is passed through unchanged.
276fn shortdate_filter(value: Value) -> Result<String, Error> {
277    let Some(s) = value.as_str() else {
278        return Ok(DASH.to_string());
279    };
280    let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") else {
281        return Ok(s.to_string());
282    };
283    Ok(if date.year() == chrono::Utc::now().year() {
284        date.format("%b %-d").to_string()
285    } else {
286        date.format("%b %-d, %Y").to_string()
287    })
288}
289
290fn urlencode_filter(value: Value) -> Result<String, Error> {
291    let s = value
292        .as_str()
293        .map(|s| s.to_string())
294        .unwrap_or_else(|| value.to_string());
295    Ok(urlencoding::encode(&s).into_owned())
296}