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
32pub 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
91pub 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}