Skip to main content

schwab_cli/agent/
regime.rs

1//! Lightweight market-regime detection for options strategy selection.
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use serde_json::{json, Value};
6use schwab_market_data::MarketDataApi;
7
8use crate::rules::OptionsRegimeConfig;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum OptionsRegimeClass {
13    LowVolTrend,
14    ElevatedVol,
15    HighVolChop,
16    BearishTrend,
17    Hostile,
18    Neutral,
19}
20
21impl OptionsRegimeClass {
22    pub fn as_str(self) -> &'static str {
23        match self {
24            Self::LowVolTrend => "low_vol_trend",
25            Self::ElevatedVol => "elevated_vol",
26            Self::HighVolChop => "high_vol_chop",
27            Self::BearishTrend => "bearish_trend",
28            Self::Hostile => "hostile",
29            Self::Neutral => "neutral",
30        }
31    }
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct OptionsRegimeSnapshot {
36    pub class: String,
37    pub benchmark_symbol: String,
38    pub vix_symbol: String,
39    pub benchmark_last: f64,
40    pub vix: Option<f64>,
41    pub above_sma_50: bool,
42    pub above_sma_200: bool,
43    pub preferred_strategy: String,
44    pub pause_entries: bool,
45    pub signals: Value,
46}
47
48impl OptionsRegimeSnapshot {
49    pub fn to_json(&self) -> Value {
50        json!({
51            "class": self.class,
52            "benchmark_symbol": self.benchmark_symbol,
53            "vix_symbol": self.vix_symbol,
54            "benchmark_last": self.benchmark_last,
55            "vix": self.vix,
56            "above_sma_50": self.above_sma_50,
57            "above_sma_200": self.above_sma_200,
58            "preferred_strategy": self.preferred_strategy,
59            "pause_entries": self.pause_entries,
60            "signals": self.signals,
61        })
62    }
63}
64
65pub async fn detect_options_regime(
66    market: &MarketDataApi,
67    cfg: &OptionsRegimeConfig,
68) -> Result<OptionsRegimeSnapshot> {
69    if !cfg.enabled {
70        return Ok(neutral_snapshot(cfg));
71    }
72
73    let benchmark = cfg.benchmark_symbol.trim().to_uppercase();
74    let (last, above_sma_50, above_sma_200) = benchmark_trend(market, &benchmark).await?;
75    let vix = fetch_vix(market, &cfg.vix_symbol).await.ok();
76
77    let class = classify_options_regime(cfg, vix, above_sma_50, above_sma_200);
78    let pause_entries = class == OptionsRegimeClass::Hostile
79        || vix.is_some_and(|v| v >= cfg.pause_entries_vix_above)
80        || cfg
81            .pause_entries_vix_below
82            .is_some_and(|floor| vix.is_some_and(|v| v <= floor));
83    let preferred = preferred_strategy(cfg, class);
84
85    Ok(OptionsRegimeSnapshot {
86        class: class.as_str().to_string(),
87        benchmark_symbol: benchmark,
88        vix_symbol: cfg.vix_symbol.clone(),
89        benchmark_last: last,
90        vix,
91        above_sma_50,
92        above_sma_200,
93        preferred_strategy: preferred,
94        pause_entries,
95        signals: json!({
96            "vix_low": cfg.vix_low,
97            "vix_high": cfg.vix_high,
98            "pause_entries_vix_above": cfg.pause_entries_vix_above,
99            "pause_entries_vix_below": cfg.pause_entries_vix_below,
100            "realized_vol_lookback": cfg.realized_vol_lookback,
101        }),
102    })
103}
104
105pub fn classify_options_regime(
106    cfg: &OptionsRegimeConfig,
107    vix: Option<f64>,
108    above_sma_50: bool,
109    above_sma_200: bool,
110) -> OptionsRegimeClass {
111    if vix.is_some_and(|v| v >= cfg.pause_entries_vix_above) {
112        return OptionsRegimeClass::Hostile;
113    }
114    if !above_sma_50 && !above_sma_200 {
115        return OptionsRegimeClass::BearishTrend;
116    }
117    let high_vix = vix.is_some_and(|v| v >= cfg.vix_high);
118    if high_vix || (!above_sma_50 && vix.is_some_and(|v| v > cfg.vix_low)) {
119        return OptionsRegimeClass::HighVolChop;
120    }
121    if vix.is_some_and(|v| v > cfg.vix_low && v < cfg.vix_high) {
122        return OptionsRegimeClass::ElevatedVol;
123    }
124    if vix.is_some_and(|v| v <= cfg.vix_low) && above_sma_50 && above_sma_200 {
125        return OptionsRegimeClass::LowVolTrend;
126    }
127    OptionsRegimeClass::Neutral
128}
129
130fn preferred_strategy(cfg: &OptionsRegimeConfig, class: OptionsRegimeClass) -> String {
131    cfg.strategy_map
132        .get(class.as_str())
133        .cloned()
134        .unwrap_or_else(|| match class {
135            OptionsRegimeClass::BearishTrend => "call_credit".into(),
136            OptionsRegimeClass::HighVolChop => "iron_condor".into(),
137            OptionsRegimeClass::Hostile => "pause".into(),
138            _ => "put_credit".into(),
139        })
140}
141
142fn neutral_snapshot(cfg: &OptionsRegimeConfig) -> OptionsRegimeSnapshot {
143    OptionsRegimeSnapshot {
144        class: OptionsRegimeClass::Neutral.as_str().to_string(),
145        benchmark_symbol: cfg.benchmark_symbol.clone(),
146        vix_symbol: cfg.vix_symbol.clone(),
147        benchmark_last: 0.0,
148        vix: None,
149        above_sma_50: true,
150        above_sma_200: true,
151        preferred_strategy: "put_credit".into(),
152        pause_entries: false,
153        signals: json!({}),
154    }
155}
156
157async fn fetch_vix(market: &MarketDataApi, symbol: &str) -> Result<f64> {
158    let sym = symbol.trim().to_uppercase();
159    let quote = market
160        .quotes()
161        .get_quote(&sym, Some("quote"), None)
162        .await
163        .with_context(|| format!("VIX quote for {sym}"))?;
164    let last = quote
165        .pointer("/quote/lastPrice")
166        .or_else(|| quote.pointer("/lastPrice"))
167        .or_else(|| quote.get("lastPrice"))
168        .and_then(|v| v.as_f64())
169        .unwrap_or(0.0);
170    if last > 0.0 {
171        Ok(last)
172    } else {
173        anyhow::bail!("missing VIX lastPrice")
174    }
175}
176
177async fn benchmark_trend(market: &MarketDataApi, symbol: &str) -> Result<(f64, bool, bool)> {
178    let history = market
179        .price_history()
180        .get(
181            symbol,
182            Some("year"),
183            Some(1),
184            Some("daily"),
185            None,
186            None,
187            None,
188            None,
189            Some(true),
190        )
191        .await
192        .with_context(|| format!("price history for {symbol}"))?;
193
194    let closes: Vec<f64> = history
195        .get("candles")
196        .and_then(|v| v.as_array())
197        .map(|arr| {
198            arr.iter()
199                .filter_map(|c| c.get("close").and_then(|v| v.as_f64()))
200                .collect()
201        })
202        .unwrap_or_default();
203
204    let last = *closes.last().unwrap_or(&0.0);
205    let sma_50 = sma(&closes, 50);
206    let sma_200 = sma(&closes, 200);
207    Ok((
208        last,
209        sma_50.is_some_and(|s| last >= s),
210        sma_200.is_some_and(|s| last >= s),
211    ))
212}
213
214fn sma(values: &[f64], period: usize) -> Option<f64> {
215    if values.len() < period || period == 0 {
216        return None;
217    }
218    let slice = &values[values.len() - period..];
219    Some(slice.iter().sum::<f64>() / period as f64)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::rules::OptionsRegimeConfig;
226
227    #[test]
228    fn bearish_when_below_both_smas() {
229        let cfg = OptionsRegimeConfig::default();
230        assert_eq!(
231            classify_options_regime(&cfg, Some(18.0), false, false),
232            OptionsRegimeClass::BearishTrend
233        );
234    }
235
236    #[test]
237    fn hostile_when_vix_extreme() {
238        let cfg = OptionsRegimeConfig {
239            pause_entries_vix_above: 30.0,
240            ..Default::default()
241        };
242        assert_eq!(
243            classify_options_regime(&cfg, Some(32.0), true, true),
244            OptionsRegimeClass::Hostile
245        );
246    }
247
248    #[test]
249    fn chop_on_high_vix_with_trend() {
250        let cfg = OptionsRegimeConfig {
251            vix_high: 28.0,
252            pause_entries_vix_above: 35.0,
253            ..Default::default()
254        };
255        assert_eq!(
256            classify_options_regime(&cfg, Some(29.0), true, true),
257            OptionsRegimeClass::HighVolChop
258        );
259    }
260
261    #[test]
262    fn pause_entries_when_vix_at_or_below_floor() {
263        let cfg = OptionsRegimeConfig {
264            pause_entries_vix_below: Some(14.0),
265            pause_entries_vix_above: 30.0,
266            ..Default::default()
267        };
268        let pause = cfg
269            .pause_entries_vix_below
270            .is_some_and(|floor| Some(13.5_f64).is_some_and(|v| v <= floor))
271            || Some(13.5_f64).is_some_and(|v| v >= cfg.pause_entries_vix_above);
272        assert!(pause);
273        let no_pause = !(cfg
274            .pause_entries_vix_below
275            .is_some_and(|floor| Some(16.0_f64).is_some_and(|v| v <= floor))
276            || Some(16.0_f64).is_some_and(|v| v >= cfg.pause_entries_vix_above));
277        assert!(no_pause);
278    }
279}