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