schwab_cli/agent/backtest/
cache.rs1use 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 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 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}