Skip to main content

schwab_cli/agent/
market_context.rs

1use chrono::NaiveDate;
2use serde_json::{json, Value};
3
4use crate::options::days_to_expiry;
5
6use super::spread_analytics::{
7    analytics_to_json, compute_vertical_analytics, VerticalAnalyticsInput,
8};
9
10/// Live chain fields attached to entry signals and LLM context.
11#[allow(clippy::too_many_arguments)]
12pub fn vertical_entry_market_context(
13    chain: &Value,
14    underlying: &str,
15    expiry: NaiveDate,
16    today: NaiveDate,
17    strike_map: &Value,
18    short_strike: f64,
19    long_strike: f64,
20    width: f64,
21    credit: f64,
22    contracts: f64,
23    is_put_spread: bool,
24    realized_vol_pct: Option<f64>,
25) -> Value {
26    let underlying_quote = chain.get("underlying").cloned().unwrap_or(json!({}));
27    let underlying_price = chain
28        .pointer("/underlying/last")
29        .or_else(|| chain.pointer("/underlying/mark"))
30        .or_else(|| chain.pointer("/underlyingPrice"))
31        .and_then(|v| v.as_f64())
32        .unwrap_or(0.0);
33
34    let dte = days_to_expiry(expiry, today);
35    let short_delta = strike_field(strike_map, short_strike, "delta");
36    let long_delta = strike_field(strike_map, long_strike, "delta");
37    let short_iv = strike_field(strike_map, short_strike, "volatility");
38    let short_theta = strike_field(strike_map, short_strike, "theta");
39    let long_theta = strike_field(strike_map, long_strike, "theta");
40    let chain_iv = chain.get("volatility").and_then(|v| v.as_f64());
41
42    let underlying_change_pct = underlying_quote
43        .pointer("/percentChange")
44        .and_then(|v| v.as_f64());
45
46    let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
47        is_put_spread,
48        underlying_price,
49        short_strike,
50        long_strike,
51        credit,
52        dte,
53        chain_iv_pct: chain_iv.or(short_iv),
54        realized_vol_pct,
55        short_delta,
56        long_delta,
57        short_theta,
58        long_theta,
59        contracts: contracts.max(1.0) as u32,
60        underlying_change_pct,
61    });
62
63    let max_loss_per_spread = analytics.max_loss_per_spread_usd.unwrap_or(0.0);
64
65    json!({
66        "data_source": "schwab_option_chain",
67        "underlying": underlying,
68        "underlying_price": underlying_price,
69        "underlying_bid": underlying_quote.pointer("/bid").and_then(|v| v.as_f64()),
70        "underlying_ask": underlying_quote.pointer("/ask").and_then(|v| v.as_f64()),
71        "underlying_change_pct": underlying_quote.pointer("/percentChange").and_then(|v| v.as_f64()),
72        "chain_iv": chain_iv,
73        "realized_vol_pct": analytics.realized_vol_pct,
74        "iv_rv_ratio": analytics.iv_rv_ratio,
75        "ivr_available": false,
76        "ivr_note": "Schwab chain provides current IV (chain_iv); realized_vol_pct enables IV/RV gate. True IV Rank needs history.",
77        "expiry": expiry.to_string(),
78        "dte": dte,
79        "spread_type": if is_put_spread { "put_credit" } else { "call_credit" },
80        "short_strike": short_strike,
81        "long_strike": long_strike,
82        "width": width,
83        "estimated_credit": credit,
84        "credit_to_width_pct": analytics.credit_to_width_pct,
85        "short_delta": short_delta,
86        "long_delta": long_delta,
87        "short_strike_iv": short_iv,
88        "short_theta": short_theta,
89        "long_theta": long_theta,
90        "net_theta_per_day_usd": analytics.net_theta_per_day_usd,
91        "short_otm_pct": analytics.short_otm_pct,
92        "short_in_the_money": strike_bool(strike_map, short_strike, "inTheMoney"),
93        "break_even_price": analytics.break_even_price,
94        "distance_to_be_pct": analytics.distance_to_be_pct,
95        "expected_move_1sigma_usd": analytics.expected_move_1sigma_usd,
96        "expected_move_1sigma_pct": analytics.expected_move_1sigma_pct,
97        "spread_pop_pct": analytics.spread_pop_pct,
98        "max_loss_per_spread_usd": max_loss_per_spread,
99        "max_loss_total_usd": max_loss_per_spread * contracts,
100        "risk_reward_ratio": analytics.risk_reward_ratio,
101        "contracts": contracts,
102        "analytics": analytics_to_json(&analytics),
103    })
104}
105
106/// Live chain context for an **open** vertical spread (monitor / LLM phase).
107#[allow(clippy::too_many_arguments)]
108pub fn vertical_open_position_context(
109    chain: &Value,
110    underlying: &str,
111    _today: NaiveDate,
112    expiry: NaiveDate,
113    strike_map: &Value,
114    short_strike: f64,
115    long_strike: f64,
116    is_put_spread: bool,
117    entry_credit: Option<f64>,
118    debit_to_close: Option<f64>,
119    profit_pct: Option<f64>,
120    dte: i64,
121    contracts: u32,
122) -> Value {
123    let underlying_quote = chain.get("underlying").cloned().unwrap_or(json!({}));
124    let underlying_price = chain
125        .pointer("/underlying/last")
126        .or_else(|| chain.pointer("/underlying/mark"))
127        .or_else(|| chain.pointer("/underlyingPrice"))
128        .and_then(|v| v.as_f64())
129        .unwrap_or(0.0);
130
131    let short_delta = strike_field(strike_map, short_strike, "delta");
132    let long_delta = strike_field(strike_map, long_strike, "delta");
133    let short_iv = strike_field(strike_map, short_strike, "volatility");
134    let short_theta = strike_field(strike_map, short_strike, "theta");
135    let long_theta = strike_field(strike_map, long_strike, "theta");
136    let short_itm = strike_bool(strike_map, short_strike, "inTheMoney");
137    let chain_iv = chain.get("volatility").and_then(|v| v.as_f64());
138    let width = (short_strike - long_strike).abs();
139    let credit = entry_credit.unwrap_or(0.0);
140
141    let underlying_change_pct = underlying_quote
142        .pointer("/percentChange")
143        .and_then(|v| v.as_f64());
144
145    let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
146        is_put_spread,
147        underlying_price,
148        short_strike,
149        long_strike,
150        credit,
151        dte,
152        chain_iv_pct: chain_iv.or(short_iv),
153        realized_vol_pct: None,
154        short_delta,
155        long_delta,
156        short_theta,
157        long_theta,
158        contracts: contracts.max(1),
159        underlying_change_pct,
160    });
161
162    let distance_to_short_usd = if underlying_price > f64::EPSILON {
163        if is_put_spread {
164            underlying_price - short_strike
165        } else {
166            short_strike - underlying_price
167        }
168    } else {
169        0.0
170    };
171
172    let approx_short_otm_prob_pct = analytics.approx_short_otm_prob_pct;
173    let approx_short_itm_prob_pct =
174        approx_short_otm_prob_pct.map(|otm| (100.0 - otm).clamp(0.0, 100.0));
175
176    let watch_elevated_delta = short_delta.is_some_and(|d| d.abs() >= 0.30);
177    let watch_near_strike = analytics
178        .short_otm_pct
179        .is_some_and(|pct| pct < 2.0 && underlying_price > f64::EPSILON);
180
181    json!({
182        "data_source": "schwab_option_chain",
183        "underlying": underlying,
184        "underlying_price": underlying_price,
185        "underlying_change_pct": underlying_quote.pointer("/percentChange").and_then(|v| v.as_f64()),
186        "expiry": expiry.to_string(),
187        "dte": dte,
188        "spread_type": if is_put_spread { "put_credit" } else { "call_credit" },
189        "short_strike": short_strike,
190        "long_strike": long_strike,
191        "width": width,
192        "entry_credit": entry_credit,
193        "debit_to_close": debit_to_close,
194        "profit_pct": profit_pct,
195        "chain_iv": chain_iv,
196        "short_delta": short_delta,
197        "long_delta": long_delta,
198        "short_strike_iv": short_iv,
199        "short_theta": short_theta,
200        "long_theta": long_theta,
201        "net_theta_per_day_usd": analytics.net_theta_per_day_usd,
202        "short_in_the_money": short_itm,
203        "short_otm_pct": analytics.short_otm_pct,
204        "distance_to_short_strike_usd": distance_to_short_usd,
205        "break_even_price": analytics.break_even_price,
206        "distance_to_be_pct": analytics.distance_to_be_pct,
207        "expected_move_1sigma_usd": analytics.expected_move_1sigma_usd,
208        "expected_move_1sigma_pct": analytics.expected_move_1sigma_pct,
209        "short_strike_inside_1sigma": analytics.short_strike_inside_1sigma,
210        "spread_pop_pct": analytics.spread_pop_pct,
211        "approx_short_otm_probability_pct": approx_short_otm_prob_pct,
212        "approx_short_itm_probability_pct": approx_short_itm_prob_pct,
213        "underlying_change_pct": analytics.underlying_change_pct,
214        "distance_to_short_strike_usd": analytics.distance_to_short_strike_usd,
215        "watch_elevated_delta": watch_elevated_delta,
216        "watch_near_short_strike": watch_near_strike,
217        "analytics": analytics_to_json(&analytics),
218        "note": "spread_pop_pct uses lognormal model at expiry; mechanical exits handle P/L and DTE."
219    })
220}
221
222pub fn market_context_summary_for_llm() -> Value {
223    json!({
224        "data_source": "schwab_option_chain",
225        "ivr_available": false,
226        "ivr_note": "IV Rank unavailable; chain_iv on each candidate is current implied vol — not a data outage",
227        "selection_guardrails": "Do not veto entries for vague missing chain data when candidate market_context has underlying_price and short_delta. Name specific null fields only.",
228        "note": "candidate_entries[] and open_positions[].market_context include live price, delta, theta, POP vs break-even, expected move, and DTE from Schwab. Use market_context for monitor decisions — do not guess greeks."
229    })
230}
231
232pub fn strike_field(strike_map: &Value, strike: f64, field: &str) -> Option<f64> {
233    let obj = strike_map.as_object()?;
234    for key in strike_key_candidates(strike) {
235        if let Some(contracts) = obj.get(&key) {
236            if let Some(v) = contracts.as_array()?.first()?.get(field)?.as_f64() {
237                return Some(v);
238            }
239        }
240    }
241    None
242}
243
244fn strike_bool(strike_map: &Value, strike: f64, field: &str) -> Option<bool> {
245    let obj = strike_map.as_object()?;
246    for key in strike_key_candidates(strike) {
247        if let Some(contracts) = obj.get(&key) {
248            if let Some(v) = contracts.as_array()?.first()?.get(field)?.as_bool() {
249                return Some(v);
250            }
251        }
252    }
253    None
254}
255
256fn strike_key_candidates(strike: f64) -> Vec<String> {
257    vec![
258        format!("{strike:.1}"),
259        format!("{strike:.0}"),
260        strike.to_string(),
261    ]
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn builds_vertical_market_context() {
270        let chain = json!({
271            "underlying": { "last": 298.0, "bid": 297.9, "ask": 298.1, "percentChange": 0.5 },
272            "underlyingPrice": 298.0,
273            "volatility": 29.0,
274            "putExpDateMap": {}
275        });
276        let put_map = json!({
277            "283.0": [{ "delta": -0.22, "volatility": 30.1, "theta": -0.07, "inTheMoney": false }],
278            "281.0": [{ "delta": -0.15, "volatility": 29.5, "theta": -0.04, "inTheMoney": false }]
279        });
280        let ctx = vertical_entry_market_context(
281            &chain,
282            "IWM",
283            NaiveDate::from_ymd_opt(2026, 7, 24).unwrap(),
284            NaiveDate::from_ymd_opt(2026, 6, 24).unwrap(),
285            &put_map,
286            283.0,
287            281.0,
288            2.0,
289            0.30,
290            1.0,
291            true,
292            Some(20.0),
293        );
294        assert_eq!(ctx["underlying_price"], 298.0);
295        assert_eq!(ctx["chain_iv"], 29.0);
296        assert_eq!(ctx["short_delta"], -0.22);
297        assert!((ctx["credit_to_width_pct"].as_f64().unwrap() - 15.0).abs() < 0.01);
298        assert!(ctx["spread_pop_pct"].as_f64().unwrap() > 50.0);
299        assert!(ctx["analytics"]["break_even_price"].as_f64().is_some());
300    }
301
302    #[test]
303    fn builds_open_position_context_with_greeks() {
304        let chain = json!({
305            "underlying": { "last": 299.0, "percentChange": -1.2 },
306            "volatility": 31.0,
307        });
308        let put_map = json!({
309            "282.0": [{ "delta": -0.32, "volatility": 28.0, "theta": -0.09, "inTheMoney": false }],
310            "280.0": [{ "delta": -0.20, "volatility": 27.0, "theta": -0.05, "inTheMoney": false }]
311        });
312        let ctx = vertical_open_position_context(
313            &chain,
314            "IWM",
315            NaiveDate::from_ymd_opt(2026, 6, 25).unwrap(),
316            NaiveDate::from_ymd_opt(2026, 7, 31).unwrap(),
317            &put_map,
318            282.0,
319            280.0,
320            true,
321            Some(0.25),
322            Some(0.18),
323            Some(28.0),
324            36,
325            1,
326        );
327        assert_eq!(ctx["short_delta"], -0.32);
328        assert!(ctx["short_otm_pct"].as_f64().unwrap() > 5.0);
329        assert!(ctx["watch_elevated_delta"].as_bool().unwrap());
330        assert!(ctx["spread_pop_pct"].as_f64().unwrap() > 60.0);
331        assert!(ctx["net_theta_per_day_usd"].as_f64().unwrap() > 0.0);
332    }
333}