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 expiry_date = chrono::NaiveDate::parse_from_str(&group.expiry, "%Y-%m-%d")
129                .ok()
130                .or_else(|| {
131                    group
132                        .legs
133                        .first()
134                        .and_then(|l| l.parsed.as_ref())
135                        .map(|p| p.expiry)
136                })
137                .unwrap_or(today);
138            let contracts = spread_contract_count(group).max(1);
139            let ctx = vertical_open_position_context(
140                &chain_snap.chain,
141                &group.underlying,
142                today,
143                expiry_date,
144                &chain_snap.strike_map,
145                chain_snap.short_strike,
146                chain_snap.long_strike,
147                chain_snap.is_put,
148                entry_credit,
149                Some(chain_snap.debit_to_close),
150                profit_pct,
151                dte,
152                contracts,
153            );
154            let analytics = analytics_from_json(ctx.get("analytics").unwrap_or(&json!({})));
155            let exit = evaluate_all_exits(
156                rules,
157                entry_credit,
158                &mark,
159                analytics.as_ref(),
160                tracked.and_then(|p| p.peak_profit_pct),
161                tracked.map(|p| p.opened_at),
162            );
163            (exit, Some(mark), analytics, Some(ctx), None)
164        }
165        Err(e) => {
166            let exit = if let Some(credit) = entry_credit.filter(|c| *c > 0.0) {
167                evaluate_dte_only_with_credit(group, rules, today, credit, dte)?
168            } else {
169                evaluate_dte_only(group, rules, today)?
170            };
171            (exit, None, None, None, Some(e.to_string()))
172        }
173    };
174
175    let snapshot = monitor_snapshot_json(
176        group,
177        tracked,
178        &exit,
179        mark_opt.as_ref(),
180        market_context,
181        chain_error.as_deref(),
182        &rules.exit_rules,
183    );
184    Ok(PositionMonitorResult {
185        exit,
186        mark: mark_opt,
187        analytics,
188        snapshot,
189    })
190}
191
192pub fn evaluate_exit_from_mark(
193    rules: &RulesConfig,
194    entry_credit: Option<f64>,
195    mark: &SpreadMark,
196) -> Option<ExitEvaluation> {
197    let entry_credit = entry_credit.filter(|c| *c > f64::EPSILON)?;
198    let mark = SpreadMark {
199        entry_credit,
200        ..mark.clone()
201    };
202
203    if mark.profit_pct >= rules.exit_rules.profit_target_pct {
204        return Some(ExitEvaluation {
205            reason: "profit_target".into(),
206            mark,
207        });
208    }
209
210    let stop_debit = entry_credit * (rules.exit_rules.stop_loss_pct / 100.0);
211    if mark.debit_to_close >= stop_debit {
212        return Some(ExitEvaluation {
213            reason: "stop_loss".into(),
214            mark,
215        });
216    }
217
218    if mark.dte <= rules.exit_rules.dte_close as i64 {
219        return Some(ExitEvaluation {
220            reason: "dte_close".into(),
221            mark,
222        });
223    }
224
225    None
226}
227
228/// Primary + thesis deterioration exits (evaluated every tick when chain data is live).
229/// Profit target / stop / DTE always apply; thesis exits respect `min_hold_minutes`.
230pub fn evaluate_all_exits(
231    rules: &RulesConfig,
232    entry_credit: Option<f64>,
233    mark: &SpreadMark,
234    analytics: Option<&SpreadAnalytics>,
235    peak_profit_pct: Option<f64>,
236    opened_at: Option<chrono::DateTime<chrono::Utc>>,
237) -> Option<ExitEvaluation> {
238    if let Some(exit) = evaluate_exit_from_mark(rules, entry_credit, mark) {
239        return Some(exit);
240    }
241    let Some(analytics) = analytics else {
242        return None;
243    };
244    if thesis_min_hold_active(rules, opened_at) {
245        return None;
246    }
247    evaluate_thesis_exit(rules, entry_credit, mark, analytics, peak_profit_pct)
248}
249
250fn thesis_min_hold_active(
251    rules: &RulesConfig,
252    opened_at: Option<chrono::DateTime<chrono::Utc>>,
253) -> bool {
254    let Some(min_hold) = rules.exit_rules.thesis.min_hold_minutes.filter(|m| *m > 0) else {
255        return false;
256    };
257    let Some(opened) = opened_at else {
258        return false;
259    };
260    chrono::Utc::now()
261        .signed_duration_since(opened)
262        .num_minutes()
263        < min_hold as i64
264}
265
266/// True when live analytics already breach an enabled thesis exit gate (entry veto).
267/// Ignores profit_giveback (needs peak history) and min_hold.
268pub fn candidate_fails_thesis_gates(
269    rules: &RulesConfig,
270    analytics: &SpreadAnalytics,
271) -> Option<&'static str> {
272    let thesis = &rules.exit_rules.thesis;
273    if !thesis.enabled {
274        return None;
275    }
276    if let Some(min_pop) = thesis.min_pop_pct_exit {
277        if analytics.spread_pop_pct.is_some_and(|pop| pop < min_pop) {
278            return Some("thesis_pop_deterioration");
279        }
280    }
281    if let Some(max_delta) = thesis.max_short_delta_exit {
282        if analytics
283            .short_delta
284            .is_some_and(|delta| delta.abs() >= max_delta)
285        {
286            return Some("thesis_delta_breach");
287        }
288    }
289    if let Some(min_otm) = thesis.min_short_otm_pct {
290        if analytics.short_otm_pct.is_some_and(|otm| otm < min_otm) {
291            return Some("thesis_near_strike");
292        }
293    }
294    if thesis.exit_short_inside_1sigma && analytics.short_strike_inside_1sigma == Some(true) {
295        return Some("thesis_inside_1sigma");
296    }
297    None
298}
299
300pub fn evaluate_thesis_exit(
301    rules: &RulesConfig,
302    entry_credit: Option<f64>,
303    mark: &SpreadMark,
304    analytics: &SpreadAnalytics,
305    peak_profit_pct: Option<f64>,
306) -> Option<ExitEvaluation> {
307    let thesis = &rules.exit_rules.thesis;
308    if !thesis.enabled {
309        return None;
310    }
311    let entry_credit = entry_credit.filter(|c| *c > f64::EPSILON)?;
312    let mark = SpreadMark {
313        entry_credit,
314        ..mark.clone()
315    };
316
317    if let Some(gb) = &thesis.profit_giveback {
318        if let Some(peak) = peak_profit_pct {
319            if peak >= gb.peak_profit_min_pct && mark.profit_pct < gb.exit_if_below_pct {
320                return Some(ExitEvaluation {
321                    reason: "thesis_profit_giveback".into(),
322                    mark,
323                });
324            }
325        }
326    }
327
328    if let Some(reason) = candidate_fails_thesis_gates(rules, analytics) {
329        return Some(ExitEvaluation {
330            reason: reason.into(),
331            mark,
332        });
333    }
334
335    None
336}
337
338async fn fetch_vertical_chain_snapshot(
339    market: &MarketDataApi,
340    group: &OptionPositionGroup,
341) -> Result<VerticalChainSnapshot> {
342    let (short_leg, long_leg) = vertical_legs(group)?;
343    let short_strike = short_leg
344        .parsed
345        .as_ref()
346        .map(|p| p.strike)
347        .context("short leg missing strike")?;
348    let long_strike = long_leg
349        .parsed
350        .as_ref()
351        .map(|p| p.strike)
352        .context("long leg missing strike")?;
353    let is_put = short_leg.parsed.as_ref().is_some_and(|p| p.put_call == 'P');
354
355    let contract_type = if is_put { "PUT" } else { "CALL" };
356    let map_key = if is_put {
357        "putExpDateMap"
358    } else {
359        "callExpDateMap"
360    };
361
362    let mut last_err = None;
363    for strike_count in [50u32, 100] {
364        match fetch_vertical_chain_at_strikes(
365            market,
366            group,
367            contract_type,
368            map_key,
369            short_strike,
370            long_strike,
371            is_put,
372            strike_count,
373        )
374        .await
375        {
376            Ok(snap) => return Ok(snap),
377            Err(e) => last_err = Some(e),
378        }
379    }
380
381    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chain fetch failed")))
382}
383
384#[allow(clippy::too_many_arguments)]
385async fn fetch_vertical_chain_at_strikes(
386    market: &MarketDataApi,
387    group: &OptionPositionGroup,
388    contract_type: &str,
389    map_key: &str,
390    short_strike: f64,
391    long_strike: f64,
392    is_put: bool,
393    strike_count: u32,
394) -> Result<VerticalChainSnapshot> {
395    let strike_anchor = format_chain_strike(short_strike);
396    let chain = market
397        .chains()
398        .get(&ChainQuery {
399            symbol: &group.underlying,
400            contract_type: Some(contract_type),
401            strike: Some(&strike_anchor),
402            strike_count: Some(strike_count),
403            include_underlying_quote: Some(true),
404            from_date: Some(&group.expiry),
405            to_date: Some(&group.expiry),
406            ..Default::default()
407        })
408        .await?;
409
410    let strike_map =
411        find_expiry_strikes(&chain, map_key, &group.expiry).context("expiry not found in chain")?;
412
413    let short_ask = strike_quote_field(&strike_map, short_strike, "ask")?;
414    let long_bid = strike_quote_field(&strike_map, long_strike, "bid")?;
415    let debit_to_close = (short_ask - long_bid).max(0.0);
416
417    Ok(VerticalChainSnapshot {
418        chain,
419        strike_map,
420        short_strike,
421        long_strike,
422        is_put,
423        debit_to_close,
424    })
425}
426
427fn format_chain_strike(strike: f64) -> String {
428    if (strike.fract() * 10.0).round() as i64 % 10 == 0 {
429        format!("{strike:.1}")
430    } else {
431        format!("{strike:.2}")
432    }
433}
434
435pub fn monitor_snapshot_json(
436    group: &OptionPositionGroup,
437    tracked: Option<&TrackedPosition>,
438    exit_eval: &Option<ExitEvaluation>,
439    mark: Option<&SpreadMark>,
440    market_context: Option<Value>,
441    chain_error: Option<&str>,
442    exit_rules: &ExitRules,
443) -> Value {
444    let entry_credit = tracked
445        .and_then(|p| p.entry_credit)
446        .or_else(|| infer_entry_credit_from_legs(&group.legs));
447
448    let status = match exit_eval {
449        Some(e) => format!("exit: {}", e.reason),
450        None => "holding".into(),
451    };
452
453    let contracts = tracked
454        .map(|p| p.contracts.max(1))
455        .unwrap_or_else(|| spread_contract_count(group));
456
457    let mut snapshot = json!({
458        "position_id": tracked
459            .map(|t| t.position_id.as_str())
460            .unwrap_or(group.id.as_str()),
461        "legacy_position_id": group.id,
462        "underlying": group.underlying,
463        "expiry": group.expiry,
464        "strategy": tracked
465            .map(|t| t.strategy.as_str())
466            .unwrap_or_else(|| group.strategy_hint.as_str()),
467        "contracts": contracts,
468        "entry_credit": entry_credit,
469        "max_loss_usd": tracked.map(|p| p.max_loss_usd),
470        "net_market_value": group.net_market_value,
471        "status": status,
472    });
473
474    if let Some(eval) = exit_eval {
475        snapshot["profit_pct"] = json!(eval.mark.profit_pct);
476        snapshot["dte"] = json!(eval.mark.dte);
477        snapshot["debit_to_close"] = json!(eval.mark.debit_to_close);
478    } else if let Some(m) = mark {
479        snapshot["profit_pct"] = json!(m.profit_pct);
480        snapshot["dte"] = json!(m.dte);
481        snapshot["debit_to_close"] = json!(m.debit_to_close);
482    }
483
484    if let Some(ctx) = market_context {
485        snapshot["market_context"] = ctx;
486    } else if let Some(err) = chain_error {
487        snapshot["market_context_error"] = json!(err);
488        snapshot["market_context_note"] = json!(
489            "Live chain greeks unavailable; mechanical exits still use chain debit when fetch succeeds on exit ticks."
490        );
491    }
492
493    if let Some(m) = mark.or(exit_eval.as_ref().map(|e| &e.mark)) {
494        let entry = m.entry_credit;
495        let stop_debit = entry * (exit_rules.stop_loss_pct / 100.0);
496        snapshot["mechanical_rules"] = json!({
497            "profit_target_pct": exit_rules.profit_target_pct,
498            "stop_loss_pct": exit_rules.stop_loss_pct,
499            "stop_debit_threshold_per_share": stop_debit,
500            "current_debit_to_close": m.debit_to_close,
501            "stop_triggered": m.debit_to_close >= stop_debit,
502            "profit_target_triggered": m.profit_pct >= exit_rules.profit_target_pct,
503            "thesis_exits_enabled": exit_rules.thesis.enabled,
504            "thesis_min_hold_minutes": exit_rules.thesis.min_hold_minutes,
505            "peak_profit_pct": tracked.and_then(|p| p.peak_profit_pct),
506            "note": "Mechanical exits use debit_to_close from the chain, NOT net_market_value. Thesis exits (POP/delta/OTM/giveback) run after min_hold when enabled."
507        });
508    }
509
510    snapshot["net_market_value_note"] = json!(
511        "Schwab leg market_value sum in dollars; not comparable to per-share entry_credit or stop_debit_threshold."
512    );
513
514    snapshot
515}
516
517fn evaluate_dte_only(
518    group: &OptionPositionGroup,
519    rules: &RulesConfig,
520    today: NaiveDate,
521) -> Result<Option<ExitEvaluation>> {
522    let dte = group
523        .legs
524        .first()
525        .and_then(|l| l.parsed.as_ref())
526        .map(|p| days_to_expiry(p.expiry, today))
527        .unwrap_or(0);
528    if dte > rules.exit_rules.dte_close as i64 {
529        return Ok(None);
530    }
531    Ok(Some(ExitEvaluation {
532        reason: "dte_close".into(),
533        mark: SpreadMark {
534            entry_credit: 0.0,
535            debit_to_close: 0.0,
536            profit_pct: 0.0,
537            dte,
538            source: "dte_only".into(),
539        },
540    }))
541}
542
543fn evaluate_dte_only_with_credit(
544    _group: &OptionPositionGroup,
545    rules: &RulesConfig,
546    _today: NaiveDate,
547    entry_credit: f64,
548    dte: i64,
549) -> Result<Option<ExitEvaluation>> {
550    if dte > rules.exit_rules.dte_close as i64 {
551        return Ok(None);
552    }
553    Ok(Some(ExitEvaluation {
554        reason: "dte_close".into(),
555        mark: SpreadMark {
556            entry_credit,
557            debit_to_close: 0.0,
558            profit_pct: 0.0,
559            dte,
560            source: "dte_fallback".into(),
561        },
562    }))
563}
564
565fn vertical_legs(group: &OptionPositionGroup) -> Result<(&OptionPositionLeg, &OptionPositionLeg)> {
566    let short = group
567        .legs
568        .iter()
569        .find(|l| l.quantity < 0.0)
570        .context("no short leg")?;
571    let long = group
572        .legs
573        .iter()
574        .find(|l| l.quantity > 0.0)
575        .context("no long leg")?;
576    Ok((short, long))
577}
578
579fn find_expiry_strikes(chain: &Value, map_key: &str, expiry: &str) -> Result<Value> {
580    let map = chain
581        .get(map_key)
582        .context("chain missing exp date map")?
583        .as_object()
584        .context("exp date map not an object")?;
585
586    for (key, strikes) in map {
587        let date_part = key.split(':').next().unwrap_or(key);
588        if date_part == expiry || key.starts_with(expiry) {
589            return Ok(strikes.clone());
590        }
591    }
592    anyhow::bail!("expiry {expiry} not in chain")
593}
594
595fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
596    for key in strike_key_candidates(strike) {
597        if let Some(val) = strike_map
598            .get(&key)
599            .and_then(|contracts| contracts.as_array()?.first())
600            .and_then(|c| c.get(field))
601            .and_then(|v| v.as_f64())
602        {
603            return Ok(val);
604        }
605    }
606    anyhow::bail!("missing {field} for strike {strike}")
607}
608
609fn strike_key_candidates(strike: f64) -> Vec<String> {
610    vec![
611        format!("{strike:.1}"),
612        format!("{strike:.0}"),
613        strike.to_string(),
614    ]
615}
616
617pub fn exit_signal_json_for_account(
618    account_hash: &str,
619    group: &OptionPositionGroup,
620    eval: &ExitEvaluation,
621) -> Value {
622    let position_id = stable_position_key(account_hash, group);
623    json!({
624        "type": "exit",
625        "reason": eval.reason,
626        "position_id": position_id,
627        "legacy_position_id": group.id,
628        "underlying": group.underlying,
629        "expiry": group.expiry,
630        "mark": eval.mark,
631    })
632}
633
634pub async fn reconcile_open_positions(
635    trader: &schwab_api::TraderApi,
636    state: &mut AgentState,
637    rules: &RulesConfig,
638) -> Result<()> {
639    let mut live_keys = std::collections::HashSet::new();
640    for account in rules.enabled_accounts() {
641        let legs = list_option_positions(trader, Some(&account.hash)).await?;
642        let groups = group_option_legs(&legs);
643        for group in groups {
644            let stable_id = stable_position_key(&account.hash, &group);
645            live_keys.insert(stable_id.clone());
646            let live_contracts = spread_contract_count(&group);
647            let entry_credit = infer_entry_credit_from_legs(&group.legs);
648            let inferred_max_loss = infer_max_loss_from_group(&group);
649
650            if let Some(mut tracked) =
651                take_existing_tracked_position(state, &stable_id, &account.hash, &group)
652            {
653                tracked.position_id = stable_id.clone();
654                tracked.account_hash = account.hash.clone();
655                let prev_contracts = tracked.contracts.max(1);
656                if let Some(max_loss) = inferred_max_loss {
657                    tracked.max_loss_usd = max_loss;
658                } else if live_contracts != prev_contracts && tracked.max_loss_usd > 0.0 {
659                    let per_contract = tracked.max_loss_usd / prev_contracts as f64;
660                    tracked.max_loss_usd = per_contract * live_contracts as f64;
661                }
662                tracked.contracts = live_contracts;
663                if entry_credit.is_some() {
664                    tracked.entry_credit = entry_credit;
665                }
666                state.open_positions.insert(stable_id, tracked);
667            } else {
668                state.open_positions.insert(
669                    stable_id.clone(),
670                    TrackedPosition {
671                        position_id: stable_id,
672                        account_hash: account.hash.clone(),
673                        underlying: group.underlying.clone(),
674                        expiry: group.expiry.clone(),
675                        strategy: group.strategy_hint.clone(),
676                        opened_at: chrono::Utc::now(),
677                        entry_credit,
678                        max_loss_usd: inferred_max_loss.unwrap_or(0.0),
679                        contracts: live_contracts,
680                        entry_params: None,
681                        peak_profit_pct: None,
682                        entry_pop_pct: None,
683                        entry_short_delta: None,
684                    },
685                );
686            }
687        }
688    }
689    state.open_positions.retain(|id, _| live_keys.contains(id));
690    Ok(())
691}
692
693fn take_existing_tracked_position(
694    state: &mut AgentState,
695    stable_id: &str,
696    account_hash: &str,
697    group: &OptionPositionGroup,
698) -> Option<TrackedPosition> {
699    if let Some(tracked) = state.open_positions.remove(stable_id) {
700        return Some(tracked);
701    }
702    if let Some(tracked) = state.open_positions.remove(&group.id) {
703        return Some(tracked);
704    }
705    let key = state.open_positions.iter().find_map(|(key, tracked)| {
706        (tracked.account_hash == account_hash
707            && tracked.underlying == group.underlying
708            && tracked.expiry == group.expiry
709            && tracked.strategy == group.strategy_hint)
710            .then(|| key.clone())
711    })?;
712    state.open_positions.remove(&key)
713}
714
715pub fn infer_max_loss_from_group(group: &OptionPositionGroup) -> Option<f64> {
716    let contracts = spread_contract_count(group) as f64;
717    let entry_credit = infer_entry_credit_from_legs(&group.legs).unwrap_or(0.0);
718    match group.strategy_hint.as_str() {
719        "vertical" => {
720            let (short, long) = vertical_legs(group).ok()?;
721            let short_strike = short.parsed.as_ref()?.strike;
722            let long_strike = long.parsed.as_ref()?.strike;
723            let width = (short_strike - long_strike).abs();
724            Some((width - entry_credit).max(0.0) * 100.0 * contracts)
725        }
726        "iron_condor" => {
727            let put_width = wing_width(group, 'P')?;
728            let call_width = wing_width(group, 'C')?;
729            Some((put_width.max(call_width) - entry_credit).max(0.0) * 100.0 * contracts)
730        }
731        _ => None,
732    }
733}
734
735fn wing_width(group: &OptionPositionGroup, put_call: char) -> Option<f64> {
736    let mut short = None;
737    let mut long = None;
738    for leg in &group.legs {
739        let parsed = leg.parsed.as_ref()?;
740        if parsed.put_call != put_call {
741            continue;
742        }
743        if leg.quantity < 0.0 {
744            short = Some(parsed.strike);
745        } else if leg.quantity > 0.0 {
746            long = Some(parsed.strike);
747        }
748    }
749    Some((short? - long?).abs())
750}
751
752pub fn exit_rules_summary(rules: &ExitRules) -> Value {
753    json!({
754        "profit_target_pct": rules.profit_target_pct,
755        "stop_loss_pct": rules.stop_loss_pct,
756        "dte_close": rules.dte_close,
757        "thesis": rules.thesis,
758    })
759}
760
761/// Per-share debit thresholds for a credit spread (target = lower debit, stop = higher debit).
762pub fn spread_exit_thresholds(entry_credit: f64, exit_rules: &ExitRules) -> (f64, f64) {
763    let target_debit = entry_credit * (1.0 - exit_rules.profit_target_pct / 100.0);
764    let stop_debit = entry_credit * (exit_rules.stop_loss_pct / 100.0);
765    (target_debit, stop_debit)
766}
767
768/// Debit to close per spread share from Schwab leg `net_market_value` (portfolio fallback).
769pub fn debit_to_close_from_group(group: &OptionPositionGroup) -> Option<f64> {
770    let contracts = spread_contract_count(group) as f64;
771    if contracts <= 0.0 {
772        return None;
773    }
774    let debit = (-group.net_market_value).max(0.0) / (contracts * 100.0);
775    Some(debit)
776}
777
778pub fn mark_from_net_market_value(
779    group: &OptionPositionGroup,
780    entry_credit: f64,
781    today: NaiveDate,
782) -> Option<SpreadMark> {
783    let debit = debit_to_close_from_group(group)?;
784    let dte = group
785        .legs
786        .first()
787        .and_then(|l| l.parsed.as_ref())
788        .map(|p| days_to_expiry(p.expiry, today))
789        .unwrap_or(0);
790    let profit_pct = if entry_credit > f64::EPSILON {
791        ((entry_credit - debit) / entry_credit) * 100.0
792    } else {
793        0.0
794    };
795    Some(SpreadMark {
796        entry_credit,
797        debit_to_close: debit,
798        profit_pct,
799        dte,
800        source: "portfolio".into(),
801    })
802}
803
804/// Live Schwab option groups keyed by stable position id.
805pub async fn load_live_position_groups(
806    trader: &schwab_api::TraderApi,
807    rules: &RulesConfig,
808) -> Result<std::collections::HashMap<String, OptionPositionGroup>> {
809    let mut map = std::collections::HashMap::new();
810    for account in rules.enabled_accounts() {
811        let legs = list_option_positions(trader, Some(&account.hash)).await?;
812        for group in group_option_legs(&legs) {
813            let key = stable_position_key(&account.hash, &group);
814            map.insert(key, group);
815        }
816    }
817    Ok(map)
818}
819
820/// Build a minimal position group from sim tracked state (vertical spreads only).
821pub fn option_group_from_tracked(tracked: &TrackedPosition) -> Option<OptionPositionGroup> {
822    let params = tracked.entry_params.as_ref()?;
823    let v: VerticalParams = serde_json::from_value(params.clone()).ok()?;
824    let put_call = if v.spread_type.to_ascii_lowercase().contains("put") {
825        'P'
826    } else {
827        'C'
828    };
829    let expiry = parse_expiry(&v.expiry).ok()?;
830    let short_sym = build_option_symbol(&v.underlying, &v.expiry, put_call, v.short_strike).ok()?;
831    let long_sym = build_option_symbol(&v.underlying, &v.expiry, put_call, v.long_strike).ok()?;
832    let contracts = tracked.contracts.max(1) as f64;
833    let legs = vec![
834        OptionPositionLeg {
835            symbol: short_sym.clone(),
836            underlying: v.underlying.clone(),
837            quantity: -contracts,
838            market_value: 0.0,
839            average_price: tracked.entry_credit,
840            parsed: parse_option_symbol(&short_sym).ok(),
841        },
842        OptionPositionLeg {
843            symbol: long_sym.clone(),
844            underlying: v.underlying.clone(),
845            quantity: contracts,
846            market_value: 0.0,
847            average_price: None,
848            parsed: parse_option_symbol(&long_sym).ok(),
849        },
850    ];
851    Some(OptionPositionGroup {
852        id: tracked.position_id.clone(),
853        underlying: v.underlying,
854        expiry: expiry.format("%Y-%m-%d").to_string(),
855        strategy_hint: tracked.strategy.clone(),
856        legs,
857        net_market_value: 0.0,
858    })
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use crate::rules::{ExitRules, RulesConfig, ThesisExitRules};
865
866    #[test]
867    fn evaluate_exit_from_mark_profit_target() {
868        let exit_rules = ExitRules {
869            profit_target_pct: 50.0,
870            stop_loss_pct: 200.0,
871            dte_close: 21,
872            thesis: Default::default(),
873        };
874        let rules = RulesConfig {
875            version: 1,
876            agent_id: "t".into(),
877            accounts: vec![],
878            schedule: Default::default(),
879            strategies: Default::default(),
880            watchlist: vec![],
881            entry_rules: Default::default(),
882            exit_rules,
883            risk: Default::default(),
884            execution: Default::default(),
885            llm: Default::default(),
886            notify: Default::default(),
887            simulation: None,
888        };
889        let mark = SpreadMark {
890            entry_credit: 0.25,
891            debit_to_close: 0.10,
892            profit_pct: 60.0,
893            dte: 30,
894            source: "test".into(),
895        };
896        let exit = evaluate_exit_from_mark(&rules, Some(0.25), &mark);
897        assert_eq!(
898            exit.as_ref().map(|e| e.reason.as_str()),
899            Some("profit_target")
900        );
901    }
902
903    #[test]
904    fn profit_target_triggers_at_half_credit() {
905        let entry = 0.29;
906        let debit = 0.14;
907        let profit_pct = ((entry - debit) / entry) * 100.0;
908        assert!(profit_pct >= 50.0);
909    }
910
911    #[test]
912    fn stop_loss_triggers_at_double_credit() {
913        let entry = 0.29;
914        let stop_debit = entry * 2.0;
915        assert!(0.58 >= stop_debit - 0.001);
916    }
917
918    #[test]
919    fn infers_credit_from_leg_averages() {
920        let legs = vec![
921            OptionPositionLeg {
922                symbol: "IWM".into(),
923                underlying: "IWM".into(),
924                quantity: -1.0,
925                market_value: -100.0,
926                average_price: Some(0.29),
927                parsed: None,
928            },
929            OptionPositionLeg {
930                symbol: "IWM".into(),
931                underlying: "IWM".into(),
932                quantity: 1.0,
933                market_value: 50.0,
934                average_price: Some(0.05),
935                parsed: None,
936            },
937        ];
938        let credit = infer_entry_credit_from_legs(&legs).unwrap();
939        assert!((credit - 0.24).abs() < 0.001);
940    }
941
942    #[test]
943    fn infers_vertical_max_loss_from_live_group() {
944        let group = OptionPositionGroup {
945            id: "IWM|2026-07-31".into(),
946            underlying: "IWM".into(),
947            expiry: "2026-07-31".into(),
948            strategy_hint: "vertical".into(),
949            legs: vec![
950                OptionPositionLeg {
951                    symbol: "IWM   260731P00282000".into(),
952                    underlying: "IWM".into(),
953                    quantity: -2.0,
954                    market_value: -64.0,
955                    average_price: Some(0.29),
956                    parsed: crate::options::symbology::parse_option_symbol("IWM   260731P00282000")
957                        .ok(),
958                },
959                OptionPositionLeg {
960                    symbol: "IWM   260731P00280000".into(),
961                    underlying: "IWM".into(),
962                    quantity: 2.0,
963                    market_value: 10.0,
964                    average_price: Some(0.05),
965                    parsed: crate::options::symbology::parse_option_symbol("IWM   260731P00280000")
966                        .ok(),
967                },
968            ],
969            net_market_value: -54.0,
970        };
971        let max_loss = infer_max_loss_from_group(&group).unwrap();
972        assert!((max_loss - 352.0).abs() < 0.01);
973    }
974
975    fn thesis_rules() -> RulesConfig {
976        RulesConfig {
977            version: 1,
978            agent_id: "t".into(),
979            accounts: vec![],
980            schedule: Default::default(),
981            strategies: Default::default(),
982            watchlist: vec![],
983            entry_rules: Default::default(),
984            exit_rules: ExitRules {
985                profit_target_pct: 50.0,
986                stop_loss_pct: 200.0,
987                dte_close: 21,
988                thesis: ThesisExitRules {
989                    enabled: true,
990                    ..Default::default()
991                },
992            },
993            risk: Default::default(),
994            execution: Default::default(),
995            llm: Default::default(),
996            notify: Default::default(),
997            simulation: None,
998        }
999    }
1000
1001    #[test]
1002    fn thesis_profit_giveback_triggers() {
1003        let mut rules = thesis_rules();
1004        rules.exit_rules.thesis.profit_giveback = Some(crate::rules::ProfitGivebackExit {
1005            peak_profit_min_pct: 20.0,
1006            exit_if_below_pct: 8.0,
1007        });
1008        let mark = SpreadMark {
1009            entry_credit: 0.30,
1010            debit_to_close: 0.28,
1011            profit_pct: 6.0,
1012            dte: 28,
1013            source: "test".into(),
1014        };
1015        let analytics = SpreadAnalytics::default();
1016        let exit = evaluate_thesis_exit(&rules, Some(0.30), &mark, &analytics, Some(24.0));
1017        assert_eq!(
1018            exit.as_ref().map(|e| e.reason.as_str()),
1019            Some("thesis_profit_giveback")
1020        );
1021    }
1022
1023    #[test]
1024    fn thesis_pop_deterioration_triggers() {
1025        let mut rules = thesis_rules();
1026        rules.exit_rules.thesis.min_pop_pct_exit = Some(55.0);
1027        let mark = SpreadMark {
1028            entry_credit: 0.30,
1029            debit_to_close: 0.25,
1030            profit_pct: 10.0,
1031            dte: 28,
1032            source: "test".into(),
1033        };
1034        let analytics = SpreadAnalytics {
1035            spread_pop_pct: Some(52.0),
1036            ..Default::default()
1037        };
1038        let exit = evaluate_thesis_exit(&rules, Some(0.30), &mark, &analytics, None);
1039        assert_eq!(
1040            exit.as_ref().map(|e| e.reason.as_str()),
1041            Some("thesis_pop_deterioration")
1042        );
1043    }
1044
1045    #[test]
1046    fn thesis_min_hold_skips_thesis_but_not_profit_target() {
1047        let mut rules = thesis_rules();
1048        rules.exit_rules.thesis.min_hold_minutes = Some(30);
1049        rules.exit_rules.thesis.min_pop_pct_exit = Some(55.0);
1050        let mark = SpreadMark {
1051            entry_credit: 0.30,
1052            debit_to_close: 0.25,
1053            profit_pct: 10.0,
1054            dte: 28,
1055            source: "test".into(),
1056        };
1057        let analytics = SpreadAnalytics {
1058            spread_pop_pct: Some(52.0),
1059            short_strike_inside_1sigma: Some(true),
1060            ..Default::default()
1061        };
1062        let opened = chrono::Utc::now() - chrono::Duration::minutes(5);
1063        let exit = evaluate_all_exits(
1064            &rules,
1065            Some(0.30),
1066            &mark,
1067            Some(&analytics),
1068            None,
1069            Some(opened),
1070        );
1071        assert!(exit.is_none(), "thesis should wait for min hold");
1072
1073        let profit_mark = SpreadMark {
1074            profit_pct: 55.0,
1075            debit_to_close: 0.135,
1076            ..mark.clone()
1077        };
1078        let profit_exit = evaluate_all_exits(
1079            &rules,
1080            Some(0.30),
1081            &profit_mark,
1082            Some(&analytics),
1083            None,
1084            Some(opened),
1085        );
1086        assert_eq!(
1087            profit_exit.as_ref().map(|e| e.reason.as_str()),
1088            Some("profit_target")
1089        );
1090    }
1091
1092    #[test]
1093    fn candidate_fails_thesis_gates_on_pop_and_1sigma() {
1094        let mut rules = thesis_rules();
1095        rules.exit_rules.thesis.min_pop_pct_exit = Some(55.0);
1096        rules.exit_rules.thesis.exit_short_inside_1sigma = false;
1097        let low_pop = SpreadAnalytics {
1098            spread_pop_pct: Some(50.0),
1099            short_otm_pct: Some(5.0),
1100            short_delta: Some(-0.20),
1101            short_strike_inside_1sigma: Some(true),
1102            ..Default::default()
1103        };
1104        assert_eq!(
1105            candidate_fails_thesis_gates(&rules, &low_pop),
1106            Some("thesis_pop_deterioration")
1107        );
1108
1109        let healthy = SpreadAnalytics {
1110            spread_pop_pct: Some(70.0),
1111            short_otm_pct: Some(5.0),
1112            short_delta: Some(-0.20),
1113            short_strike_inside_1sigma: Some(true),
1114            ..Default::default()
1115        };
1116        assert_eq!(candidate_fails_thesis_gates(&rules, &healthy), None);
1117
1118        rules.exit_rules.thesis.exit_short_inside_1sigma = true;
1119        assert_eq!(
1120            candidate_fails_thesis_gates(&rules, &healthy),
1121            Some("thesis_inside_1sigma")
1122        );
1123    }
1124
1125    #[test]
1126    fn find_expiry_strikes_matches_schwab_key() {
1127        let chain = json!({
1128            "putExpDateMap": {
1129                "2026-07-31:36": { "282.0": [] }
1130            }
1131        });
1132        let strikes = find_expiry_strikes(&chain, "putExpDateMap", "2026-07-31").unwrap();
1133        assert!(strikes.is_object());
1134    }
1135
1136    #[test]
1137    fn format_chain_strike_uses_one_decimal_for_whole_strikes() {
1138        assert_eq!(format_chain_strike(282.0), "282.0");
1139        assert_eq!(format_chain_strike(282.5), "282.50");
1140    }
1141}