Skip to main content

schwab_cli/agent/
exits.rs

1use anyhow::{Context, Result};
2use chrono::NaiveDate;
3use schwab_market_data::endpoints::chains::ChainQuery;
4use schwab_market_data::MarketDataApi;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7
8use crate::options::{
9    days_to_expiry, group_option_legs, list_option_positions, position_group_id,
10    spread_contract_count, OptionPositionGroup, OptionPositionLeg,
11};
12use crate::rules::{ExitRules, RulesConfig};
13
14use super::market_context::vertical_open_position_context;
15use super::state::{AgentState, TrackedPosition};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SpreadMark {
19    pub entry_credit: f64,
20    pub debit_to_close: f64,
21    pub profit_pct: f64,
22    pub dte: i64,
23    pub source: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExitEvaluation {
28    pub reason: String,
29    pub mark: SpreadMark,
30}
31
32pub fn stable_position_key(account_hash: &str, group: &OptionPositionGroup) -> String {
33    position_group_id(account_hash, group)
34}
35
36pub fn find_tracked_position<'a>(
37    state: &'a AgentState,
38    account_hash: &str,
39    group: &OptionPositionGroup,
40) -> Option<&'a TrackedPosition> {
41    let stable_key = stable_position_key(account_hash, group);
42    state
43        .open_positions
44        .get(&stable_key)
45        .or_else(|| state.open_positions.get(&group.id))
46        .or_else(|| {
47            state.open_positions.values().find(|p| {
48                p.account_hash == account_hash
49                    && p.underlying == group.underlying
50                    && p.expiry == group.expiry
51            })
52        })
53}
54
55pub fn infer_entry_credit_from_legs(legs: &[OptionPositionLeg]) -> Option<f64> {
56    if legs.len() != 2 {
57        return None;
58    }
59    let mut short_premium = None;
60    let mut long_premium = None;
61    for leg in legs {
62        let avg = leg.average_price?;
63        if leg.quantity < 0.0 {
64            short_premium = Some(avg.abs());
65        } else if leg.quantity > 0.0 {
66            long_premium = Some(avg.abs());
67        }
68    }
69    match (short_premium, long_premium) {
70        (Some(s), Some(l)) => Some((s - l).max(0.0)),
71        (Some(s), None) => Some(s),
72        _ => None,
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct PositionMonitorResult {
78    pub exit: Option<ExitEvaluation>,
79    pub snapshot: Value,
80}
81
82struct VerticalChainSnapshot {
83    chain: Value,
84    strike_map: Value,
85    short_strike: f64,
86    long_strike: f64,
87    is_put: bool,
88    debit_to_close: f64,
89}
90
91/// Evaluate mechanical exit rules and build an LLM-ready monitor snapshot (single chain fetch).
92pub async fn evaluate_position_monitor(
93    market: &MarketDataApi,
94    group: &OptionPositionGroup,
95    rules: &RulesConfig,
96    today: NaiveDate,
97    tracked: Option<&TrackedPosition>,
98) -> Result<PositionMonitorResult> {
99    let entry_credit = tracked
100        .and_then(|p| p.entry_credit)
101        .or_else(|| infer_entry_credit_from_legs(&group.legs));
102
103    let dte = group
104        .legs
105        .first()
106        .and_then(|l| l.parsed.as_ref())
107        .map(|p| days_to_expiry(p.expiry, today))
108        .unwrap_or(0);
109
110    let chain_result = fetch_vertical_chain_snapshot(market, group).await;
111
112    let (exit, mark_opt, market_context, chain_error) = match chain_result {
113        Ok(chain_snap) => {
114            let profit_pct = entry_credit
115                .filter(|c| *c > f64::EPSILON)
116                .map(|entry| ((entry - chain_snap.debit_to_close) / entry) * 100.0);
117            let mark = SpreadMark {
118                entry_credit: entry_credit.unwrap_or(0.0),
119                debit_to_close: chain_snap.debit_to_close,
120                profit_pct: profit_pct.unwrap_or(0.0),
121                dte,
122                source: "chain".into(),
123            };
124            let exit = evaluate_exit_from_mark(rules, entry_credit, &mark);
125            let expiry_date = chrono::NaiveDate::parse_from_str(&group.expiry, "%Y-%m-%d")
126                .ok()
127                .or_else(|| {
128                    group
129                        .legs
130                        .first()
131                        .and_then(|l| l.parsed.as_ref())
132                        .map(|p| p.expiry)
133                })
134                .unwrap_or(today);
135            let ctx = vertical_open_position_context(
136                &chain_snap.chain,
137                &group.underlying,
138                today,
139                expiry_date,
140                &chain_snap.strike_map,
141                chain_snap.short_strike,
142                chain_snap.long_strike,
143                chain_snap.is_put,
144                entry_credit,
145                Some(chain_snap.debit_to_close),
146                profit_pct,
147                dte,
148            );
149            (exit, Some(mark), Some(ctx), None)
150        }
151        Err(e) => {
152            let exit = if let Some(credit) = entry_credit.filter(|c| *c > 0.0) {
153                evaluate_dte_only_with_credit(group, rules, today, credit, dte)?
154            } else {
155                evaluate_dte_only(group, rules, today)?
156            };
157            (exit, None, None, Some(e.to_string()))
158        }
159    };
160
161    let snapshot = monitor_snapshot_json(
162        group,
163        tracked,
164        &exit,
165        mark_opt.as_ref(),
166        market_context,
167        chain_error.as_deref(),
168        &rules.exit_rules,
169    );
170    Ok(PositionMonitorResult { exit, snapshot })
171}
172
173fn evaluate_exit_from_mark(
174    rules: &RulesConfig,
175    entry_credit: Option<f64>,
176    mark: &SpreadMark,
177) -> Option<ExitEvaluation> {
178    let entry_credit = entry_credit.filter(|c| *c > f64::EPSILON)?;
179    let mark = SpreadMark {
180        entry_credit,
181        ..mark.clone()
182    };
183
184    if mark.profit_pct >= rules.exit_rules.profit_target_pct {
185        return Some(ExitEvaluation {
186            reason: "profit_target".into(),
187            mark,
188        });
189    }
190
191    let stop_debit = entry_credit * (rules.exit_rules.stop_loss_pct / 100.0);
192    if mark.debit_to_close >= stop_debit {
193        return Some(ExitEvaluation {
194            reason: "stop_loss".into(),
195            mark,
196        });
197    }
198
199    if mark.dte <= rules.exit_rules.dte_close as i64 {
200        return Some(ExitEvaluation {
201            reason: "dte_close".into(),
202            mark,
203        });
204    }
205
206    None
207}
208
209async fn fetch_vertical_chain_snapshot(
210    market: &MarketDataApi,
211    group: &OptionPositionGroup,
212) -> Result<VerticalChainSnapshot> {
213    let (short_leg, long_leg) = vertical_legs(group)?;
214    let short_strike = short_leg
215        .parsed
216        .as_ref()
217        .map(|p| p.strike)
218        .context("short leg missing strike")?;
219    let long_strike = long_leg
220        .parsed
221        .as_ref()
222        .map(|p| p.strike)
223        .context("long leg missing strike")?;
224    let is_put = short_leg.parsed.as_ref().is_some_and(|p| p.put_call == 'P');
225
226    let contract_type = if is_put { "PUT" } else { "CALL" };
227    let map_key = if is_put {
228        "putExpDateMap"
229    } else {
230        "callExpDateMap"
231    };
232
233    let mut last_err = None;
234    for strike_count in [50u32, 100] {
235        match fetch_vertical_chain_at_strikes(
236            market,
237            group,
238            contract_type,
239            map_key,
240            short_strike,
241            long_strike,
242            is_put,
243            strike_count,
244        )
245        .await
246        {
247            Ok(snap) => return Ok(snap),
248            Err(e) => last_err = Some(e),
249        }
250    }
251
252    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chain fetch failed")))
253}
254
255#[allow(clippy::too_many_arguments)]
256async fn fetch_vertical_chain_at_strikes(
257    market: &MarketDataApi,
258    group: &OptionPositionGroup,
259    contract_type: &str,
260    map_key: &str,
261    short_strike: f64,
262    long_strike: f64,
263    is_put: bool,
264    strike_count: u32,
265) -> Result<VerticalChainSnapshot> {
266    let strike_anchor = format_chain_strike(short_strike);
267    let chain = market
268        .chains()
269        .get(&ChainQuery {
270            symbol: &group.underlying,
271            contract_type: Some(contract_type),
272            strike: Some(&strike_anchor),
273            strike_count: Some(strike_count),
274            include_underlying_quote: Some(true),
275            from_date: Some(&group.expiry),
276            to_date: Some(&group.expiry),
277            ..Default::default()
278        })
279        .await?;
280
281    let strike_map =
282        find_expiry_strikes(&chain, map_key, &group.expiry).context("expiry not found in chain")?;
283
284    let short_ask = strike_quote_field(&strike_map, short_strike, "ask")?;
285    let long_bid = strike_quote_field(&strike_map, long_strike, "bid")?;
286    let debit_to_close = (short_ask - long_bid).max(0.0);
287
288    Ok(VerticalChainSnapshot {
289        chain,
290        strike_map,
291        short_strike,
292        long_strike,
293        is_put,
294        debit_to_close,
295    })
296}
297
298fn format_chain_strike(strike: f64) -> String {
299    if (strike.fract() * 10.0).round() as i64 % 10 == 0 {
300        format!("{strike:.1}")
301    } else {
302        format!("{strike:.2}")
303    }
304}
305
306pub fn monitor_snapshot_json(
307    group: &OptionPositionGroup,
308    tracked: Option<&TrackedPosition>,
309    exit_eval: &Option<ExitEvaluation>,
310    mark: Option<&SpreadMark>,
311    market_context: Option<Value>,
312    chain_error: Option<&str>,
313    exit_rules: &ExitRules,
314) -> Value {
315    let entry_credit = tracked
316        .and_then(|p| p.entry_credit)
317        .or_else(|| infer_entry_credit_from_legs(&group.legs));
318
319    let status = match exit_eval {
320        Some(e) => format!("exit: {}", e.reason),
321        None => "holding".into(),
322    };
323
324    let contracts = tracked
325        .map(|p| p.contracts.max(1))
326        .unwrap_or_else(|| spread_contract_count(group));
327
328    let mut snapshot = json!({
329        "position_id": tracked
330            .map(|t| t.position_id.as_str())
331            .unwrap_or(group.id.as_str()),
332        "legacy_position_id": group.id,
333        "underlying": group.underlying,
334        "expiry": group.expiry,
335        "strategy": tracked
336            .map(|t| t.strategy.as_str())
337            .unwrap_or_else(|| group.strategy_hint.as_str()),
338        "contracts": contracts,
339        "entry_credit": entry_credit,
340        "max_loss_usd": tracked.map(|p| p.max_loss_usd),
341        "net_market_value": group.net_market_value,
342        "status": status,
343    });
344
345    if let Some(eval) = exit_eval {
346        snapshot["profit_pct"] = json!(eval.mark.profit_pct);
347        snapshot["dte"] = json!(eval.mark.dte);
348        snapshot["debit_to_close"] = json!(eval.mark.debit_to_close);
349    } else if let Some(m) = mark {
350        snapshot["profit_pct"] = json!(m.profit_pct);
351        snapshot["dte"] = json!(m.dte);
352        snapshot["debit_to_close"] = json!(m.debit_to_close);
353    }
354
355    if let Some(ctx) = market_context {
356        snapshot["market_context"] = ctx;
357    } else if let Some(err) = chain_error {
358        snapshot["market_context_error"] = json!(err);
359        snapshot["market_context_note"] = json!(
360            "Live chain greeks unavailable; mechanical exits still use chain debit when fetch succeeds on exit ticks."
361        );
362    }
363
364    if let Some(m) = mark.or(exit_eval.as_ref().map(|e| &e.mark)) {
365        let entry = m.entry_credit;
366        let stop_debit = entry * (exit_rules.stop_loss_pct / 100.0);
367        snapshot["mechanical_rules"] = json!({
368            "profit_target_pct": exit_rules.profit_target_pct,
369            "stop_loss_pct": exit_rules.stop_loss_pct,
370            "stop_debit_threshold_per_share": stop_debit,
371            "current_debit_to_close": m.debit_to_close,
372            "stop_triggered": m.debit_to_close >= stop_debit,
373            "profit_target_triggered": m.profit_pct >= exit_rules.profit_target_pct,
374            "note": "Mechanical exits use debit_to_close from the chain, NOT net_market_value. If stop_triggered is false, do not alert that the stop was hit."
375        });
376    }
377
378    snapshot["net_market_value_note"] = json!(
379        "Schwab leg market_value sum in dollars; not comparable to per-share entry_credit or stop_debit_threshold."
380    );
381
382    snapshot
383}
384
385fn evaluate_dte_only(
386    group: &OptionPositionGroup,
387    rules: &RulesConfig,
388    today: NaiveDate,
389) -> Result<Option<ExitEvaluation>> {
390    let dte = group
391        .legs
392        .first()
393        .and_then(|l| l.parsed.as_ref())
394        .map(|p| days_to_expiry(p.expiry, today))
395        .unwrap_or(0);
396    if dte > rules.exit_rules.dte_close as i64 {
397        return Ok(None);
398    }
399    Ok(Some(ExitEvaluation {
400        reason: "dte_close".into(),
401        mark: SpreadMark {
402            entry_credit: 0.0,
403            debit_to_close: 0.0,
404            profit_pct: 0.0,
405            dte,
406            source: "dte_only".into(),
407        },
408    }))
409}
410
411fn evaluate_dte_only_with_credit(
412    _group: &OptionPositionGroup,
413    rules: &RulesConfig,
414    _today: NaiveDate,
415    entry_credit: f64,
416    dte: i64,
417) -> Result<Option<ExitEvaluation>> {
418    if dte > rules.exit_rules.dte_close as i64 {
419        return Ok(None);
420    }
421    Ok(Some(ExitEvaluation {
422        reason: "dte_close".into(),
423        mark: SpreadMark {
424            entry_credit,
425            debit_to_close: 0.0,
426            profit_pct: 0.0,
427            dte,
428            source: "dte_fallback".into(),
429        },
430    }))
431}
432
433fn vertical_legs(group: &OptionPositionGroup) -> Result<(&OptionPositionLeg, &OptionPositionLeg)> {
434    let short = group
435        .legs
436        .iter()
437        .find(|l| l.quantity < 0.0)
438        .context("no short leg")?;
439    let long = group
440        .legs
441        .iter()
442        .find(|l| l.quantity > 0.0)
443        .context("no long leg")?;
444    Ok((short, long))
445}
446
447fn find_expiry_strikes(chain: &Value, map_key: &str, expiry: &str) -> Result<Value> {
448    let map = chain
449        .get(map_key)
450        .context("chain missing exp date map")?
451        .as_object()
452        .context("exp date map not an object")?;
453
454    for (key, strikes) in map {
455        let date_part = key.split(':').next().unwrap_or(key);
456        if date_part == expiry || key.starts_with(expiry) {
457            return Ok(strikes.clone());
458        }
459    }
460    anyhow::bail!("expiry {expiry} not in chain")
461}
462
463fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
464    for key in strike_key_candidates(strike) {
465        if let Some(val) = strike_map
466            .get(&key)
467            .and_then(|contracts| contracts.as_array()?.first())
468            .and_then(|c| c.get(field))
469            .and_then(|v| v.as_f64())
470        {
471            return Ok(val);
472        }
473    }
474    anyhow::bail!("missing {field} for strike {strike}")
475}
476
477fn strike_key_candidates(strike: f64) -> Vec<String> {
478    vec![
479        format!("{strike:.1}"),
480        format!("{strike:.0}"),
481        strike.to_string(),
482    ]
483}
484
485pub fn exit_signal_json_for_account(
486    account_hash: &str,
487    group: &OptionPositionGroup,
488    eval: &ExitEvaluation,
489) -> Value {
490    let position_id = stable_position_key(account_hash, group);
491    json!({
492        "type": "exit",
493        "reason": eval.reason,
494        "position_id": position_id,
495        "legacy_position_id": group.id,
496        "underlying": group.underlying,
497        "expiry": group.expiry,
498        "mark": eval.mark,
499    })
500}
501
502pub async fn reconcile_open_positions(
503    trader: &schwab_api::TraderApi,
504    state: &mut AgentState,
505    rules: &RulesConfig,
506) -> Result<()> {
507    let mut live_keys = std::collections::HashSet::new();
508    for account in rules.enabled_accounts() {
509        let legs = list_option_positions(trader, Some(&account.hash)).await?;
510        let groups = group_option_legs(&legs);
511        for group in groups {
512            let stable_id = stable_position_key(&account.hash, &group);
513            live_keys.insert(stable_id.clone());
514            let live_contracts = spread_contract_count(&group);
515            let entry_credit = infer_entry_credit_from_legs(&group.legs);
516            let inferred_max_loss = infer_max_loss_from_group(&group);
517
518            if let Some(mut tracked) =
519                take_existing_tracked_position(state, &stable_id, &account.hash, &group)
520            {
521                tracked.position_id = stable_id.clone();
522                tracked.account_hash = account.hash.clone();
523                let prev_contracts = tracked.contracts.max(1);
524                if let Some(max_loss) = inferred_max_loss {
525                    tracked.max_loss_usd = max_loss;
526                } else if live_contracts != prev_contracts && tracked.max_loss_usd > 0.0 {
527                    let per_contract = tracked.max_loss_usd / prev_contracts as f64;
528                    tracked.max_loss_usd = per_contract * live_contracts as f64;
529                }
530                tracked.contracts = live_contracts;
531                if entry_credit.is_some() {
532                    tracked.entry_credit = entry_credit;
533                }
534                state.open_positions.insert(stable_id, tracked);
535            } else {
536                state.open_positions.insert(
537                    stable_id.clone(),
538                    TrackedPosition {
539                        position_id: stable_id,
540                        account_hash: account.hash.clone(),
541                        underlying: group.underlying.clone(),
542                        expiry: group.expiry.clone(),
543                        strategy: group.strategy_hint.clone(),
544                        opened_at: chrono::Utc::now(),
545                        entry_credit,
546                        max_loss_usd: inferred_max_loss.unwrap_or(0.0),
547                        contracts: live_contracts,
548                    },
549                );
550            }
551        }
552    }
553    state.open_positions.retain(|id, _| live_keys.contains(id));
554    Ok(())
555}
556
557fn take_existing_tracked_position(
558    state: &mut AgentState,
559    stable_id: &str,
560    account_hash: &str,
561    group: &OptionPositionGroup,
562) -> Option<TrackedPosition> {
563    if let Some(tracked) = state.open_positions.remove(stable_id) {
564        return Some(tracked);
565    }
566    if let Some(tracked) = state.open_positions.remove(&group.id) {
567        return Some(tracked);
568    }
569    let key = state.open_positions.iter().find_map(|(key, tracked)| {
570        (tracked.account_hash == account_hash
571            && tracked.underlying == group.underlying
572            && tracked.expiry == group.expiry
573            && tracked.strategy == group.strategy_hint)
574            .then(|| key.clone())
575    })?;
576    state.open_positions.remove(&key)
577}
578
579pub fn infer_max_loss_from_group(group: &OptionPositionGroup) -> Option<f64> {
580    let contracts = spread_contract_count(group) as f64;
581    let entry_credit = infer_entry_credit_from_legs(&group.legs).unwrap_or(0.0);
582    match group.strategy_hint.as_str() {
583        "vertical" => {
584            let (short, long) = vertical_legs(group).ok()?;
585            let short_strike = short.parsed.as_ref()?.strike;
586            let long_strike = long.parsed.as_ref()?.strike;
587            let width = (short_strike - long_strike).abs();
588            Some((width - entry_credit).max(0.0) * 100.0 * contracts)
589        }
590        "iron_condor" => {
591            let put_width = wing_width(group, 'P')?;
592            let call_width = wing_width(group, 'C')?;
593            Some((put_width.max(call_width) - entry_credit).max(0.0) * 100.0 * contracts)
594        }
595        _ => None,
596    }
597}
598
599fn wing_width(group: &OptionPositionGroup, put_call: char) -> Option<f64> {
600    let mut short = None;
601    let mut long = None;
602    for leg in &group.legs {
603        let parsed = leg.parsed.as_ref()?;
604        if parsed.put_call != put_call {
605            continue;
606        }
607        if leg.quantity < 0.0 {
608            short = Some(parsed.strike);
609        } else if leg.quantity > 0.0 {
610            long = Some(parsed.strike);
611        }
612    }
613    Some((short? - long?).abs())
614}
615
616pub fn exit_rules_summary(rules: &ExitRules) -> Value {
617    json!({
618        "profit_target_pct": rules.profit_target_pct,
619        "stop_loss_pct": rules.stop_loss_pct,
620        "dte_close": rules.dte_close,
621    })
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::rules::{ExitRules, RulesConfig};
628
629    #[test]
630    fn evaluate_exit_from_mark_profit_target() {
631        let exit_rules = ExitRules {
632            profit_target_pct: 50.0,
633            stop_loss_pct: 200.0,
634            dte_close: 21,
635        };
636        let rules = RulesConfig {
637            version: 1,
638            agent_id: "t".into(),
639            accounts: vec![],
640            schedule: Default::default(),
641            strategies: Default::default(),
642            watchlist: vec![],
643            entry_rules: Default::default(),
644            exit_rules,
645            risk: Default::default(),
646            execution: Default::default(),
647            llm: Default::default(),
648            notify: Default::default(),
649        };
650        let mark = SpreadMark {
651            entry_credit: 0.25,
652            debit_to_close: 0.10,
653            profit_pct: 60.0,
654            dte: 30,
655            source: "test".into(),
656        };
657        let exit = evaluate_exit_from_mark(&rules, Some(0.25), &mark);
658        assert_eq!(
659            exit.as_ref().map(|e| e.reason.as_str()),
660            Some("profit_target")
661        );
662    }
663
664    #[test]
665    fn profit_target_triggers_at_half_credit() {
666        let entry = 0.29;
667        let debit = 0.14;
668        let profit_pct = ((entry - debit) / entry) * 100.0;
669        assert!(profit_pct >= 50.0);
670    }
671
672    #[test]
673    fn stop_loss_triggers_at_double_credit() {
674        let entry = 0.29;
675        let stop_debit = entry * 2.0;
676        assert!(0.58 >= stop_debit - 0.001);
677    }
678
679    #[test]
680    fn infers_credit_from_leg_averages() {
681        let legs = vec![
682            OptionPositionLeg {
683                symbol: "IWM".into(),
684                underlying: "IWM".into(),
685                quantity: -1.0,
686                market_value: -100.0,
687                average_price: Some(0.29),
688                parsed: None,
689            },
690            OptionPositionLeg {
691                symbol: "IWM".into(),
692                underlying: "IWM".into(),
693                quantity: 1.0,
694                market_value: 50.0,
695                average_price: Some(0.05),
696                parsed: None,
697            },
698        ];
699        let credit = infer_entry_credit_from_legs(&legs).unwrap();
700        assert!((credit - 0.24).abs() < 0.001);
701    }
702
703    #[test]
704    fn infers_vertical_max_loss_from_live_group() {
705        let group = OptionPositionGroup {
706            id: "IWM|2026-07-31".into(),
707            underlying: "IWM".into(),
708            expiry: "2026-07-31".into(),
709            strategy_hint: "vertical".into(),
710            legs: vec![
711                OptionPositionLeg {
712                    symbol: "IWM   260731P00282000".into(),
713                    underlying: "IWM".into(),
714                    quantity: -2.0,
715                    market_value: -64.0,
716                    average_price: Some(0.29),
717                    parsed: crate::options::symbology::parse_option_symbol("IWM   260731P00282000")
718                        .ok(),
719                },
720                OptionPositionLeg {
721                    symbol: "IWM   260731P00280000".into(),
722                    underlying: "IWM".into(),
723                    quantity: 2.0,
724                    market_value: 10.0,
725                    average_price: Some(0.05),
726                    parsed: crate::options::symbology::parse_option_symbol("IWM   260731P00280000")
727                        .ok(),
728                },
729            ],
730            net_market_value: -54.0,
731        };
732        let max_loss = infer_max_loss_from_group(&group).unwrap();
733        assert!((max_loss - 352.0).abs() < 0.01);
734    }
735
736    #[test]
737    fn find_expiry_strikes_matches_schwab_key() {
738        let chain = json!({
739            "putExpDateMap": {
740                "2026-07-31:36": { "282.0": [] }
741            }
742        });
743        let strikes = find_expiry_strikes(&chain, "putExpDateMap", "2026-07-31").unwrap();
744        assert!(strikes.is_object());
745    }
746
747    #[test]
748    fn format_chain_strike_uses_one_decimal_for_whole_strikes() {
749        assert_eq!(format_chain_strike(282.0), "282.0");
750        assert_eq!(format_chain_strike(282.5), "282.50");
751    }
752}