Skip to main content

portfolio_analyzer/
live.rs

1//! # Live: SD on today's data, for the runner and the website feed
2//!
3//! The backtests read years of saved history.  The live tools (`sd_live`,
4//! `sd_feed`) need the same SD, the same strategies and the same costs, on
5//! bars fetched this afternoon.  This module fetches them once per day into
6//! a cache, computes SD exactly as the backtests do (side from market
7//! weather included), and builds the option-pricing inputs.
8//!
9//! Nothing here decides anything; it only assembles the inputs.
10
11use 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
25/// One symbol, ready for strategies.
26pub 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    /// Index ETF with deep option markets (tight spreads).
35    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
61/// Default cache directory: `~/.cache/sd-live`.
62pub 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
68/// Fetch (or reuse today's cached copy of) `symbol`'s last three years.
69pub 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
82/// Fetch the universe (or `symbols`) and compute SD for every bar.
83pub 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// ───────────────────────── real option prices ─────────────────────────
125
126/// What the option market actually charged for an at-the-money straddle.
127#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
128pub struct RealQuote {
129    pub date: NaiveDate,
130    pub spot: f64,
131    /// Expiry used (unix seconds) and its calendar days away.
132    pub expiration: i64,
133    pub days: f64,
134    pub strike: f64,
135    /// Mean of the ATM call and put implied vols (annualized).
136    pub atm_iv: f64,
137    /// Straddle bid and ask, per share.
138    pub straddle_bid: f64,
139    pub straddle_ask: f64,
140}
141
142/// A Yahoo session (cookie + crumb) for the options endpoint.
143pub 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    /// The ATM straddle at the listed expiry closest to `target_days` calendar days.
166    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
196/// `~/.local/state/sd-live/iv_log.json`: every real quote seen, by symbol.
197pub 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
205/// Fetch today's real ATM quotes (one-month and one-week expiries), add
206/// them to the log, and make every logged date use the *real* implied vol
207/// instead of the proxy.  Returns today's one-month and one-week quotes.
208pub 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                    // The simulator reads an index level and multiplies by ATM_FROM_INDEX.
241                    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}