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