Skip to main content

schwab_cli/agent/
volatility.rs

1//! Realized volatility for IV/RV entry gates (credit-spread edge filter).
2
3use anyhow::{Context, Result};
4use schwab_market_data::MarketDataApi;
5
6/// Annualized realized vol (%) from daily closes — same formula as schwab-trader regime.
7pub fn realized_vol_annualized_pct(closes: &[f64], lookback: usize) -> f64 {
8    if closes.len() <= lookback + 1 || lookback == 0 {
9        return 0.0;
10    }
11    let mut returns = Vec::new();
12    let start = closes.len().saturating_sub(lookback + 1);
13    for i in start + 1..closes.len() {
14        let prev = closes[i - 1];
15        if prev > 0.0 && closes[i] > 0.0 {
16            returns.push((closes[i] / prev).ln());
17        }
18    }
19    if returns.is_empty() {
20        return 0.0;
21    }
22    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
23    let var = returns
24        .iter()
25        .map(|r| (r - mean).powi(2))
26        .sum::<f64>()
27        / returns.len() as f64;
28    (var.sqrt() * (252.0_f64).sqrt()) * 100.0
29}
30
31/// Fetch daily closes and compute annualized realized vol for `symbol`.
32pub async fn fetch_realized_vol_pct(
33    market: &MarketDataApi,
34    symbol: &str,
35    lookback: usize,
36) -> Result<Option<f64>> {
37    let sym = symbol.trim().to_uppercase();
38    let history = market
39        .price_history()
40        .get(
41            &sym,
42            Some("year"),
43            Some(1),
44            Some("daily"),
45            None,
46            None,
47            None,
48            None,
49            Some(true),
50        )
51        .await
52        .with_context(|| format!("price history for realized vol {sym}"))?;
53
54    let closes: Vec<f64> = history
55        .get("candles")
56        .and_then(|v| v.as_array())
57        .map(|arr| {
58            arr.iter()
59                .filter_map(|c| c.get("close").and_then(|v| v.as_f64()))
60                .collect()
61        })
62        .unwrap_or_default();
63
64    let rv = realized_vol_annualized_pct(&closes, lookback);
65    if rv > 0.0 {
66        Ok(Some(rv))
67    } else {
68        Ok(None)
69    }
70}
71
72/// IV / RV ratio when both are positive; `None` if either side is missing.
73pub fn iv_rv_ratio(chain_iv_pct: Option<f64>, realized_vol_pct: Option<f64>) -> Option<f64> {
74    match (chain_iv_pct, realized_vol_pct) {
75        (Some(iv), Some(rv)) if iv > 0.0 && rv > 0.0 => Some(iv / rv),
76        _ => None,
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn realized_vol_positive_on_trending_series() {
86        let mut closes = Vec::new();
87        let mut px = 100.0;
88        for i in 0..40 {
89            px *= if i % 2 == 0 { 1.01 } else { 0.99 };
90            closes.push(px);
91        }
92        let rv = realized_vol_annualized_pct(&closes, 20);
93        assert!(rv > 5.0, "rv={rv}");
94    }
95
96    #[test]
97    fn iv_rv_ratio_none_when_missing() {
98        assert!(iv_rv_ratio(None, Some(15.0)).is_none());
99        assert!(iv_rv_ratio(Some(20.0), None).is_none());
100        assert!((iv_rv_ratio(Some(22.0), Some(20.0)).unwrap() - 1.1).abs() < 1e-9);
101    }
102}