1use crate::direction::MarketWeather;
12use crate::history::{self, DatedBar};
13use crate::research::UNIVERSE;
14use crate::sd2::engine::{sd2_series_market, Sd2Model, Sd2Reading};
15use crate::sd2::primitives::rolling_std;
16use crate::strategy::sim::{Costs, Underlying};
17use anyhow::Result;
18use chrono::NaiveDate;
19use reqwest::blocking::Client;
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23pub const IV_INDEX: &[(&str, &str)] = &[("SPY", "^VIX"), ("QQQ", "^VXN"), ("DIA", "^VXD"), ("GLD", "^GVZ"), ("USO", "^OVX")];
24
25pub struct LiveSymbol {
27 pub symbol: String,
28 pub bars: Vec<DatedBar>,
29 pub dates: Vec<NaiveDate>,
30 pub close: Vec<f64>,
31 pub sd: Vec<Option<Sd2Reading>>,
32 pub iv: Vec<f64>,
33 pub real_iv: bool,
34 pub liquid: bool,
36}
37
38impl LiveSymbol {
39 pub fn underlying(&self) -> Underlying<'_> {
40 Underlying {
41 symbol: &self.symbol,
42 dates: &self.dates,
43 close: &self.close,
44 iv_index: &self.iv,
45 real_iv: self.real_iv,
46 sd: &self.sd,
47 costs: if self.liquid { Costs::LIQUID } else { Costs::SINGLE },
48 }
49 }
50}
51
52pub struct LiveData {
53 pub asof: NaiveDate,
54 pub model: Sd2Model,
55 pub symbols: Vec<LiveSymbol>,
56 pub weather: MarketWeather,
57 pub spy: Vec<DatedBar>,
58 pub vix: Vec<DatedBar>,
59}
60
61pub fn cache_dir() -> PathBuf {
63 std::env::var_os("SD_LIVE_CACHE")
64 .map(PathBuf::from)
65 .unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())).join(".cache/sd-live"))
66}
67
68pub fn bars(client: &Client, cache: &Path, symbol: &str, today: NaiveDate) -> Result<Vec<DatedBar>> {
70 let path = cache.join(today.to_string()).join(format!("{symbol}.csv"));
71 if let Ok(b) = history::load(&path) {
72 if b.len() > 300 {
73 return Ok(b);
74 }
75 }
76 let b = history::fetch_daily(client, symbol, "3y")?;
77 history::save(&path, &b)?;
78 std::thread::sleep(std::time::Duration::from_millis(200));
79 Ok(b)
80}
81
82pub fn load(symbols: Option<&[String]>) -> Result<LiveData> {
84 let client = Client::builder().build()?;
85 let cache = cache_dir();
86 let today = chrono::Local::now().date_naive();
87 let model = Sd2Model::embedded();
88 let spy = bars(&client, &cache, "SPY", today)?;
89 let vix = bars(&client, &cache, "^VIX", today).unwrap_or_default();
90 let weather = MarketWeather::new(&spy, &vix);
91 let list: Vec<String> = symbols.map(|s| s.to_vec()).unwrap_or_else(|| UNIVERSE.iter().map(|s| s.to_string()).collect());
92 let mut out = Vec::new();
93 for symbol in list {
94 let Ok(b) = bars(&client, &cache, &symbol, today) else {
95 eprintln!("sd-live: could not fetch {symbol}");
96 continue;
97 };
98 if b.len() < 300 {
99 continue;
100 }
101 let ohlcv: Vec<_> = b.iter().map(|x| x.bar.clone()).collect();
102 let dates: Vec<NaiveDate> = b.iter().map(|x| x.date).collect();
103 let sd = sd2_series_market(&ohlcv, &dates, &model, &weather);
104 let close: Vec<f64> = ohlcv.iter().map(|x| x.close).collect();
105 let lr: Vec<f64> = (0..close.len()).map(|i| if i == 0 { 0.0 } else { (close[i] / close[i - 1]).ln() }).collect();
106 let (v20, v126) = (rolling_std(&lr, 20), rolling_std(&lr, 126));
107 let mut iv: Vec<f64> = (0..close.len()).map(|i| model.iv_proxy(v20[i], v126[i])).collect();
108 let mut real_iv = false;
109 if let Some((_, idx)) = IV_INDEX.iter().find(|(u, _)| *u == symbol) {
110 if let Ok(ix) = bars(&client, &cache, idx, today) {
111 let by: HashMap<NaiveDate, f64> = ix.iter().map(|x| (x.date, x.bar.close / 100.0)).collect();
112 real_iv = true;
113 for (i, d) in dates.iter().enumerate() {
114 iv[i] = by.get(d).copied().unwrap_or(f64::NAN);
115 }
116 }
117 }
118 out.push(LiveSymbol { symbol, bars: b, dates, close, sd, iv, real_iv, liquid: real_iv });
119 }
120 let asof = spy.last().map(|b| b.date).unwrap_or(today);
121 Ok(LiveData { asof, model, symbols: out, weather, spy, vix })
122}
123
124#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
128pub struct RealQuote {
129 pub date: NaiveDate,
130 pub spot: f64,
131 pub expiration: i64,
133 pub days: f64,
134 pub strike: f64,
135 pub atm_iv: f64,
137 pub straddle_bid: f64,
139 pub straddle_ask: f64,
140}
141
142pub struct OptionsClient {
144 client: Client,
145 crumb: String,
146}
147
148impl OptionsClient {
149 pub fn new() -> Result<Self> {
150 let client = Client::builder().cookie_store(true).user_agent("Mozilla/5.0").build()?;
151 let _ = client.get("https://fc.yahoo.com").send();
152 let crumb = client.get("https://query2.finance.yahoo.com/v1/test/getcrumb").send()?.text()?;
153 anyhow::ensure!(!crumb.is_empty() && !crumb.contains('{'), "no crumb");
154 Ok(Self { client, crumb })
155 }
156
157 fn chain(&self, symbol: &str, date: Option<i64>) -> Result<serde_json::Value> {
158 let mut url = format!("https://query2.finance.yahoo.com/v7/finance/options/{symbol}?crumb={}", self.crumb);
159 if let Some(d) = date {
160 url.push_str(&format!("&date={d}"));
161 }
162 Ok(self.client.get(&url).timeout(std::time::Duration::from_secs(20)).send()?.json()?)
163 }
164
165 pub fn atm_straddle(&self, symbol: &str, today: NaiveDate, target_days: f64) -> Result<RealQuote> {
167 let first = self.chain(symbol, None)?;
168 let r = &first["optionChain"]["result"][0];
169 let spot = r["quote"]["regularMarketPrice"].as_f64().ok_or_else(|| anyhow::anyhow!("no price"))?;
170 let now = today.and_hms_opt(20, 0, 0).unwrap().and_utc().timestamp();
171 let exp = r["expirationDates"]
172 .as_array()
173 .ok_or_else(|| anyhow::anyhow!("no expirations"))?
174 .iter()
175 .filter_map(|v| v.as_i64())
176 .filter(|e| *e > now + 86_400)
177 .min_by(|a, b| {
178 let da = ((*a - now) as f64 / 86_400.0 - target_days).abs();
179 let db = ((*b - now) as f64 / 86_400.0 - target_days).abs();
180 da.partial_cmp(&db).unwrap()
181 })
182 .ok_or_else(|| anyhow::anyhow!("no future expiry"))?;
183 let c = self.chain(symbol, Some(exp))?;
184 let o = &c["optionChain"]["result"][0]["options"][0];
185 let pick = |side: &str| -> Option<(f64, f64, f64, f64)> {
186 o[side].as_array()?.iter().filter_map(|x| Some((x["strike"].as_f64()?, x["bid"].as_f64().unwrap_or(0.0), x["ask"].as_f64()?, x["impliedVolatility"].as_f64()?))).min_by(|a, b| (a.0 - spot).abs().partial_cmp(&(b.0 - spot).abs()).unwrap())
187 };
188 let (k, cb, ca, civ) = pick("calls").ok_or_else(|| anyhow::anyhow!("no calls"))?;
189 let put = o["puts"].as_array().and_then(|p| p.iter().find(|x| x["strike"].as_f64() == Some(k)).cloned()).ok_or_else(|| anyhow::anyhow!("no matching put"))?;
190 let (pb, pa, piv) = (put["bid"].as_f64().unwrap_or(0.0), put["ask"].as_f64().unwrap_or(f64::NAN), put["impliedVolatility"].as_f64().unwrap_or(f64::NAN));
191 anyhow::ensure!(ca > 0.0 && pa > 0.0 && civ > 0.01 && piv > 0.01, "no usable quotes");
192 Ok(RealQuote { date: today, spot, expiration: exp, days: (exp - now) as f64 / 86_400.0, strike: k, atm_iv: 0.5 * (civ + piv), straddle_bid: cb + pb, straddle_ask: ca + pa })
193 }
194}
195
196pub fn iv_log_path() -> PathBuf {
198 std::env::var_os("SD_IV_LOG").map(PathBuf::from).unwrap_or_else(|| {
199 PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| ".".into())).join(".local/state/sd-live/iv_log.json")
200 })
201}
202
203pub type IvLog = HashMap<String, Vec<RealQuote>>;
204
205pub fn apply_real_iv(data: &mut LiveData, fetch: bool) -> (HashMap<String, RealQuote>, HashMap<String, RealQuote>) {
209 let path = iv_log_path();
210 let mut log: IvLog = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
211 let mut month = HashMap::new();
212 let mut week = HashMap::new();
213 if fetch {
214 match OptionsClient::new() {
215 Ok(oc) => {
216 for s in &data.symbols {
217 if let Ok(q) = oc.atm_straddle(&s.symbol, data.asof, 30.0) {
218 let v = log.entry(s.symbol.clone()).or_default();
219 v.retain(|x| x.date != q.date);
220 v.push(q.clone());
221 month.insert(s.symbol.clone(), q);
222 }
223 if let Ok(q) = oc.atm_straddle(&s.symbol, data.asof, 9.0) {
224 week.insert(s.symbol.clone(), q);
225 }
226 std::thread::sleep(std::time::Duration::from_millis(150));
227 }
228 if let Some(p) = path.parent() {
229 let _ = std::fs::create_dir_all(p);
230 }
231 let _ = std::fs::write(&path, serde_json::to_string(&log).unwrap_or_default());
232 }
233 Err(e) => eprintln!("sd-live: no option chains today ({e}); using the proxy"),
234 }
235 }
236 for s in data.symbols.iter_mut() {
237 if let Some(v) = log.get(&s.symbol) {
238 for q in v {
239 if let Ok(i) = s.dates.binary_search(&q.date) {
240 s.iv[i] = q.atm_iv / crate::strategy::pricing::ATM_FROM_INDEX;
242 }
243 }
244 s.real_iv = s.real_iv || v.iter().any(|q| q.date == data.asof);
245 }
246 }
247 (month, week)
248}