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#[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#[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}