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