src/cache.rs
Rama main · réplica de lectura del repositorio original.
//! Last-good quotes on disk, so a throttled or blocked source does not
//! leave the app staring at an empty screen.
//!
//! Yahoo, the keyless default, rate limits by IP: measured 2026-09-10, a
//! block arrived after about ten requests and held for 19 minutes, and the
//! second one that day held for over an hour. Nothing in the client can
//! shorten that, so the answer is to have something to show meanwhile. The
//! rail labels what came from here, and the first real poll replaces it.
//!
//! Deliberately not a request cache: entries are only ever read at startup,
//! never to answer a fetch, so this can never stand between the user and a
//! live price.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::domain::{Interval, Range, Sessions, TickerData};
/// Entries older than this are dropped on load. A week-old session is still
/// honest history when it is labelled, but past that the chart under it is
/// telling a story about a different market.
const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
/// How often the store is allowed to hit the disk. The poller writes after
/// a cycle, and cycles can be two seconds apart; the file only has to be
/// good enough for the next start.
const WRITE_EVERY: Duration = Duration::from_secs(60);
/// Bumped when the entry shape changes; a file from another version is
/// dropped rather than migrated.
const VERSION: u32 = 2;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Entry {
/// Unix seconds of the fetch behind this entry.
pub fetched: i64,
/// The window this was fetched for (`params_key`). An entry taken with
/// other parameters is not comparable data, so it is never seeded.
pub params: String,
pub data: TickerData,
}
#[derive(Default, Deserialize, Serialize)]
struct File {
version: u32,
entries: HashMap<String, Entry>,
}
/// The store the poller owns: entries in memory, written back on a timer.
pub struct Store {
path: Option<PathBuf>,
entries: HashMap<String, Entry>,
dirty: bool,
last_write: Option<Instant>,
}
impl Store {
/// Loads the cache file, or an empty store when there is none, when it
/// is unreadable, or when it was written by another version. A cache is
/// never worth an error message: every caller can work without it.
pub fn load() -> Self {
Self::load_at(default_path())
}
pub fn load_at(path: Option<PathBuf>) -> Self {
let entries = path
.as_deref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|raw| serde_json::from_str::<File>(&raw).ok())
.filter(|file| file.version == VERSION)
.map(|file| file.entries)
.unwrap_or_default();
let now = unix_now();
let max_age = MAX_AGE.as_secs() as i64;
let entries = entries
.into_iter()
.filter(|(_, e)| now - e.fetched <= max_age)
.collect();
Self {
path,
entries,
dirty: false,
last_write: None,
}
}
/// The cached ticker for this source and window, if there is one. The
/// window has to match: a 1d/5m series seeded into a 1y/1d chart would
/// be a different picture drawn on the same axes.
pub fn get(&self, source: &str, symbol: &str, params: &str) -> Option<&Entry> {
self.entries
.get(&key(source, symbol))
.filter(|e| e.params == params)
}
/// Records a successful fetch. Nothing is written here: `flush_due`
/// owns the disk, so a two second poll interval does not mean two
/// second writes.
pub fn put(&mut self, source: &str, symbol: &str, params: &str, data: &TickerData) {
self.entries.insert(
key(source, symbol),
Entry {
fetched: unix_now(),
params: params.to_string(),
data: data.clone(),
},
);
self.dirty = true;
}
/// Writes the first successful cycle immediately, then at most once
/// per write interval. Returns whether it wrote; failures are
/// swallowed, since a cache that cannot be written is not an error the
/// user can act on.
pub fn flush_due(&mut self) -> bool {
if !self.dirty || self.last_write.is_some_and(|at| at.elapsed() < WRITE_EVERY) {
return false;
}
self.flush()
}
/// Writes the store back regardless of the timer.
pub fn flush(&mut self) -> bool {
// Failed writes get the same backoff as successful ones.
self.last_write = Some(Instant::now());
let Some(path) = self.path.clone() else {
return false;
};
let file = File {
version: VERSION,
entries: self.entries.clone(),
};
let Ok(raw) = serde_json::to_string(&file) else {
return false;
};
if let Some(dir) = path.parent()
&& std::fs::create_dir_all(dir).is_err()
{
return false;
}
// Through a temp file: a half-written cache would be dropped on the
// next load, which is exactly the start where it was wanted. One
// per process: two panes writing the same temp file could rename
// the other's half-written one into place.
let tmp = path.with_extension(format!("tmp{}", std::process::id()));
let written = std::fs::write(&tmp, raw).is_ok() && std::fs::rename(&tmp, &path).is_ok();
if written {
self.dirty = false;
} else {
let _ = std::fs::remove_file(&tmp);
}
written
}
}
impl Drop for Store {
fn drop(&mut self) {
// Runtime shutdown drops the poller even while it is sleeping or
// fetching. Preserve the final updates of short sessions too.
if self.dirty {
self.flush();
}
}
}
/// `<cache dir>/alphai-tui/quotes.json`, or None on a platform with no
/// cache directory (the store then works in memory and writes nothing).
fn default_path() -> Option<PathBuf> {
Some(dirs::cache_dir()?.join("alphai-tui").join("quotes.json"))
}
fn key(source: &str, symbol: &str) -> String {
format!("{source}/{symbol}")
}
/// The fetch window as one comparable string. Kept as text rather than the
/// enums so the file survives a rename or a new variant: an entry it can no
/// longer match is simply not seeded.
pub fn params_key(range: Range, interval: Interval, sessions: Sessions) -> String {
let sessions = match sessions {
Sessions::Regular => "regular",
Sessions::Extended => "extended",
};
format!("{}/{}/{sessions}", range.as_str(), interval.as_str())
}
/// Compact age of a cached entry, for the rail's badge: "4m", "2h", "3d".
pub fn age_label(fetched: i64) -> String {
let mins = (unix_now() - fetched).max(0) / 60;
match mins {
0..=59 => format!("{mins}m"),
60..=1439 => format!("{}h", mins / 60),
_ => format!("{}d", mins / 1440),
}
}
fn unix_now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{Candle, Quote};
fn data(price: f64) -> TickerData {
TickerData {
quote: Quote {
name: None,
timing: Default::default(),
symbol: "AAPL".into(),
price,
prev_close: Some(1.0),
currency: Some("USD".into()),
extended: None,
fifty_two_week: None,
day_range: None,
volume: None,
},
candles: vec![Candle {
feed: Default::default(),
ts: 1_700_000_000,
open: 1.0,
high: 2.0,
low: 0.5,
close: price,
volume: None,
}],
}
}
fn tmp_path(name: &str) -> PathBuf {
std::env::temp_dir()
.join(format!("alphai-tui-test-{name}-{}", std::process::id()))
.join("quotes.json")
}
#[test]
fn a_written_store_comes_back_on_the_next_load() {
let path = tmp_path("roundtrip");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
let params = params_key(Range::D1, Interval::M5, Sessions::Regular);
let mut store = Store::load_at(Some(path.clone()));
assert!(store.get("yahoo", "AAPL", ¶ms).is_none());
store.put("yahoo", "AAPL", ¶ms, &data(10.0));
assert!(store.flush(), "write failed");
let store = Store::load_at(Some(path.clone()));
let entry = store.get("yahoo", "AAPL", ¶ms).expect("entry");
assert_eq!(entry.data.quote.price, 10.0);
assert_eq!(entry.data.candles.len(), 1);
// Another source, or another window, is not this entry.
assert!(store.get("alpaca", "AAPL", ¶ms).is_none());
let other = params_key(Range::Y1, Interval::D1, Sessions::Regular);
assert!(store.get("yahoo", "AAPL", &other).is_none());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn stale_entries_are_dropped_on_load() {
let path = tmp_path("stale");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
let params = params_key(Range::D1, Interval::M5, Sessions::Regular);
let mut store = Store::load_at(Some(path.clone()));
store.put("yahoo", "AAPL", ¶ms, &data(10.0));
// Backdate it past the horizon, the way a week off would.
let entry = store.entries.get_mut(&key("yahoo", "AAPL")).unwrap();
entry.fetched -= MAX_AGE.as_secs() as i64 + 60;
assert!(store.flush());
let store = Store::load_at(Some(path.clone()));
assert!(store.get("yahoo", "AAPL", ¶ms).is_none());
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn a_short_session_keeps_its_last_successful_fetch() {
let path = tmp_path("short-session");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
let params = params_key(Range::D1, Interval::M5, Sessions::Regular);
{
let mut store = Store::load_at(Some(path.clone()));
store.put("yahoo", "AAPL", ¶ms, &data(10.0));
assert!(store.flush_due(), "the first cycle should reach disk");
store.put("yahoo", "AAPL", ¶ms, &data(11.0));
assert!(!store.flush_due(), "subsequent cycles should be throttled");
}
let store = Store::load_at(Some(path.clone()));
assert_eq!(
store
.get("yahoo", "AAPL", ¶ms)
.unwrap()
.data
.quote
.price,
11.0
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// A file from another version, or a truncated one, must not stop the
/// app: it is a cache, and an empty store is a correct answer.
#[test]
fn an_unreadable_file_loads_as_an_empty_store() {
let path = tmp_path("broken");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{\"version\": 99, \"entries\": {}}").unwrap();
let params = params_key(Range::D1, Interval::M5, Sessions::Regular);
assert!(
Store::load_at(Some(path.clone()))
.get("yahoo", "AAPL", ¶ms)
.is_none()
);
std::fs::write(&path, "{ not json").unwrap();
assert!(
Store::load_at(Some(path.clone()))
.get("yahoo", "AAPL", ¶ms)
.is_none()
);
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
/// The disk is touched on a timer, not on every poll.
#[test]
fn flush_due_waits_for_its_interval() {
let path = tmp_path("timer");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
let params = params_key(Range::D1, Interval::M5, Sessions::Regular);
let mut store = Store::load_at(Some(path.clone()));
store.put("yahoo", "AAPL", ¶ms, &data(10.0));
assert!(store.flush_due(), "did not write the first cycle");
store.put("yahoo", "AAPL", ¶ms, &data(11.0));
assert!(!store.flush_due(), "wrote inside the interval");
store.last_write = Some(Instant::now() - WRITE_EVERY - Duration::from_secs(1));
assert!(store.flush_due(), "did not write after the interval");
assert!(!store.flush_due(), "wrote again with nothing new");
let _ = std::fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn age_label_reads_in_the_units_a_reader_thinks_in() {
let now = unix_now();
assert_eq!(age_label(now), "0m");
assert_eq!(age_label(now - 300), "5m");
assert_eq!(age_label(now - 7_200), "2h");
assert_eq!(age_label(now - 3 * 86_400), "3d");
// A clock that moved backwards is not a negative age.
assert_eq!(age_label(now + 600), "0m");
}
#[test]
fn params_key_separates_the_windows_it_has_to() {
let d1 = params_key(Range::D1, Interval::M5, Sessions::Regular);
assert_eq!(d1, "1d/5m/regular");
assert_ne!(d1, params_key(Range::D1, Interval::M15, Sessions::Regular));
assert_ne!(d1, params_key(Range::D5, Interval::M5, Sessions::Regular));
assert_ne!(d1, params_key(Range::D1, Interval::M5, Sessions::Extended));
}
}