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, OptionPositionGroup,
10    OptionPositionLeg,
11};
12use crate::rules::{ExitRules, RulesConfig};
13
14use super::market_context::vertical_open_position_context;
15use super::state::{AgentState, TrackedPosition};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SpreadMark {
19    pub entry_credit: f64,
20    pub debit_to_close: f64,
21    pub profit_pct: f64,
22    pub dte: i64,
23    pub source: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ExitEvaluation {
28    pub reason: String,
29    pub mark: SpreadMark,
30}
31
32/// Stable position key matching `OptionPositionGroup::id` (`underlying|expiry`).
33pub fn position_key(underlying: &str, expiry: &str) -> String {
34    format!("{underlying}|{expiry}")
35}
36
37pub fn find_tracked_position<'a>(
38    state: &'a AgentState,
39    account_hash: &str,
40    group: &OptionPositionGroup,
41) -> Option<&'a TrackedPosition> {
42    let key = group.id.clone();
43    state
44        .open_positions
45        .get(&key)
46        .or_else(|| {
47            state.open_positions.values().find(|p| {
48                p.account_hash == account_hash
49                    && p.underlying == group.underlying
50                    && p.expiry == group.expiry
51            })
52        })
53}
54
55pub fn infer_entry_credit_from_legs(legs: &[OptionPositionLeg]) -> Option<f64> {
56    if legs.len() != 2 {
57        return None;
58    }
59    let mut short_premium = None;
60    let mut long_premium = None;
61    for leg in legs {
62        let avg = leg.average_price?;
63        if leg.quantity < 0.0 {
64            short_premium = Some(avg.abs());
65        } else if leg.quantity > 0.0 {
66            long_premium = Some(avg.abs());
67        }
68    }
69    match (short_premium, long_premium) {
70        (Some(s), Some(l)) => Some((s - l).max(0.0)),
71        (Some(s), None) => Some(s),
72        _ => None,
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct PositionMonitorResult {
78    pub exit: Option<ExitEvaluation>,
79    pub snapshot: Value,
80}
81
82struct VerticalChainSnapshot {
83    chain: Value,
84    strike_map: Value,
85    short_strike: f64,
86    long_strike: f64,
87    is_put: bool,
88    debit_to_close: f64,
89}
90
91/// Evaluate mechanical exit rules and build an LLM-ready monitor snapshot (single chain fetch).
92pub async fn evaluate_position_monitor(
93    market: &MarketDataApi,
94    group: &OptionPositionGroup,
95    rules: &RulesConfig,
96    today: NaiveDate,
97    tracked: Option<&TrackedPosition>,
98) -> Result<PositionMonitorResult> {
99    let entry_credit = tracked
100        .and_then(|p| p.entry_credit)
101        .or_else(|| infer_entry_credit_from_legs(&group.legs));
102
103    let dte = group
104        .legs
105        .first()
106        .and_then(|l| l.parsed.as_ref())
107        .map(|p| days_to_expiry(p.expiry, today))
108        .unwrap_or(0);
109
110    let chain_result = fetch_vertical_chain_snapshot(market, group).await;
111
112    let (exit, mark_opt, market_context) = match chain_result {
113        Ok(chain_snap) => {
114            let profit_pct = entry_credit.filter(|c| *c > f64::EPSILON).map(|entry| {
115                ((entry - chain_snap.debit_to_close) / entry) * 100.0
116            });
117            let mark = SpreadMark {
118                entry_credit: entry_credit.unwrap_or(0.0),
119                debit_to_close: chain_snap.debit_to_close,
120                profit_pct: profit_pct.unwrap_or(0.0),
121                dte,
122                source: "chain".into(),
123            };
124            let exit = evaluate_exit_from_mark(rules, entry_credit, &mark);
125            let expiry_date = chrono::NaiveDate::parse_from_str(&group.expiry, "%Y-%m-%d")
126                .ok()
127                .or_else(|| {
128                    group
129                        .legs
130                        .first()
131                        .and_then(|l| l.parsed.as_ref())
132                        .map(|p| p.expiry)
133                })
134                .unwrap_or(today);
135            let ctx = vertical_open_position_context(
136                &chain_snap.chain,
137                &group.underlying,
138                today,
139                expiry_date,
140                &chain_snap.strike_map,
141                chain_snap.short_strike,
142                chain_snap.long_strike,
143                chain_snap.is_put,
144                entry_credit,
145                Some(chain_snap.debit_to_close),
146                profit_pct,
147                dte,
148            );
149            (exit, Some(mark), Some(ctx))
150        }
151        Err(_) => {
152            let exit = if let Some(credit) = entry_credit.filter(|c| *c > 0.0) {
153                evaluate_dte_only_with_credit(group, rules, today, credit, dte)?
154            } else {
155                evaluate_dte_only(group, rules, today)?
156            };
157            (exit, None, None)
158        }
159    };
160
161    let snapshot = monitor_snapshot_json(
162        group,
163        tracked,
164        &exit,
165        mark_opt.as_ref(),
166        market_context,
167        &rules.exit_rules,
168    );
169    Ok(PositionMonitorResult { exit, snapshot })
170}
171
172fn evaluate_exit_from_mark(
173    rules: &RulesConfig,
174    entry_credit: Option<f64>,
175    mark: &SpreadMark,
176) -> Option<ExitEvaluation> {
177    let entry_credit = entry_credit.filter(|c| *c > f64::EPSILON)?;
178    let mark = SpreadMark {
179        entry_credit,
180        ..mark.clone()
181    };
182
183    if mark.profit_pct >= rules.exit_rules.profit_target_pct {
184        return Some(ExitEvaluation {
185            reason: "profit_target".into(),
186            mark,
187        });
188    }
189
190    let stop_debit = entry_credit * (rules.exit_rules.stop_loss_pct / 100.0);
191    if mark.debit_to_close >= stop_debit {
192        return Some(ExitEvaluation {
193            reason: "stop_loss".into(),
194            mark,
195        });
196    }
197
198    if mark.dte <= rules.exit_rules.dte_close as i64 {
199        return Some(ExitEvaluation {
200            reason: "dte_close".into(),
201            mark,
202        });
203    }
204
205    None
206}
207
208async fn fetch_vertical_chain_snapshot(
209    market: &MarketDataApi,
210    group: &OptionPositionGroup,
211) -> Result<VerticalChainSnapshot> {
212    let (short_leg, long_leg) = vertical_legs(group)?;
213    let short_strike = short_leg
214        .parsed
215        .as_ref()
216        .map(|p| p.strike)
217        .context("short leg missing strike")?;
218    let long_strike = long_leg
219        .parsed
220        .as_ref()
221        .map(|p| p.strike)
222        .context("long leg missing strike")?;
223    let is_put = short_leg
224        .parsed
225        .as_ref()
226        .is_some_and(|p| p.put_call == 'P');
227
228    let contract_type = if is_put { "PUT" } else { "CALL" };
229    let chain = market
230        .chains()
231        .get(&ChainQuery {
232            symbol: &group.underlying,
233            contract_type: Some(contract_type),
234            strike_count: Some(20),
235            include_underlying_quote: Some(true),
236            ..Default::default()
237        })
238        .await?;
239
240    let map_key = if is_put {
241        "putExpDateMap"
242    } else {
243        "callExpDateMap"
244    };
245    let strike_map = find_expiry_strikes(&chain, map_key, &group.expiry)
246        .context("expiry not found in chain")?;
247
248    let short_ask = strike_quote_field(&strike_map, short_strike, "ask")?;
249    let long_bid = strike_quote_field(&strike_map, long_strike, "bid")?;
250    let debit_to_close = (short_ask - long_bid).max(0.0);
251
252    Ok(VerticalChainSnapshot {
253        chain,
254        strike_map,
255        short_strike,
256        long_strike,
257        is_put,
258        debit_to_close,
259    })
260}
261
262pub fn monitor_snapshot_json(
263    group: &OptionPositionGroup,
264    tracked: Option<&TrackedPosition>,
265    exit_eval: &Option<ExitEvaluation>,
266    mark: Option<&SpreadMark>,
267    market_context: Option<Value>,
268    exit_rules: &ExitRules,
269) -> Value {
270    let entry_credit = tracked
271        .and_then(|p| p.entry_credit)
272        .or_else(|| infer_entry_credit_from_legs(&group.legs));
273
274    let status = match exit_eval {
275        Some(e) => format!("exit: {}", e.reason),
276        None => "holding".into(),
277    };
278
279    let mut snapshot = json!({
280        "position_id": group.id,
281        "underlying": group.underlying,
282        "expiry": group.expiry,
283        "strategy": tracked
284            .map(|t| t.strategy.as_str())
285            .unwrap_or_else(|| group.strategy_hint.as_str()),
286        "entry_credit": entry_credit,
287        "net_market_value": group.net_market_value,
288        "status": status,
289    });
290
291    if let Some(eval) = exit_eval {
292        snapshot["profit_pct"] = json!(eval.mark.profit_pct);
293        snapshot["dte"] = json!(eval.mark.dte);
294        snapshot["debit_to_close"] = json!(eval.mark.debit_to_close);
295    } else if let Some(m) = mark {
296        snapshot["profit_pct"] = json!(m.profit_pct);
297        snapshot["dte"] = json!(m.dte);
298        snapshot["debit_to_close"] = json!(m.debit_to_close);
299    }
300
301    if let Some(ctx) = market_context {
302        snapshot["market_context"] = ctx;
303    }
304
305    if let Some(m) = mark.or(exit_eval.as_ref().map(|e| &e.mark)) {
306        let entry = m.entry_credit;
307        let stop_debit = entry * (exit_rules.stop_loss_pct / 100.0);
308        snapshot["mechanical_rules"] = json!({
309            "profit_target_pct": exit_rules.profit_target_pct,
310            "stop_loss_pct": exit_rules.stop_loss_pct,
311            "stop_debit_threshold_per_share": stop_debit,
312            "current_debit_to_close": m.debit_to_close,
313            "stop_triggered": m.debit_to_close >= stop_debit,
314            "profit_target_triggered": m.profit_pct >= exit_rules.profit_target_pct,
315            "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."
316        });
317    }
318
319    snapshot["net_market_value_note"] = json!(
320        "Schwab leg market_value sum in dollars; not comparable to per-share entry_credit or stop_debit_threshold."
321    );
322
323    snapshot
324}
325
326pub async fn evaluate_exit_for_group(
327    market: &MarketDataApi,
328    group: &OptionPositionGroup,
329    rules: &RulesConfig,
330    today: NaiveDate,
331    tracked: Option<&TrackedPosition>,
332) -> Result<Option<ExitEvaluation>> {
333    if group.legs.len() != 2 {
334        return evaluate_dte_only(group, rules, today);
335    }
336
337    let entry_credit = tracked
338        .and_then(|p| p.entry_credit)
339        .or_else(|| infer_entry_credit_from_legs(&group.legs))
340        .filter(|c| *c > 0.0);
341
342    let Some(entry_credit) = entry_credit else {
343        return evaluate_dte_only(group, rules, today);
344    };
345
346    let dte = group
347        .legs
348        .first()
349        .and_then(|l| l.parsed.as_ref())
350        .map(|p| days_to_expiry(p.expiry, today))
351        .unwrap_or(0);
352
353    let debit_to_close = match estimate_debit_to_close(market, group).await {
354        Ok(v) => v,
355        Err(_) => {
356            return evaluate_dte_only_with_credit(group, rules, today, entry_credit, dte);
357        }
358    };
359
360    let profit_pct = if entry_credit > f64::EPSILON {
361        ((entry_credit - debit_to_close) / entry_credit) * 100.0
362    } else {
363        0.0
364    };
365
366    let mark = SpreadMark {
367        entry_credit,
368        debit_to_close,
369        profit_pct,
370        dte,
371        source: "chain".into(),
372    };
373
374    Ok(evaluate_exit_from_mark(rules, Some(entry_credit), &mark))
375}
376
377fn evaluate_dte_only(
378    group: &OptionPositionGroup,
379    rules: &RulesConfig,
380    today: NaiveDate,
381) -> Result<Option<ExitEvaluation>> {
382    let dte = group
383        .legs
384        .first()
385        .and_then(|l| l.parsed.as_ref())
386        .map(|p| days_to_expiry(p.expiry, today))
387        .unwrap_or(0);
388    if dte > rules.exit_rules.dte_close as i64 {
389        return Ok(None);
390    }
391    Ok(Some(ExitEvaluation {
392        reason: "dte_close".into(),
393        mark: SpreadMark {
394            entry_credit: 0.0,
395            debit_to_close: 0.0,
396            profit_pct: 0.0,
397            dte,
398            source: "dte_only".into(),
399        },
400    }))
401}
402
403fn evaluate_dte_only_with_credit(
404    _group: &OptionPositionGroup,
405    rules: &RulesConfig,
406    _today: NaiveDate,
407    entry_credit: f64,
408    dte: i64,
409) -> Result<Option<ExitEvaluation>> {
410    if dte > rules.exit_rules.dte_close as i64 {
411        return Ok(None);
412    }
413    Ok(Some(ExitEvaluation {
414        reason: "dte_close".into(),
415        mark: SpreadMark {
416            entry_credit,
417            debit_to_close: 0.0,
418            profit_pct: 0.0,
419            dte,
420            source: "dte_fallback".into(),
421        },
422    }))
423}
424
425async fn estimate_debit_to_close(
426    market: &MarketDataApi,
427    group: &OptionPositionGroup,
428) -> Result<f64> {
429    Ok(fetch_vertical_chain_snapshot(market, group)
430        .await?
431        .debit_to_close)
432}
433
434fn vertical_legs(group: &OptionPositionGroup) -> Result<(&OptionPositionLeg, &OptionPositionLeg)> {
435    let short = group
436        .legs
437        .iter()
438        .find(|l| l.quantity < 0.0)
439        .context("no short leg")?;
440    let long = group
441        .legs
442        .iter()
443        .find(|l| l.quantity > 0.0)
444        .context("no long leg")?;
445    Ok((short, long))
446}
447
448fn find_expiry_strikes(chain: &Value, map_key: &str, expiry: &str) -> Result<Value> {
449    let map = chain
450        .get(map_key)
451        .context("chain missing exp date map")?
452        .as_object()
453        .context("exp date map not an object")?;
454
455    for (key, strikes) in map {
456        let date_part = key.split(':').next().unwrap_or(key);
457        if date_part == expiry || key.starts_with(expiry) {
458            return Ok(strikes.clone());
459        }
460    }
461    anyhow::bail!("expiry {expiry} not in chain")
462}
463
464fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
465    for key in strike_key_candidates(strike) {
466        if let Some(val) = strike_map
467            .get(&key)
468            .and_then(|contracts| contracts.as_array()?.first())
469            .and_then(|c| c.get(field))
470            .and_then(|v| v.as_f64())
471        {
472            return Ok(val);
473        }
474    }
475    anyhow::bail!("missing {field} for strike {strike}")
476}
477
478fn strike_key_candidates(strike: f64) -> Vec<String> {
479    vec![
480        format!("{strike:.1}"),
481        format!("{strike:.0}"),
482        strike.to_string(),
483    ]
484}
485
486pub fn exit_signal_json(group: &OptionPositionGroup, eval: &ExitEvaluation) -> Value {
487    json!({
488        "type": "exit",
489        "reason": eval.reason,
490        "position_id": group.id,
491        "underlying": group.underlying,
492        "expiry": group.expiry,
493        "mark": eval.mark,
494    })
495}
496
497pub async fn reconcile_open_positions(
498    trader: &schwab_api::TraderApi,
499    state: &mut AgentState,
500    rules: &RulesConfig,
501) -> Result<()> {
502    let mut live_keys = std::collections::HashSet::new();
503    for account in rules.enabled_accounts() {
504        let legs = list_option_positions(trader, Some(&account.hash)).await?;
505        let groups = group_option_legs(&legs);
506        for group in groups {
507            live_keys.insert(group.id.clone());
508            if state.open_positions.contains_key(&group.id) {
509                continue;
510            }
511            let entry_credit = infer_entry_credit_from_legs(&group.legs);
512            state.open_positions.insert(
513                group.id.clone(),
514                TrackedPosition {
515                    position_id: group.id.clone(),
516                    account_hash: account.hash.clone(),
517                    underlying: group.underlying.clone(),
518                    expiry: group.expiry.clone(),
519                    strategy: group.strategy_hint.clone(),
520                    opened_at: chrono::Utc::now(),
521                    entry_credit,
522                    max_loss_usd: 0.0,
523                },
524            );
525        }
526    }
527    state
528        .open_positions
529        .retain(|id, _| live_keys.contains(id));
530    Ok(())
531}
532
533pub fn exit_rules_summary(rules: &ExitRules) -> Value {
534    json!({
535        "profit_target_pct": rules.profit_target_pct,
536        "stop_loss_pct": rules.stop_loss_pct,
537        "dte_close": rules.dte_close,
538    })
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::rules::{ExitRules, RulesConfig};
545
546    #[test]
547    fn evaluate_exit_from_mark_profit_target() {
548        let exit_rules = ExitRules {
549            profit_target_pct: 50.0,
550            stop_loss_pct: 200.0,
551            dte_close: 21,
552        };
553        let rules = RulesConfig {
554            version: 1,
555            agent_id: "t".into(),
556            accounts: vec![],
557            schedule: Default::default(),
558            strategies: Default::default(),
559            watchlist: vec![],
560            entry_rules: Default::default(),
561            exit_rules,
562            risk: Default::default(),
563            execution: Default::default(),
564            llm: Default::default(),
565            notify: Default::default(),
566        };
567        let mark = SpreadMark {
568            entry_credit: 0.25,
569            debit_to_close: 0.10,
570            profit_pct: 60.0,
571            dte: 30,
572            source: "test".into(),
573        };
574        let exit = evaluate_exit_from_mark(&rules, Some(0.25), &mark);
575        assert_eq!(exit.as_ref().map(|e| e.reason.as_str()), Some("profit_target"));
576    }
577
578    #[test]
579    fn profit_target_triggers_at_half_credit() {
580        let entry = 0.29;
581        let debit = 0.14;
582        let profit_pct = ((entry - debit) / entry) * 100.0;
583        assert!(profit_pct >= 50.0);
584    }
585
586    #[test]
587    fn stop_loss_triggers_at_double_credit() {
588        let entry = 0.29;
589        let stop_debit = entry * 2.0;
590        assert!(0.58 >= stop_debit - 0.001);
591    }
592
593    #[test]
594    fn infers_credit_from_leg_averages() {
595        let legs = vec![
596            OptionPositionLeg {
597                symbol: "IWM".into(),
598                underlying: "IWM".into(),
599                quantity: -1.0,
600                market_value: -100.0,
601                average_price: Some(0.29),
602                parsed: None,
603            },
604            OptionPositionLeg {
605                symbol: "IWM".into(),
606                underlying: "IWM".into(),
607                quantity: 1.0,
608                market_value: 50.0,
609                average_price: Some(0.05),
610                parsed: None,
611            },
612        ];
613        let credit = infer_entry_credit_from_legs(&legs).unwrap();
614        assert!((credit - 0.24).abs() < 0.001);
615    }
616}