Skip to main content

schwab_cli/agent/backtest/
cache.rs

1use std::collections::{BTreeSet, HashMap};
2use std::fs;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use chrono::{DateTime, NaiveDate, TimeZone, Utc};
7use chrono_tz::America::New_York;
8use serde::{Deserialize, Serialize};
9
10pub const CACHE_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct BacktestCache {
14    pub version: u32,
15    pub fetched_at: DateTime<Utc>,
16    pub from: NaiveDate,
17    pub to: NaiveDate,
18    #[serde(default)]
19    pub symbols: HashMap<String, Vec<StoredCandle>>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct StoredCandle {
24    pub datetime_ms: i64,
25    pub open: f64,
26    pub high: f64,
27    pub low: f64,
28    pub close: f64,
29    pub volume: f64,
30}
31
32impl StoredCandle {
33    pub fn from_schwab_json(c: &serde_json::Value) -> Option<Self> {
34        Some(Self {
35            datetime_ms: c.get("datetime")?.as_i64()?,
36            open: c.get("open")?.as_f64()?,
37            high: c.get("high")?.as_f64()?,
38            low: c.get("low")?.as_f64()?,
39            close: c.get("close")?.as_f64()?,
40            volume: c.get("volume")?.as_f64()?,
41        })
42    }
43
44    pub fn trading_date_et(&self) -> NaiveDate {
45        let secs = self.datetime_ms / 1000;
46        let dt = Utc
47            .timestamp_opt(secs, 0)
48            .single()
49            .unwrap_or_else(Utc::now);
50        dt.with_timezone(&New_York).date_naive()
51    }
52}
53
54impl BacktestCache {
55    pub fn new(from: NaiveDate, to: NaiveDate) -> Self {
56        Self {
57            version: CACHE_VERSION,
58            fetched_at: Utc::now(),
59            from,
60            to,
61            symbols: HashMap::new(),
62        }
63    }
64
65    pub fn load(path: &Path) -> Result<Self> {
66        let raw = fs::read_to_string(path)
67            .with_context(|| format!("read backtest cache {}", path.display()))?;
68        let cache: Self = serde_json::from_str(&raw).context("parse backtest cache JSON")?;
69        anyhow::ensure!(
70            cache.version == CACHE_VERSION,
71            "unsupported backtest cache version {} (expected {CACHE_VERSION})",
72            cache.version
73        );
74        Ok(cache)
75    }
76
77    pub fn save(&self, path: &Path) -> Result<()> {
78        if let Some(parent) = path.parent() {
79            fs::create_dir_all(parent)?;
80        }
81        let raw = serde_json::to_string_pretty(self)?;
82        fs::write(path, raw).with_context(|| format!("write backtest cache {}", path.display()))
83    }
84
85    pub fn merge_history(&mut self, symbol: &str, candles: Vec<StoredCandle>) {
86        let sym = symbol.trim().to_uppercase();
87        let mut merged = self.symbols.get(&sym).cloned().unwrap_or_default();
88        merged.extend(candles);
89        merged.sort_by_key(|c| c.datetime_ms);
90        merged.dedup_by_key(|c| c.datetime_ms);
91        self.symbols.insert(sym, merged);
92    }
93
94    pub fn ingest_schwab_history(&mut self, symbol: &str, history: &serde_json::Value) {
95        let stored: Vec<StoredCandle> = history
96            .get("candles")
97            .and_then(|v| v.as_array())
98            .map(|arr| arr.iter().filter_map(StoredCandle::from_schwab_json).collect())
99            .unwrap_or_default();
100        self.merge_history(symbol, stored);
101    }
102
103    pub fn closes_through(&self, symbol: &str, day: NaiveDate) -> Vec<f64> {
104        let sym = symbol.trim().to_uppercase();
105        self.symbols
106            .get(&sym)
107            .map(|bars| {
108                bars.iter()
109                    .filter(|b| b.trading_date_et() <= day)
110                    .map(|b| b.close)
111                    .collect()
112            })
113            .unwrap_or_default()
114    }
115
116    pub fn bar_on_date(&self, symbol: &str, day: NaiveDate) -> Option<&StoredCandle> {
117        let sym = symbol.trim().to_uppercase();
118        self.symbols.get(&sym).and_then(|bars| {
119            bars.iter()
120                .filter(|b| b.trading_date_et() == day)
121                .last()
122        })
123    }
124
125    pub fn close_on_date(&self, symbol: &str, day: NaiveDate) -> Option<f64> {
126        self.bar_on_date(symbol, day).map(|b| b.close)
127    }
128
129    /// Prefer `$VIX` then `VIX` for the index level on `day`.
130    pub fn vix_on_date(&self, configured: &str, day: NaiveDate) -> Option<f64> {
131        let cfg = configured.trim().to_uppercase();
132        let candidates = if cfg.is_empty() {
133            vec!["$VIX".to_string(), "VIX".to_string()]
134        } else {
135            let mut v = vec![cfg.clone()];
136            if cfg != "$VIX" {
137                v.push("$VIX".into());
138            }
139            if cfg != "VIX" {
140                v.push("VIX".into());
141            }
142            v
143        };
144        for sym in candidates {
145            if let Some(c) = self.close_on_date(&sym, day) {
146                return Some(c);
147            }
148        }
149        None
150    }
151
152    pub fn trading_days(
153        &self,
154        benchmark: &str,
155        from: NaiveDate,
156        to: NaiveDate,
157    ) -> Result<Vec<NaiveDate>> {
158        let sym = benchmark.trim().to_uppercase();
159        let bars = self
160            .symbols
161            .get(&sym)
162            .with_context(|| format!("benchmark {sym} missing from cache — run backtest prefetch"))?;
163        let days: Vec<NaiveDate> = bars
164            .iter()
165            .map(|b| b.trading_date_et())
166            .filter(|d| *d >= from && *d <= to)
167            .collect::<BTreeSet<_>>()
168            .into_iter()
169            .collect();
170        anyhow::ensure!(
171            !days.is_empty(),
172            "no trading days for {sym} between {from} and {to}"
173        );
174        Ok(days)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn trading_days_unique_sorted() {
184        let mut cache = BacktestCache::new(
185            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
186            NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
187        );
188        // 2024-01-02 and 2024-01-03 ET approx via UTC noon
189        cache.merge_history(
190            "SPY",
191            vec![
192                StoredCandle {
193                    datetime_ms: 1_704_196_800_000,
194                    open: 1.0,
195                    high: 1.0,
196                    low: 1.0,
197                    close: 470.0,
198                    volume: 1.0,
199                },
200                StoredCandle {
201                    datetime_ms: 1_704_283_200_000,
202                    open: 1.0,
203                    high: 1.0,
204                    low: 1.0,
205                    close: 471.0,
206                    volume: 1.0,
207                },
208            ],
209        );
210        let days = cache
211            .trading_days(
212                "SPY",
213                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
214                NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
215            )
216            .unwrap();
217        assert!(days.len() >= 2);
218        assert!(days[0] < days[1]);
219    }
220}