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 exit = evaluate_exit_from_mark(rules, entry_credit, &mark);
129 let expiry_date = chrono::NaiveDate::parse_from_str(&group.expiry, "%Y-%m-%d")
130 .ok()
131 .or_else(|| {
132 group
133 .legs
134 .first()
135 .and_then(|l| l.parsed.as_ref())
136 .map(|p| p.expiry)
137 })
138 .unwrap_or(today);
139 let contracts = spread_contract_count(group).max(1);
140 let ctx = vertical_open_position_context(
141 &chain_snap.chain,
142 &group.underlying,
143 today,
144 expiry_date,
145 &chain_snap.strike_map,
146 chain_snap.short_strike,
147 chain_snap.long_strike,
148 chain_snap.is_put,
149 entry_credit,
150 Some(chain_snap.debit_to_close),
151 profit_pct,
152 dte,
153 contracts,
154 );
155 let analytics = analytics_from_json(ctx.get("analytics").unwrap_or(&json!({})));
156 (exit, Some(mark), analytics, Some(ctx), None)
157 }
158 Err(e) => {
159 let exit = if let Some(credit) = entry_credit.filter(|c| *c > 0.0) {
160 evaluate_dte_only_with_credit(group, rules, today, credit, dte)?
161 } else {
162 evaluate_dte_only(group, rules, today)?
163 };
164 (exit, None, None, None, Some(e.to_string()))
165 }
166 };
167
168 let snapshot = monitor_snapshot_json(
169 group,
170 tracked,
171 &exit,
172 mark_opt.as_ref(),
173 market_context,
174 chain_error.as_deref(),
175 &rules.exit_rules,
176 );
177 Ok(PositionMonitorResult {
178 exit,
179 mark: mark_opt,
180 analytics,
181 snapshot,
182 })
183}
184
185pub fn evaluate_exit_from_mark(
186 rules: &RulesConfig,
187 entry_credit: Option<f64>,
188 mark: &SpreadMark,
189) -> Option<ExitEvaluation> {
190 let entry_credit = entry_credit.filter(|c| *c > f64::EPSILON)?;
191 let mark = SpreadMark {
192 entry_credit,
193 ..mark.clone()
194 };
195
196 if mark.profit_pct >= rules.exit_rules.profit_target_pct {
197 return Some(ExitEvaluation {
198 reason: "profit_target".into(),
199 mark,
200 });
201 }
202
203 let stop_debit = entry_credit * (rules.exit_rules.stop_loss_pct / 100.0);
204 if mark.debit_to_close >= stop_debit {
205 return Some(ExitEvaluation {
206 reason: "stop_loss".into(),
207 mark,
208 });
209 }
210
211 if mark.dte <= rules.exit_rules.dte_close as i64 {
212 return Some(ExitEvaluation {
213 reason: "dte_close".into(),
214 mark,
215 });
216 }
217
218 None
219}
220
221async fn fetch_vertical_chain_snapshot(
222 market: &MarketDataApi,
223 group: &OptionPositionGroup,
224) -> Result<VerticalChainSnapshot> {
225 let (short_leg, long_leg) = vertical_legs(group)?;
226 let short_strike = short_leg
227 .parsed
228 .as_ref()
229 .map(|p| p.strike)
230 .context("short leg missing strike")?;
231 let long_strike = long_leg
232 .parsed
233 .as_ref()
234 .map(|p| p.strike)
235 .context("long leg missing strike")?;
236 let is_put = short_leg.parsed.as_ref().is_some_and(|p| p.put_call == 'P');
237
238 let contract_type = if is_put { "PUT" } else { "CALL" };
239 let map_key = if is_put {
240 "putExpDateMap"
241 } else {
242 "callExpDateMap"
243 };
244
245 let mut last_err = None;
246 for strike_count in [50u32, 100] {
247 match fetch_vertical_chain_at_strikes(
248 market,
249 group,
250 contract_type,
251 map_key,
252 short_strike,
253 long_strike,
254 is_put,
255 strike_count,
256 )
257 .await
258 {
259 Ok(snap) => return Ok(snap),
260 Err(e) => last_err = Some(e),
261 }
262 }
263
264 Err(last_err.unwrap_or_else(|| anyhow::anyhow!("chain fetch failed")))
265}
266
267#[allow(clippy::too_many_arguments)]
268async fn fetch_vertical_chain_at_strikes(
269 market: &MarketDataApi,
270 group: &OptionPositionGroup,
271 contract_type: &str,
272 map_key: &str,
273 short_strike: f64,
274 long_strike: f64,
275 is_put: bool,
276 strike_count: u32,
277) -> Result<VerticalChainSnapshot> {
278 let strike_anchor = format_chain_strike(short_strike);
279 let chain = market
280 .chains()
281 .get(&ChainQuery {
282 symbol: &group.underlying,
283 contract_type: Some(contract_type),
284 strike: Some(&strike_anchor),
285 strike_count: Some(strike_count),
286 include_underlying_quote: Some(true),
287 from_date: Some(&group.expiry),
288 to_date: Some(&group.expiry),
289 ..Default::default()
290 })
291 .await?;
292
293 let strike_map =
294 find_expiry_strikes(&chain, map_key, &group.expiry).context("expiry not found in chain")?;
295
296 let short_ask = strike_quote_field(&strike_map, short_strike, "ask")?;
297 let long_bid = strike_quote_field(&strike_map, long_strike, "bid")?;
298 let debit_to_close = (short_ask - long_bid).max(0.0);
299
300 Ok(VerticalChainSnapshot {
301 chain,
302 strike_map,
303 short_strike,
304 long_strike,
305 is_put,
306 debit_to_close,
307 })
308}
309
310fn format_chain_strike(strike: f64) -> String {
311 if (strike.fract() * 10.0).round() as i64 % 10 == 0 {
312 format!("{strike:.1}")
313 } else {
314 format!("{strike:.2}")
315 }
316}
317
318pub fn monitor_snapshot_json(
319 group: &OptionPositionGroup,
320 tracked: Option<&TrackedPosition>,
321 exit_eval: &Option<ExitEvaluation>,
322 mark: Option<&SpreadMark>,
323 market_context: Option<Value>,
324 chain_error: Option<&str>,
325 exit_rules: &ExitRules,
326) -> Value {
327 let entry_credit = tracked
328 .and_then(|p| p.entry_credit)
329 .or_else(|| infer_entry_credit_from_legs(&group.legs));
330
331 let status = match exit_eval {
332 Some(e) => format!("exit: {}", e.reason),
333 None => "holding".into(),
334 };
335
336 let contracts = tracked
337 .map(|p| p.contracts.max(1))
338 .unwrap_or_else(|| spread_contract_count(group));
339
340 let mut snapshot = json!({
341 "position_id": tracked
342 .map(|t| t.position_id.as_str())
343 .unwrap_or(group.id.as_str()),
344 "legacy_position_id": group.id,
345 "underlying": group.underlying,
346 "expiry": group.expiry,
347 "strategy": tracked
348 .map(|t| t.strategy.as_str())
349 .unwrap_or_else(|| group.strategy_hint.as_str()),
350 "contracts": contracts,
351 "entry_credit": entry_credit,
352 "max_loss_usd": tracked.map(|p| p.max_loss_usd),
353 "net_market_value": group.net_market_value,
354 "status": status,
355 });
356
357 if let Some(eval) = exit_eval {
358 snapshot["profit_pct"] = json!(eval.mark.profit_pct);
359 snapshot["dte"] = json!(eval.mark.dte);
360 snapshot["debit_to_close"] = json!(eval.mark.debit_to_close);
361 } else if let Some(m) = mark {
362 snapshot["profit_pct"] = json!(m.profit_pct);
363 snapshot["dte"] = json!(m.dte);
364 snapshot["debit_to_close"] = json!(m.debit_to_close);
365 }
366
367 if let Some(ctx) = market_context {
368 snapshot["market_context"] = ctx;
369 } else if let Some(err) = chain_error {
370 snapshot["market_context_error"] = json!(err);
371 snapshot["market_context_note"] = json!(
372 "Live chain greeks unavailable; mechanical exits still use chain debit when fetch succeeds on exit ticks."
373 );
374 }
375
376 if let Some(m) = mark.or(exit_eval.as_ref().map(|e| &e.mark)) {
377 let entry = m.entry_credit;
378 let stop_debit = entry * (exit_rules.stop_loss_pct / 100.0);
379 snapshot["mechanical_rules"] = json!({
380 "profit_target_pct": exit_rules.profit_target_pct,
381 "stop_loss_pct": exit_rules.stop_loss_pct,
382 "stop_debit_threshold_per_share": stop_debit,
383 "current_debit_to_close": m.debit_to_close,
384 "stop_triggered": m.debit_to_close >= stop_debit,
385 "profit_target_triggered": m.profit_pct >= exit_rules.profit_target_pct,
386 "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."
387 });
388 }
389
390 snapshot["net_market_value_note"] = json!(
391 "Schwab leg market_value sum in dollars; not comparable to per-share entry_credit or stop_debit_threshold."
392 );
393
394 snapshot
395}
396
397fn evaluate_dte_only(
398 group: &OptionPositionGroup,
399 rules: &RulesConfig,
400 today: NaiveDate,
401) -> Result<Option<ExitEvaluation>> {
402 let dte = group
403 .legs
404 .first()
405 .and_then(|l| l.parsed.as_ref())
406 .map(|p| days_to_expiry(p.expiry, today))
407 .unwrap_or(0);
408 if dte > rules.exit_rules.dte_close as i64 {
409 return Ok(None);
410 }
411 Ok(Some(ExitEvaluation {
412 reason: "dte_close".into(),
413 mark: SpreadMark {
414 entry_credit: 0.0,
415 debit_to_close: 0.0,
416 profit_pct: 0.0,
417 dte,
418 source: "dte_only".into(),
419 },
420 }))
421}
422
423fn evaluate_dte_only_with_credit(
424 _group: &OptionPositionGroup,
425 rules: &RulesConfig,
426 _today: NaiveDate,
427 entry_credit: f64,
428 dte: i64,
429) -> Result<Option<ExitEvaluation>> {
430 if dte > rules.exit_rules.dte_close as i64 {
431 return Ok(None);
432 }
433 Ok(Some(ExitEvaluation {
434 reason: "dte_close".into(),
435 mark: SpreadMark {
436 entry_credit,
437 debit_to_close: 0.0,
438 profit_pct: 0.0,
439 dte,
440 source: "dte_fallback".into(),
441 },
442 }))
443}
444
445fn vertical_legs(group: &OptionPositionGroup) -> Result<(&OptionPositionLeg, &OptionPositionLeg)> {
446 let short = group
447 .legs
448 .iter()
449 .find(|l| l.quantity < 0.0)
450 .context("no short leg")?;
451 let long = group
452 .legs
453 .iter()
454 .find(|l| l.quantity > 0.0)
455 .context("no long leg")?;
456 Ok((short, long))
457}
458
459fn find_expiry_strikes(chain: &Value, map_key: &str, expiry: &str) -> Result<Value> {
460 let map = chain
461 .get(map_key)
462 .context("chain missing exp date map")?
463 .as_object()
464 .context("exp date map not an object")?;
465
466 for (key, strikes) in map {
467 let date_part = key.split(':').next().unwrap_or(key);
468 if date_part == expiry || key.starts_with(expiry) {
469 return Ok(strikes.clone());
470 }
471 }
472 anyhow::bail!("expiry {expiry} not in chain")
473}
474
475fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
476 for key in strike_key_candidates(strike) {
477 if let Some(val) = strike_map
478 .get(&key)
479 .and_then(|contracts| contracts.as_array()?.first())
480 .and_then(|c| c.get(field))
481 .and_then(|v| v.as_f64())
482 {
483 return Ok(val);
484 }
485 }
486 anyhow::bail!("missing {field} for strike {strike}")
487}
488
489fn strike_key_candidates(strike: f64) -> Vec<String> {
490 vec![
491 format!("{strike:.1}"),
492 format!("{strike:.0}"),
493 strike.to_string(),
494 ]
495}
496
497pub fn exit_signal_json_for_account(
498 account_hash: &str,
499 group: &OptionPositionGroup,
500 eval: &ExitEvaluation,
501) -> Value {
502 let position_id = stable_position_key(account_hash, group);
503 json!({
504 "type": "exit",
505 "reason": eval.reason,
506 "position_id": position_id,
507 "legacy_position_id": group.id,
508 "underlying": group.underlying,
509 "expiry": group.expiry,
510 "mark": eval.mark,
511 })
512}
513
514pub async fn reconcile_open_positions(
515 trader: &schwab_api::TraderApi,
516 state: &mut AgentState,
517 rules: &RulesConfig,
518) -> Result<()> {
519 let mut live_keys = std::collections::HashSet::new();
520 for account in rules.enabled_accounts() {
521 let legs = list_option_positions(trader, Some(&account.hash)).await?;
522 let groups = group_option_legs(&legs);
523 for group in groups {
524 let stable_id = stable_position_key(&account.hash, &group);
525 live_keys.insert(stable_id.clone());
526 let live_contracts = spread_contract_count(&group);
527 let entry_credit = infer_entry_credit_from_legs(&group.legs);
528 let inferred_max_loss = infer_max_loss_from_group(&group);
529
530 if let Some(mut tracked) =
531 take_existing_tracked_position(state, &stable_id, &account.hash, &group)
532 {
533 tracked.position_id = stable_id.clone();
534 tracked.account_hash = account.hash.clone();
535 let prev_contracts = tracked.contracts.max(1);
536 if let Some(max_loss) = inferred_max_loss {
537 tracked.max_loss_usd = max_loss;
538 } else if live_contracts != prev_contracts && tracked.max_loss_usd > 0.0 {
539 let per_contract = tracked.max_loss_usd / prev_contracts as f64;
540 tracked.max_loss_usd = per_contract * live_contracts as f64;
541 }
542 tracked.contracts = live_contracts;
543 if entry_credit.is_some() {
544 tracked.entry_credit = entry_credit;
545 }
546 state.open_positions.insert(stable_id, tracked);
547 } else {
548 state.open_positions.insert(
549 stable_id.clone(),
550 TrackedPosition {
551 position_id: stable_id,
552 account_hash: account.hash.clone(),
553 underlying: group.underlying.clone(),
554 expiry: group.expiry.clone(),
555 strategy: group.strategy_hint.clone(),
556 opened_at: chrono::Utc::now(),
557 entry_credit,
558 max_loss_usd: inferred_max_loss.unwrap_or(0.0),
559 contracts: live_contracts,
560 entry_params: None,
561 },
562 );
563 }
564 }
565 }
566 state.open_positions.retain(|id, _| live_keys.contains(id));
567 Ok(())
568}
569
570fn take_existing_tracked_position(
571 state: &mut AgentState,
572 stable_id: &str,
573 account_hash: &str,
574 group: &OptionPositionGroup,
575) -> Option<TrackedPosition> {
576 if let Some(tracked) = state.open_positions.remove(stable_id) {
577 return Some(tracked);
578 }
579 if let Some(tracked) = state.open_positions.remove(&group.id) {
580 return Some(tracked);
581 }
582 let key = state.open_positions.iter().find_map(|(key, tracked)| {
583 (tracked.account_hash == account_hash
584 && tracked.underlying == group.underlying
585 && tracked.expiry == group.expiry
586 && tracked.strategy == group.strategy_hint)
587 .then(|| key.clone())
588 })?;
589 state.open_positions.remove(&key)
590}
591
592pub fn infer_max_loss_from_group(group: &OptionPositionGroup) -> Option<f64> {
593 let contracts = spread_contract_count(group) as f64;
594 let entry_credit = infer_entry_credit_from_legs(&group.legs).unwrap_or(0.0);
595 match group.strategy_hint.as_str() {
596 "vertical" => {
597 let (short, long) = vertical_legs(group).ok()?;
598 let short_strike = short.parsed.as_ref()?.strike;
599 let long_strike = long.parsed.as_ref()?.strike;
600 let width = (short_strike - long_strike).abs();
601 Some((width - entry_credit).max(0.0) * 100.0 * contracts)
602 }
603 "iron_condor" => {
604 let put_width = wing_width(group, 'P')?;
605 let call_width = wing_width(group, 'C')?;
606 Some((put_width.max(call_width) - entry_credit).max(0.0) * 100.0 * contracts)
607 }
608 _ => None,
609 }
610}
611
612fn wing_width(group: &OptionPositionGroup, put_call: char) -> Option<f64> {
613 let mut short = None;
614 let mut long = None;
615 for leg in &group.legs {
616 let parsed = leg.parsed.as_ref()?;
617 if parsed.put_call != put_call {
618 continue;
619 }
620 if leg.quantity < 0.0 {
621 short = Some(parsed.strike);
622 } else if leg.quantity > 0.0 {
623 long = Some(parsed.strike);
624 }
625 }
626 Some((short? - long?).abs())
627}
628
629pub fn exit_rules_summary(rules: &ExitRules) -> Value {
630 json!({
631 "profit_target_pct": rules.profit_target_pct,
632 "stop_loss_pct": rules.stop_loss_pct,
633 "dte_close": rules.dte_close,
634 })
635}
636
637pub fn spread_exit_thresholds(entry_credit: f64, exit_rules: &ExitRules) -> (f64, f64) {
639 let target_debit = entry_credit * (1.0 - exit_rules.profit_target_pct / 100.0);
640 let stop_debit = entry_credit * (exit_rules.stop_loss_pct / 100.0);
641 (target_debit, stop_debit)
642}
643
644pub fn debit_to_close_from_group(group: &OptionPositionGroup) -> Option<f64> {
646 let contracts = spread_contract_count(group) as f64;
647 if contracts <= 0.0 {
648 return None;
649 }
650 let debit = (-group.net_market_value).max(0.0) / (contracts * 100.0);
651 Some(debit)
652}
653
654pub fn mark_from_net_market_value(
655 group: &OptionPositionGroup,
656 entry_credit: f64,
657 today: NaiveDate,
658) -> Option<SpreadMark> {
659 let debit = debit_to_close_from_group(group)?;
660 let dte = group
661 .legs
662 .first()
663 .and_then(|l| l.parsed.as_ref())
664 .map(|p| days_to_expiry(p.expiry, today))
665 .unwrap_or(0);
666 let profit_pct = if entry_credit > f64::EPSILON {
667 ((entry_credit - debit) / entry_credit) * 100.0
668 } else {
669 0.0
670 };
671 Some(SpreadMark {
672 entry_credit,
673 debit_to_close: debit,
674 profit_pct,
675 dte,
676 source: "portfolio".into(),
677 })
678}
679
680pub async fn load_live_position_groups(
682 trader: &schwab_api::TraderApi,
683 rules: &RulesConfig,
684) -> Result<std::collections::HashMap<String, OptionPositionGroup>> {
685 let mut map = std::collections::HashMap::new();
686 for account in rules.enabled_accounts() {
687 let legs = list_option_positions(trader, Some(&account.hash)).await?;
688 for group in group_option_legs(&legs) {
689 let key = stable_position_key(&account.hash, &group);
690 map.insert(key, group);
691 }
692 }
693 Ok(map)
694}
695
696pub fn option_group_from_tracked(tracked: &TrackedPosition) -> Option<OptionPositionGroup> {
698 let params = tracked.entry_params.as_ref()?;
699 let v: VerticalParams = serde_json::from_value(params.clone()).ok()?;
700 let put_call = if v.spread_type.to_ascii_lowercase().contains("put") {
701 'P'
702 } else {
703 'C'
704 };
705 let expiry = parse_expiry(&v.expiry).ok()?;
706 let short_sym = build_option_symbol(&v.underlying, &v.expiry, put_call, v.short_strike).ok()?;
707 let long_sym = build_option_symbol(&v.underlying, &v.expiry, put_call, v.long_strike).ok()?;
708 let contracts = tracked.contracts.max(1) as f64;
709 let legs = vec![
710 OptionPositionLeg {
711 symbol: short_sym.clone(),
712 underlying: v.underlying.clone(),
713 quantity: -contracts,
714 market_value: 0.0,
715 average_price: tracked.entry_credit,
716 parsed: parse_option_symbol(&short_sym).ok(),
717 },
718 OptionPositionLeg {
719 symbol: long_sym.clone(),
720 underlying: v.underlying.clone(),
721 quantity: contracts,
722 market_value: 0.0,
723 average_price: None,
724 parsed: parse_option_symbol(&long_sym).ok(),
725 },
726 ];
727 Some(OptionPositionGroup {
728 id: tracked.position_id.clone(),
729 underlying: v.underlying,
730 expiry: expiry.format("%Y-%m-%d").to_string(),
731 strategy_hint: tracked.strategy.clone(),
732 legs,
733 net_market_value: 0.0,
734 })
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use crate::rules::{ExitRules, RulesConfig};
741
742 #[test]
743 fn evaluate_exit_from_mark_profit_target() {
744 let exit_rules = ExitRules {
745 profit_target_pct: 50.0,
746 stop_loss_pct: 200.0,
747 dte_close: 21,
748 };
749 let rules = RulesConfig {
750 version: 1,
751 agent_id: "t".into(),
752 accounts: vec![],
753 schedule: Default::default(),
754 strategies: Default::default(),
755 watchlist: vec![],
756 entry_rules: Default::default(),
757 exit_rules,
758 risk: Default::default(),
759 execution: Default::default(),
760 llm: Default::default(),
761 notify: Default::default(),
762 simulation: None,
763 };
764 let mark = SpreadMark {
765 entry_credit: 0.25,
766 debit_to_close: 0.10,
767 profit_pct: 60.0,
768 dte: 30,
769 source: "test".into(),
770 };
771 let exit = evaluate_exit_from_mark(&rules, Some(0.25), &mark);
772 assert_eq!(
773 exit.as_ref().map(|e| e.reason.as_str()),
774 Some("profit_target")
775 );
776 }
777
778 #[test]
779 fn profit_target_triggers_at_half_credit() {
780 let entry = 0.29;
781 let debit = 0.14;
782 let profit_pct = ((entry - debit) / entry) * 100.0;
783 assert!(profit_pct >= 50.0);
784 }
785
786 #[test]
787 fn stop_loss_triggers_at_double_credit() {
788 let entry = 0.29;
789 let stop_debit = entry * 2.0;
790 assert!(0.58 >= stop_debit - 0.001);
791 }
792
793 #[test]
794 fn infers_credit_from_leg_averages() {
795 let legs = vec![
796 OptionPositionLeg {
797 symbol: "IWM".into(),
798 underlying: "IWM".into(),
799 quantity: -1.0,
800 market_value: -100.0,
801 average_price: Some(0.29),
802 parsed: None,
803 },
804 OptionPositionLeg {
805 symbol: "IWM".into(),
806 underlying: "IWM".into(),
807 quantity: 1.0,
808 market_value: 50.0,
809 average_price: Some(0.05),
810 parsed: None,
811 },
812 ];
813 let credit = infer_entry_credit_from_legs(&legs).unwrap();
814 assert!((credit - 0.24).abs() < 0.001);
815 }
816
817 #[test]
818 fn infers_vertical_max_loss_from_live_group() {
819 let group = OptionPositionGroup {
820 id: "IWM|2026-07-31".into(),
821 underlying: "IWM".into(),
822 expiry: "2026-07-31".into(),
823 strategy_hint: "vertical".into(),
824 legs: vec![
825 OptionPositionLeg {
826 symbol: "IWM 260731P00282000".into(),
827 underlying: "IWM".into(),
828 quantity: -2.0,
829 market_value: -64.0,
830 average_price: Some(0.29),
831 parsed: crate::options::symbology::parse_option_symbol("IWM 260731P00282000")
832 .ok(),
833 },
834 OptionPositionLeg {
835 symbol: "IWM 260731P00280000".into(),
836 underlying: "IWM".into(),
837 quantity: 2.0,
838 market_value: 10.0,
839 average_price: Some(0.05),
840 parsed: crate::options::symbology::parse_option_symbol("IWM 260731P00280000")
841 .ok(),
842 },
843 ],
844 net_market_value: -54.0,
845 };
846 let max_loss = infer_max_loss_from_group(&group).unwrap();
847 assert!((max_loss - 352.0).abs() < 0.01);
848 }
849
850 #[test]
851 fn find_expiry_strikes_matches_schwab_key() {
852 let chain = json!({
853 "putExpDateMap": {
854 "2026-07-31:36": { "282.0": [] }
855 }
856 });
857 let strikes = find_expiry_strikes(&chain, "putExpDateMap", "2026-07-31").unwrap();
858 assert!(strikes.is_object());
859 }
860
861 #[test]
862 fn format_chain_strike_uses_one_decimal_for_whole_strikes() {
863 assert_eq!(format_chain_strike(282.0), "282.0");
864 assert_eq!(format_chain_strike(282.5), "282.50");
865 }
866}