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};
const MAX_AGE: Duration = Duration::from_secs(7 * 24 * 3600);
const WRITE_EVERY: Duration = Duration::from_secs(60);
const VERSION: u32 = 2;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Entry {
pub fetched: i64,
pub params: String,
pub data: TickerData,
}
#[derive(Default, Deserialize, Serialize)]
struct File {
version: u32,
entries: HashMap<String, Entry>,
}
pub struct Store {
path: Option<PathBuf>,
entries: HashMap<String, Entry>,
dirty: bool,
last_write: Option<Instant>,
}
impl Store {
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,
}
}
pub fn get(&self, source: &str, symbol: &str, params: &str) -> Option<&Entry> {
self.entries
.get(&key(source, symbol))
.filter(|e| e.params == params)
}
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;
}
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()
}
pub fn flush(&mut self) -> bool {
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;
}
let tmp = path.with_extension("tmp");
let written = std::fs::write(&tmp, raw).is_ok() && std::fs::rename(&tmp, &path).is_ok();
if written {
self.dirty = false;
}
written
}
}
impl Drop for Store {
fn drop(&mut self) {
if self.dirty {
self.flush();
}
}
}
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}")
}
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())
}
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 {
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);
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));
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());
}
#[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());
}
#[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");
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));
}
}