use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const HTTP_TIMEOUT: Duration = Duration::from_secs(10);
const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
AppleWebKit/537.36 (KHTML, like Gecko) \
Chrome/126.0.0.0 Safari/537.36";
#[derive(Debug, Clone, PartialEq)]
pub struct Quote {
pub symbol: String,
pub price: f64,
pub previous_close: f64,
pub currency: Option<String>,
pub series: Vec<f64>,
pub delayed: bool,
}
impl Quote {
pub fn change(&self) -> f64 {
self.price - self.previous_close
}
pub fn change_pct(&self) -> f64 {
if self.close_is_zero() {
return 0.0;
}
(self.change() / self.previous_close.abs()) * 100.0
}
fn close_is_zero(&self) -> bool {
self.previous_close.abs() < f64::EPSILON
}
}
pub trait QuoteSource: Send {
fn name(&self) -> &'static str;
fn fetch(&self, symbol: &str) -> Result<Quote>;
}
pub const SOURCE_NAMES: &[&str] = &["yahoo"];
pub fn source_for(name: &str) -> Option<Box<dyn QuoteSource>> {
match name {
"yahoo" => Some(Box::new(YahooChart)),
_ => None,
}
}
pub struct YahooChart;
#[derive(Debug, Deserialize)]
struct ChartEnvelope {
chart: ChartBody,
}
#[derive(Debug, Deserialize)]
struct ChartBody {
#[serde(default)]
result: Vec<ChartResult>,
#[serde(default)]
error: Option<ChartError>,
}
#[derive(Debug, Deserialize)]
struct ChartError {
#[serde(default)]
description: Option<String>,
#[serde(default)]
code: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ChartResult {
meta: ChartMeta,
#[serde(default)]
indicators: Option<Indicators>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ChartMeta {
symbol: String,
#[serde(default)]
regular_market_price: Option<f64>,
#[serde(default)]
chart_previous_close: Option<f64>,
#[serde(default)]
previous_close: Option<f64>,
#[serde(default)]
currency: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Indicators {
#[serde(default)]
quote: Vec<QuoteSeries>,
}
#[derive(Debug, Deserialize)]
struct QuoteSeries {
#[serde(default)]
close: Vec<Option<f64>>,
}
const MAX_DETAIL: usize = 80;
pub fn parse_chart(body: &str) -> Result<Quote> {
let parsed: ChartEnvelope = serde_json::from_str(body).context("parsing the chart response")?;
if let Some(error) = parsed.chart.error {
let detail = error
.description
.or(error.code)
.unwrap_or_else(|| "unknown error".to_string());
let detail = crate::grid::truncate(&detail, MAX_DETAIL);
anyhow::bail!("the quote service rejected the symbol: {detail}");
}
let result = parsed
.chart
.result
.into_iter()
.next()
.context("the quote service returned no data for this symbol")?;
let price = result
.meta
.regular_market_price
.context("the response carried no price")?;
let previous_close = result
.meta
.chart_previous_close
.or(result.meta.previous_close)
.unwrap_or(price);
let series = result
.indicators
.and_then(|i| i.quote.into_iter().next())
.map(|q| bounded(q.close.into_iter().flatten().collect()))
.unwrap_or_default();
let quote = Quote {
symbol: result.meta.symbol,
price,
previous_close,
currency: result.meta.currency,
series,
delayed: false,
};
let unmeasurable = quote.close_is_zero() && quote.change().abs() >= f64::EPSILON;
anyhow::ensure!(
quote.change().is_finite() && quote.change_pct().is_finite() && !unmeasurable,
"numbers out of range in the response"
);
Ok(quote)
}
const MAX_SERIES: usize = 1_000;
fn bounded(series: Vec<f64>) -> Vec<f64> {
if series.len() <= MAX_SERIES {
return series;
}
bucket_means(&series, MAX_SERIES).collect()
}
impl QuoteSource for YahooChart {
fn name(&self) -> &'static str {
"yahoo"
}
fn fetch(&self, symbol: &str) -> Result<Quote> {
let url = format!(
"https://query1.finance.yahoo.com/v8/finance/chart/{}\
?range=1d&interval=5m",
crate::fetch::percent_encode(symbol)
);
let body = http_get(&url)?;
parse_chart(&body)
}
}
fn http_get(url: &str) -> Result<String> {
crate::fetch::get(url, HTTP_TIMEOUT, Some(BROWSER_UA)).map_err(explain)
}
fn explain(error: ureq::Error) -> anyhow::Error {
match error {
ureq::Error::StatusCode(429) => anyhow::anyhow!(
"rate-limited by Yahoo (HTTP 429), which limits by IP address; \
wait and it tries again, though datacenter and VPN addresses are \
refused outright"
),
ureq::Error::StatusCode(404) => {
anyhow::anyhow!("no such symbol")
}
ureq::Error::StatusCode(code) => {
anyhow::anyhow!("the quote service returned HTTP {code}")
}
other => anyhow::anyhow!("network request failed: {other}"),
}
}
#[derive(Debug)]
pub struct Watchlist {
path: PathBuf,
symbols: Vec<String>,
dirty: bool,
pub last_error: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct WatchlistFile<L = Vec<String>> {
#[serde(default)]
symbols: L,
}
const WATCHLIST_FILE: crate::store::TomlFile = crate::store::TomlFile {
what: "the watchlist",
header: "# mirador watchlist. Safe to edit by hand.\n\
# Only the symbols live here — prices are never written to disk.",
};
impl Watchlist {
pub fn load(path: impl Into<PathBuf>, seed: &[String]) -> Result<Self> {
let path = path.into();
let (symbols, dirty) = match WATCHLIST_FILE.read::<WatchlistFile>(&path)? {
Some(file) => (file.symbols, false),
None => (seed.to_vec(), !seed.is_empty()),
};
Ok(Self {
path,
symbols: symbols.into_iter().map(|s| normalise(&s)).collect(),
dirty,
last_error: None,
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn symbols(&self) -> &[String] {
&self.symbols
}
pub fn add(&mut self, symbol: &str) -> bool {
let symbol = normalise(symbol);
if symbol.is_empty() || self.symbols.contains(&symbol) {
return false;
}
self.symbols.push(symbol);
self.dirty = true;
true
}
pub fn remove(&mut self, symbol: &str) -> bool {
let symbol = normalise(symbol);
let before = self.symbols.len();
self.symbols.retain(|s| *s != symbol);
let removed = self.symbols.len() != before;
self.dirty |= removed;
removed
}
pub fn save(&mut self) -> Result<()> {
if !self.dirty {
return Ok(());
}
WATCHLIST_FILE.write(
&self.path,
&WatchlistFile {
symbols: self.symbols.as_slice(),
},
)?;
self.dirty = false;
Ok(())
}
pub fn save_reporting(&mut self) {
crate::store::report(self.save(), &mut self.last_error);
}
}
fn normalise(symbol: &str) -> String {
symbol.trim().to_uppercase()
}
const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
pub fn sparkline(series: &[f64], width: usize) -> String {
if width == 0 || series.is_empty() {
return String::new();
}
let low = series.iter().copied().fold(f64::INFINITY, f64::min);
let high = series.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let span = high - low;
bucket_means(series, width)
.map(|mean| {
if span.abs() < f64::EPSILON {
return BLOCKS[BLOCKS.len() / 2];
}
let ratio = (mean - low) / span;
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "ratio is clamped to 0..=1, so the product is a valid index"
)]
let index = (ratio * (BLOCKS.len() - 1) as f64).round() as usize;
BLOCKS[index.min(BLOCKS.len() - 1)]
})
.collect()
}
fn bucket_means(series: &[f64], n: usize) -> impl Iterator<Item = f64> + '_ {
let len = series.len();
(0..n).map(move |run| {
let start = run * len / n;
let end = (((run + 1) * len) / n).max(start + 1);
let bucket = &series[start.min(len - 1)..end.min(len)];
bucket.iter().sum::<f64>() / bucket.len() as f64
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::testing::TempDir;
#[test]
fn the_network_is_refused_while_testing() {
let err = YahooChart
.fetch("AAPL")
.expect_err("a live fetch must not be possible from a test");
assert!(
err.to_string().contains("under cfg(test)"),
"the refusal must come from fetch::get's cfg(test) guard, not from the wire: {err}"
);
}
const SAMPLE: &str = r#"{
"chart": {
"result": [{
"meta": {
"currency": "USD",
"symbol": "AAPL",
"regularMarketPrice": 213.5,
"chartPreviousClose": 211.0,
"previousClose": 210.0
},
"timestamp": [1, 2, 3, 4],
"indicators": {
"quote": [{ "close": [211.0, null, 212.5, 213.5] }]
}
}],
"error": null
}
}"#;
const SHOWN: Option<&str> = None;
const UNPARSED: Option<&str> = Some("parsing the chart response");
const OUT_OF_RANGE: Option<&str> = Some("numbers out of range");
const HOSTILE: &[(&str, &str, &str, &str, Option<&str>)] = &[
(
"a price too large for an f64",
"1e400",
"211.0",
"[211.0, 212.5]",
UNPARSED,
),
(
"a previous close too large",
"213.5",
"1e400",
"[211.0, 212.5]",
UNPARSED,
),
(
"a value in the series too large",
"1.0",
"1.0",
"[1e400, 1.0]",
UNPARSED,
),
(
"a tiny previous close",
"1e308",
"1e-10",
"[1.0]",
OUT_OF_RANGE,
),
(
"a change too large for an f64",
"1e308",
"-1e308",
"[1.0]",
OUT_OF_RANGE,
),
(
"a previous close of zero",
"213.5",
"0.0",
"[211.0, 212.5]",
OUT_OF_RANGE,
),
(
"a close below the divisor guard",
"1e308",
"1e-300",
"[1.0]",
OUT_OF_RANGE,
),
("nothing traded at all", "0.0", "0.0", "[]", SHOWN),
(
"a price beyond any market",
"1e308",
"211.0",
"[1.0]",
SHOWN,
),
(
"negative prices",
"-5.0",
"-10.0",
"[-1.0, -2.0, -3.0]",
SHOWN,
),
("an empty series", "1.0", "1.0", "[]", SHOWN),
("a flat series", "1.0", "1.0", "[7.0, 7.0, 7.0]", SHOWN),
];
#[test]
fn hostile_numbers_never_reach_the_screen_as_inf_or_nan() {
let swap = |price: &str, prev: &str, series: &str| {
SAMPLE
.replace("213.5,", &format!("{price},"))
.replace(
"\"chartPreviousClose\": 211.0",
&format!("\"chartPreviousClose\": {prev}"),
)
.replace("[211.0, null, 212.5, 213.5]", series)
};
for &(name, price, prev, series, refusal) in HOSTILE {
let parsed = parse_chart(&swap(price, prev, series));
if let Some(reason) = refusal {
let err = parsed.map_or_else(
|e| format!("{e:#}"),
|q| panic!("{name}: must be refused, got {q:?}"),
);
assert!(err.starts_with(reason), "{name}: `{reason}…`, got `{err}`");
continue;
}
let quote = parsed.unwrap_or_else(|e| panic!("{name}: must be shown, got {e:#}"));
assert!(quote.price.is_finite(), "{name}: price is {}", quote.price);
assert!(
quote.change().is_finite(),
"{name}: `{:+.2}` would read as a broken panel",
quote.change()
);
assert!(
quote.change_pct().is_finite(),
"{name}: `{}%` would read as a broken panel",
quote.change_pct()
);
let (change, pct) = (quote.change(), quote.change_pct());
assert!(
change * pct >= 0.0,
"{name}: `{change:+.2}` beside `{pct:+.2}%` contradicts itself"
);
for width in [0, 1, 8, 40] {
assert!(
sparkline("e.series, width).chars().count() <= width,
"{name}: sparkline overran {width} cells"
);
}
}
}
#[test]
fn a_chart_response_becomes_a_quote() {
let q = parse_chart(SAMPLE).unwrap();
assert_eq!(q.symbol, "AAPL");
assert!((q.price - 213.5).abs() < f64::EPSILON);
assert!(
(q.previous_close - 211.0).abs() < f64::EPSILON,
"chartPreviousClose wins: it matches the returned series"
);
assert_eq!(q.currency.as_deref(), Some("USD"));
}
#[test]
fn gaps_in_the_series_are_dropped_rather_than_read_as_zero() {
let q = parse_chart(SAMPLE).unwrap();
assert_eq!(
q.series,
vec![211.0, 212.5, 213.5],
"a null minute must not become a zero and floor the sparkline"
);
}
#[test]
fn the_day_change_is_measured_against_the_previous_close() {
let q = parse_chart(SAMPLE).unwrap();
assert!((q.change() - 2.5).abs() < 1e-9);
assert!((q.change_pct() - (2.5 / 211.0 * 100.0)).abs() < 1e-9);
}
#[test]
fn a_zero_previous_close_reports_no_change_rather_than_infinity() {
let q = Quote {
symbol: "NEW".into(),
price: 10.0,
previous_close: 0.0,
currency: None,
series: vec![],
delayed: false,
};
assert!(q.change_pct().is_finite(), "`inf%` reads as a broken panel");
assert!((q.change_pct() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn an_error_envelope_becomes_an_error_not_an_empty_quote() {
let body = r#"{"chart":{"result":[],"error":{"code":"Not Found",
"description":"No data found, symbol may be delisted"}}}"#;
let err = parse_chart(body).unwrap_err().to_string();
assert!(err.contains("delisted"), "got `{err}`");
}
#[test]
fn the_services_error_text_is_bounded_where_it_is_read() {
let huge = "symbol may be delisted ".repeat(50_000);
let body = format!(
r#"{{"chart":{{"result":[],"error":{{"code":"Not Found","description":"{huge}"}}}}}}"#
);
let err = format!("{:#}", parse_chart(&body).unwrap_err());
assert!(err.len() < 200, "a {}-byte error survived", err.len());
assert!(err.ends_with('\u{2026}'), "a cut reason says so: `{err}`");
}
#[test]
fn a_rate_limit_says_what_is_true() {
let message = format!("{:#}", explain(ureq::Error::StatusCode(429)));
if SOURCE_NAMES.len() == 1 {
assert!(
!message.contains("[stocks].source"),
"there is no other source to choose: `{message}`"
);
}
assert!(
crate::grid::truncate(&message, 30).contains("rate-limited by Yahoo"),
"the fact has to survive a narrow status row: `{message}`"
);
assert!(
message.contains("IP address"),
"say why waiting may not help: `{message}`"
);
}
#[test]
fn a_response_with_no_price_is_an_error_rather_than_a_zero() {
let body = r#"{"chart":{"result":[{"meta":{"symbol":"X"}}],"error":null}}"#;
assert!(
parse_chart(body).is_err(),
"a missing price must not render as 0.00"
);
}
#[test]
fn a_response_without_a_series_still_yields_a_quote() {
let body = r#"{"chart":{"result":[{"meta":{"symbol":"X",
"regularMarketPrice":5.0,"previousClose":4.0}}],"error":null}}"#;
let q = parse_chart(body).unwrap();
assert!(q.series.is_empty(), "{:?}", q.series);
assert!((q.change() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn a_series_longer_than_any_real_day_is_averaged_down_to_the_bound() {
let rising: Vec<String> = (0..100_000).map(|i| format!("{i}.0")).collect();
let body = SAMPLE.replace(
"[211.0, null, 212.5, 213.5]",
&format!("[{}]", rising.join(",")),
);
let q = parse_chart(&body).unwrap();
assert_eq!(q.series.len(), MAX_SERIES, "bounded where it is read");
let (first, last) = (q.series[0], q.series[MAX_SERIES - 1]);
assert!(
first < 100.0 && last > 99_900.0,
"the whole day, not the first or last of it: {first} .. {last}"
);
assert!(
q.series.windows(2).all(|pair| pair[0] < pair[1]),
"and still in the order it was traded"
);
}
#[test]
fn a_real_days_series_is_kept_sample_for_sample() {
let day: Vec<f64> = (0..390).map(|i| 200.0 + f64::from(i) * 0.25).collect();
let close: Vec<String> = day.iter().map(f64::to_string).collect();
let body = SAMPLE.replace(
"[211.0, null, 212.5, 213.5]",
&format!("[{}]", close.join(",")),
);
assert_eq!(parse_chart(&body).unwrap().series, day);
}
#[test]
fn only_advertised_sources_can_be_built() {
for name in SOURCE_NAMES {
assert!(
source_for(name).is_some(),
"{name} is advertised but absent"
);
}
assert!(source_for("finnhub").is_none(), "ruled out on licence");
assert!(source_for("").is_none());
}
#[test]
fn a_sparkline_is_exactly_as_wide_as_it_was_asked_to_be() {
for width in [1, 5, 12, 40] {
let line = sparkline(&[1.0, 2.0, 3.0, 4.0, 5.0], width);
assert_eq!(line.chars().count(), width, "at width {width}");
}
}
#[test]
fn a_rising_series_ends_higher_than_it_starts() {
let line: Vec<char> = sparkline(&[1.0, 2.0, 3.0, 4.0], 4).chars().collect();
assert_eq!(line[0], BLOCKS[0]);
assert_eq!(line[3], BLOCKS[7]);
}
#[test]
fn a_flat_series_draws_at_mid_height_not_on_the_floor() {
let line = sparkline(&[7.0; 6], 6);
assert!(
line.chars().all(|c| c == BLOCKS[BLOCKS.len() / 2]),
"a stock that has not moved has not crashed: `{line}`"
);
}
#[test]
fn a_sparkline_covers_the_whole_series_even_when_narrow() {
let level = |c: char| BLOCKS.iter().position(|b| *b == c).unwrap();
let mut series: Vec<f64> = (0..10).map(f64::from).collect();
let line: Vec<char> = sparkline(&series, 2).chars().collect();
assert!(
level(line[1]) >= level(line[0]) + 3,
"the tail of a rising series must sit well above its head: `{}`",
line.iter().collect::<String>()
);
series.reverse();
let line: Vec<char> = sparkline(&series, 2).chars().collect();
assert!(
level(line[0]) >= level(line[1]) + 3,
"and a falling series inverts: `{}`",
line.iter().collect::<String>()
);
}
#[test]
fn degenerate_sparklines_do_not_panic() {
assert_eq!(sparkline(&[], 10), "");
assert_eq!(sparkline(&[1.0], 0), "");
assert_eq!(sparkline(&[1.0], 3).chars().count(), 3);
}
fn watchlist(name: &str, seed: &[&str]) -> (Watchlist, TempDir) {
let dir = TempDir::new(&format!("watch-{name}"));
let seed: Vec<String> = seed.iter().map(|s| (*s).to_string()).collect();
let list = Watchlist::load(dir.join("watchlist.toml"), &seed).unwrap();
(list, dir)
}
#[test]
fn a_missing_watchlist_is_seeded_from_config_and_then_owns_itself() {
let (mut list, guard) = watchlist("seed", &["AAPL", "MSFT"]);
assert_eq!(list.symbols(), ["AAPL", "MSFT"]);
list.save().unwrap();
let reloaded =
Watchlist::load(guard.join("watchlist.toml"), &["TSLA".to_string()]).unwrap();
assert_eq!(reloaded.symbols(), ["AAPL", "MSFT"]);
}
#[test]
fn symbols_are_normalised_so_the_same_ticker_cannot_appear_twice() {
let (mut list, _g) = watchlist("dupes", &[]);
assert!(list.add("aapl"));
assert!(!list.add("AAPL"), "case must not create a second entry");
assert!(!list.add(" aapl "), "nor padding");
assert_eq!(list.symbols(), ["AAPL"]);
}
#[test]
fn blank_symbols_are_refused() {
let (mut list, _g) = watchlist("blank", &[]);
assert!(!list.add(""));
assert!(!list.add(" "));
assert!(list.symbols().is_empty(), "{:?}", list.symbols());
}
#[test]
fn removing_a_symbol_survives_a_reload() {
let (mut list, guard) = watchlist("remove", &["AAPL", "MSFT"]);
assert!(list.remove("aapl"), "removal is case-insensitive too");
list.save().unwrap();
let reloaded = Watchlist::load(guard.join("watchlist.toml"), &[]).unwrap();
assert_eq!(reloaded.symbols(), ["MSFT"]);
}
const WRITTEN_BY_1_20_0: &str = r#"# mirador watchlist. Safe to edit by hand.
# Only the symbols live here — prices are never written to disk.
symbols = [
"AAPL",
"BRK.B",
"^GSPC",
]
"#;
#[test]
fn a_file_from_1_20_0_is_written_back_byte_for_byte() {
let (_, guard) = watchlist("bytes", &[]);
let path = guard.join("watchlist.toml");
std::fs::write(&path, WRITTEN_BY_1_20_0).unwrap();
let mut list = Watchlist::load(&path, &["TSLA".to_string()]).unwrap();
assert_eq!(list.symbols(), ["AAPL", "BRK.B", "^GSPC"]);
std::fs::remove_file(&path).unwrap();
list.dirty = true;
list.save().unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), WRITTEN_BY_1_20_0);
}
#[test]
fn an_empty_seed_writes_nothing_so_the_user_is_not_given_a_file_of_nothing() {
let (mut list, guard) = watchlist("empty", &[]);
list.save().unwrap();
assert!(
!guard.join("watchlist.toml").exists(),
"nothing to persist yet"
);
}
}