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
95pub 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
193pub 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
231pub 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
243pub 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
283pub 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 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
794pub 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
801pub 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
837pub 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
853pub 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}