1use 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}