1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use ratatui::style::Color;
7
8use crate::agent::exits::{
9 evaluate_exit_from_mark_with_analytics, spread_exit_thresholds, SpreadMark,
10};
11use crate::agent::spread_analytics::SpreadAnalytics;
12use crate::agent::state::{AgentState, TrackedPosition};
13use crate::rules::RulesConfig;
14
15#[derive(Debug, Clone, Default)]
16pub struct SpreadLiveSnapshot {
17 pub marks: HashMap<String, SpreadPositionMark>,
18 pub last_fetch: Option<DateTime<Utc>>,
19 pub last_error: Option<String>,
20}
21
22#[derive(Debug, Clone)]
23pub struct SpreadPositionMark {
24 pub mark: SpreadMark,
25 pub analytics: Option<SpreadAnalytics>,
26 pub imminent_exit: Option<String>,
27 pub mark_age_secs: Option<i64>,
28}
29
30#[derive(Debug, Clone)]
31pub struct SpreadMonitorView {
32 pub underlying: String,
33 pub expiry: String,
34 pub strategy: String,
35 pub contracts: u32,
36 pub entry_credit: f64,
37 pub debit_to_close: f64,
38 pub target_debit: f64,
39 pub stop_debit: f64,
40 pub profit_pct: f64,
41 pub pnl_usd: f64,
42 pub pct_toward_target: f64,
43 pub pct_cushion_from_stop: f64,
44 pub dte: i64,
45 pub dte_close: u32,
46 pub imminent_exit: Option<String>,
47 pub mark_source: String,
48 pub mark_age_secs: Option<i64>,
49 pub analytics: Option<SpreadAnalytics>,
50}
51
52pub fn build_spread_monitor(
53 tracked: &TrackedPosition,
54 live: Option<&SpreadPositionMark>,
55 exit_rules: &crate::rules::ExitRules,
56) -> SpreadMonitorView {
57 let entry_credit = tracked
58 .entry_credit
59 .filter(|c| *c > f64::EPSILON)
60 .unwrap_or(0.0);
61 let contracts = tracked.contracts.max(1);
62 let (target_debit, stop_debit) = spread_exit_thresholds(entry_credit, exit_rules);
63
64 let (debit_to_close, profit_pct, dte, mark_source, mark_age_secs, imminent_exit, analytics) =
65 if let Some(live) = live {
66 (
67 live.mark.debit_to_close,
68 live.mark.profit_pct,
69 live.mark.dte,
70 live.mark.source.clone(),
71 live.mark_age_secs,
72 live.imminent_exit.clone(),
73 live.analytics.clone(),
74 )
75 } else {
76 (
77 entry_credit,
78 0.0,
79 0,
80 "stale".into(),
81 None,
82 None,
83 None,
84 )
85 };
86
87 let pnl_usd = (entry_credit - debit_to_close) * 100.0 * contracts as f64;
88
89 let target_span = (entry_credit - target_debit).max(0.0001);
90 let pct_toward_target =
91 ((entry_credit - debit_to_close) / target_span * 100.0).clamp(-100.0, 150.0);
92
93 let stop_span = (stop_debit - entry_credit).max(0.0001);
94 let pct_cushion_from_stop =
95 ((stop_debit - debit_to_close) / stop_span * 100.0).clamp(0.0, 200.0);
96
97 SpreadMonitorView {
98 underlying: tracked.underlying.clone(),
99 expiry: tracked.expiry.clone(),
100 strategy: tracked.strategy.clone(),
101 contracts,
102 entry_credit,
103 debit_to_close,
104 target_debit,
105 stop_debit,
106 profit_pct,
107 pnl_usd,
108 pct_toward_target,
109 pct_cushion_from_stop,
110 dte,
111 dte_close: exit_rules.dte_close,
112 imminent_exit,
113 mark_source,
114 mark_age_secs,
115 analytics,
116 }
117}
118
119pub fn spread_exit_rail(
122 stop_debit: f64,
123 entry_debit: f64,
124 target_debit: f64,
125 current_debit: f64,
126 width: usize,
127) -> String {
128 let width = width.max(12);
129 let span = (stop_debit - target_debit).max(0.0001);
130 let max_idx = width.saturating_sub(1) as f64;
131 let debit_idx = |debit: f64| {
132 ((stop_debit - debit.clamp(target_debit, stop_debit)) / span * max_idx).round() as usize
133 };
134 let mut chars: Vec<char> = vec!['·'; width];
135 let entry_idx = debit_idx(entry_debit);
136 let current_idx = debit_idx(current_debit);
137 if entry_idx < width {
138 chars[entry_idx] = '│';
139 }
140 if current_idx < width {
141 chars[current_idx] = '●';
142 }
143 chars.into_iter().collect()
144}
145
146pub(crate) fn spread_rail_progress_labels(m: &SpreadMonitorView) -> String {
147 let above_stop = m.pct_cushion_from_stop;
148 if m.debit_to_close > m.entry_credit + f64::EPSILON {
149 let stop_span = (m.stop_debit - m.entry_credit).max(0.0001);
150 let toward_stop =
151 ((m.debit_to_close - m.entry_credit) / stop_span * 100.0).clamp(0.0, 200.0);
152 format!(" {toward_stop:.0}% toward stop {above_stop:.0}% above stop")
153 } else {
154 format!(
155 " {:.0}%→target {above_stop:.0}% above stop",
156 m.pct_toward_target.max(0.0)
157 )
158 }
159}
160
161#[derive(Debug, Clone, Copy)]
162pub struct SpreadHealth {
163 pub label: &'static str,
164 pub arrow: &'static str,
165 pub color: Color,
166}
167
168pub fn spread_health(m: &SpreadMonitorView, exits_armed: bool) -> SpreadHealth {
169 let pop = m.analytics.as_ref().and_then(|a| a.spread_pop_pct);
170 let delta = m
171 .analytics
172 .as_ref()
173 .and_then(|a| a.short_delta.map(|d| d.abs()));
174 let short_otm = m.analytics.as_ref().and_then(|a| a.short_otm_pct);
175 let path_healthy = short_otm.is_some_and(|otm| otm >= 3.5)
177 && pop.is_none_or(|p| p >= 55.0)
178 && delta.is_none_or(|d| d < 0.32);
179 let near_strike = short_otm.is_some_and(|otm| otm < 3.5);
180 let near_stop = m.pct_cushion_from_stop < 30.0;
181
182 if m.imminent_exit.is_some() {
183 if exits_armed {
184 return SpreadHealth {
185 label: "EXIT SOON",
186 arrow: "!",
187 color: Color::Red,
188 };
189 }
190 return SpreadHealth {
191 label: "PENDING EXIT",
192 arrow: "!",
193 color: Color::Yellow,
194 };
195 }
196 if path_healthy {
198 if m.profit_pct >= 40.0 || m.pct_toward_target >= 90.0 {
199 return SpreadHealth {
200 label: "STRONG WIN",
201 arrow: "▲▲",
202 color: Color::LightGreen,
203 };
204 }
205 if m.profit_pct > 5.0 {
206 return SpreadHealth {
207 label: "WINNING",
208 arrow: "▲",
209 color: Color::Green,
210 };
211 }
212 return SpreadHealth {
213 label: "PATH OK",
214 arrow: "═",
215 color: Color::Cyan,
216 };
217 }
218 if near_strike && (m.profit_pct <= -25.0 || near_stop) {
219 return SpreadHealth {
220 label: "LOSING",
221 arrow: "▼",
222 color: Color::Red,
223 };
224 }
225 if near_strike || pop.is_some_and(|p| p < 50.0) || delta.is_some_and(|d| d >= 0.35) {
226 return SpreadHealth {
227 label: "AT RISK",
228 arrow: "▼",
229 color: Color::Yellow,
230 };
231 }
232 if delta.is_some_and(|d| d >= 0.28) || pop.is_some_and(|p| p < 60.0) {
233 return SpreadHealth {
234 label: "WATCH",
235 arrow: "◆",
236 color: Color::Magenta,
237 };
238 }
239 if m.profit_pct >= 40.0 || m.pct_toward_target >= 90.0 {
240 return SpreadHealth {
241 label: "STRONG WIN",
242 arrow: "▲▲",
243 color: Color::LightGreen,
244 };
245 }
246 if m.profit_pct > 0.0 {
247 return SpreadHealth {
248 label: "WINNING",
249 arrow: "▲",
250 color: Color::Green,
251 };
252 }
253 SpreadHealth {
254 label: "HOLDING",
255 arrow: "═",
256 color: Color::Cyan,
257 }
258}
259
260fn spread_monitor_sort_key(pos: &TrackedPosition) -> (DateTime<Utc>, &str, &str, &str) {
261 (
262 pos.opened_at,
263 pos.underlying.as_str(),
264 pos.expiry.as_str(),
265 pos.position_id.as_str(),
266 )
267}
268
269pub fn list_spread_monitors(
270 rules: &RulesConfig,
271 state: &AgentState,
272 live: Option<&SpreadLiveSnapshot>,
273) -> Vec<SpreadMonitorView> {
274 let mut positions: Vec<_> = state.open_positions.values().collect();
275 positions.sort_by_key(|pos| spread_monitor_sort_key(pos));
276 positions
277 .into_iter()
278 .map(|pos| {
279 let live_mark = live.and_then(|l| l.marks.get(&pos.position_id));
280 build_spread_monitor(pos, live_mark, &rules.exit_rules)
281 })
282 .collect()
283}
284
285pub fn attach_exit_hint(mark: &mut SpreadPositionMark, rules: &RulesConfig, entry_credit: f64) {
286 if entry_credit <= f64::EPSILON {
287 return;
288 }
289 if let Some(eval) = evaluate_exit_from_mark_with_analytics(
291 rules,
292 Some(entry_credit),
293 &mark.mark,
294 mark.analytics.as_ref(),
295 ) {
296 mark.imminent_exit = Some(eval.reason);
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::agent::spread_analytics::{compute_vertical_analytics, spread_win_score, VerticalAnalyticsInput};
304 use crate::agent::state::AgentState;
305 use crate::rules::{ExitRules, RulesConfig};
306
307 #[test]
308 fn spread_rail_places_markers() {
309 let rail = spread_exit_rail(0.58, 0.29, 0.145, 0.20, 20);
310 assert!(rail.contains('│'));
311 assert!(rail.contains('●'));
312 }
313
314 #[test]
315 fn spread_rail_right_is_toward_target() {
316 let winning = spread_exit_rail(0.58, 0.29, 0.145, 0.20, 28);
317 let entry_win = winning.find('│').unwrap();
318 let mark_win = winning.find('●').unwrap();
319 assert!(
320 mark_win > entry_win,
321 "lower debit (winning) should sit right of entry"
322 );
323
324 let losing = spread_exit_rail(0.56, 0.28, 0.14, 0.46, 28);
325 let entry_lose = losing.find('│').unwrap();
326 let mark_lose = losing.find('●').unwrap();
327 assert!(
328 mark_lose < entry_lose,
329 "higher debit (losing) should sit left of entry toward stop"
330 );
331 }
332
333 #[test]
334 fn spread_rail_labels_show_toward_stop_when_losing() {
335 let exit_rules = ExitRules::default();
336 let tracked = TrackedPosition {
337 position_id: "IWM|2026-08-14".into(),
338 account_hash: "h".into(),
339 underlying: "IWM".into(),
340 expiry: "2026-08-14".into(),
341 strategy: "vertical".into(),
342 opened_at: Utc::now(),
343 entry_credit: Some(0.28),
344 max_loss_usd: 144.0,
345 contracts: 1,
346 entry_params: None,
347 ..Default::default()
348 };
349 let live = SpreadPositionMark {
350 mark: SpreadMark {
351 entry_credit: 0.28,
352 debit_to_close: 0.46,
353 profit_pct: -64.3,
354 dte: 35,
355 source: "test".into(),
356 },
357 analytics: None,
358 imminent_exit: None,
359 mark_age_secs: Some(0),
360 };
361 let m = build_spread_monitor(&tracked, Some(&live), &exit_rules);
362 let labels = spread_rail_progress_labels(&m);
363 assert!(labels.contains("toward stop"));
364 assert!(labels.contains("above stop"));
365 assert!(!labels.contains("→target"));
366 }
367
368 #[test]
369 fn profit_pct_maps_to_target_progress() {
370 let exit_rules = ExitRules::default();
371 let tracked = TrackedPosition {
372 position_id: "SPY|2026-07-18".into(),
373 account_hash: "h".into(),
374 underlying: "SPY".into(),
375 expiry: "2026-07-18".into(),
376 strategy: "vertical".into(),
377 opened_at: Utc::now(),
378 entry_credit: Some(0.40),
379 max_loss_usd: 200.0,
380 contracts: 2,
381 entry_params: None,
382 ..Default::default()
383 };
384 let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
385 is_put_spread: true,
386 underlying_price: 520.0,
387 short_strike: 500.0,
388 long_strike: 498.0,
389 credit: 0.40,
390 dte: 25,
391 chain_iv_pct: Some(18.0),
392 realized_vol_pct: None,
393 short_delta: Some(-0.20),
394 long_delta: Some(-0.12),
395 short_theta: Some(-0.10),
396 long_theta: Some(-0.06),
397 contracts: 2,
398 underlying_change_pct: Some(-0.3),
399 });
400 let live = SpreadPositionMark {
401 mark: SpreadMark {
402 entry_credit: 0.40,
403 debit_to_close: 0.20,
404 profit_pct: 50.0,
405 dte: 25,
406 source: "test".into(),
407 },
408 analytics: Some(analytics),
409 imminent_exit: Some("profit_target".into()),
410 mark_age_secs: Some(5),
411 };
412 let m = build_spread_monitor(&tracked, Some(&live), &exit_rules);
413 assert!((m.pct_toward_target - 100.0).abs() < 0.1);
414 assert!((m.pnl_usd - 40.0).abs() < 0.01);
415 assert!(m.analytics.is_some());
416 assert_eq!(spread_health(&m, true).label, "EXIT SOON");
417 assert_eq!(spread_health(&m, false).label, "PENDING EXIT");
418 }
419
420 #[test]
421 fn winning_position_gets_winning_health() {
422 let exit_rules = ExitRules::default();
423 let tracked = TrackedPosition {
424 position_id: "IWM|2026-07-31".into(),
425 account_hash: "h".into(),
426 underlying: "IWM".into(),
427 expiry: "2026-07-31".into(),
428 strategy: "vertical".into(),
429 opened_at: Utc::now(),
430 entry_credit: Some(0.32),
431 max_loss_usd: 136.0,
432 contracts: 2,
433 entry_params: None,
434 ..Default::default()
435 };
436 let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
437 is_put_spread: true,
438 underlying_price: 301.0,
439 short_strike: 282.0,
440 long_strike: 280.0,
441 credit: 0.32,
442 dte: 30,
443 chain_iv_pct: Some(29.0),
444 realized_vol_pct: None,
445 short_delta: Some(-0.16),
446 long_delta: Some(-0.14),
447 short_theta: Some(-0.06),
448 long_theta: Some(-0.04),
449 contracts: 2,
450 underlying_change_pct: Some(0.4),
451 });
452 let live = SpreadPositionMark {
453 mark: SpreadMark {
454 entry_credit: 0.32,
455 debit_to_close: 0.27,
456 profit_pct: 14.7,
457 dte: 30,
458 source: "test".into(),
459 },
460 analytics: Some(analytics),
461 imminent_exit: None,
462 mark_age_secs: Some(1),
463 };
464 let m = build_spread_monitor(&tracked, Some(&live), &exit_rules);
465 let h = spread_health(&m, true);
466 assert!(matches!(h.label, "WINNING" | "STRONG WIN" | "WATCH" | "PATH OK"));
467 assert!(spread_win_score(m.profit_pct, m.analytics.as_ref().unwrap(), m.pct_cushion_from_stop)
468 > 60.0);
469 }
470
471 #[test]
472 fn far_otm_negative_mtm_is_path_ok_not_losing() {
473 let exit_rules = ExitRules::default();
474 let tracked = TrackedPosition {
475 position_id: "QQQ|2026-08-21".into(),
476 account_hash: "h".into(),
477 underlying: "QQQ".into(),
478 expiry: "2026-08-21".into(),
479 strategy: "vertical".into(),
480 opened_at: Utc::now(),
481 entry_credit: Some(0.82),
482 max_loss_usd: 418.0,
483 contracts: 1,
484 entry_params: None,
485 ..Default::default()
486 };
487 let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
488 is_put_spread: true,
489 underlying_price: 685.0,
490 short_strike: 655.0,
491 long_strike: 650.0,
492 credit: 0.82,
493 dte: 28,
494 chain_iv_pct: Some(29.0),
495 realized_vol_pct: None,
496 short_delta: Some(-0.23),
497 long_delta: Some(-0.20),
498 short_theta: Some(-0.30),
499 long_theta: Some(-0.28),
500 contracts: 1,
501 underlying_change_pct: Some(-1.2),
502 });
503 assert!(analytics.short_otm_pct.unwrap() >= 3.5);
505 let live = SpreadPositionMark {
506 mark: SpreadMark {
507 entry_credit: 0.82,
508 debit_to_close: 1.10,
509 profit_pct: -34.0,
510 dte: 28,
511 source: "test".into(),
512 },
513 analytics: Some(analytics),
514 imminent_exit: None,
515 mark_age_secs: Some(1),
516 };
517 let m = build_spread_monitor(&tracked, Some(&live), &exit_rules);
518 assert_eq!(spread_health(&m, true).label, "PATH OK");
519 }
520
521 #[test]
522 fn spread_monitors_sorted_by_opened_at_not_hashmap_order() {
523 let rules = RulesConfig {
524 version: 1,
525 agent_id: "t".into(),
526 accounts: vec![],
527 schedule: Default::default(),
528 strategies: Default::default(),
529 watchlist: vec![],
530 entry_policy: Default::default(),
531 entry_rules: Default::default(),
532 exit_rules: ExitRules::default(),
533 risk: Default::default(),
534 regime: Default::default(),
535 execution: Default::default(),
536 llm: Default::default(),
537 notify: Default::default(),
538 simulation: None,
539 };
540 let mut state = AgentState::default();
541 let older = Utc::now() - chrono::Duration::days(10);
542 let newer = Utc::now() - chrono::Duration::days(2);
543 state.open_positions.insert(
544 "IWM|2026-08-14".into(),
545 TrackedPosition {
546 position_id: "IWM|2026-08-14".into(),
547 underlying: "IWM".into(),
548 expiry: "2026-08-14".into(),
549 opened_at: newer,
550 ..Default::default()
551 },
552 );
553 state.open_positions.insert(
554 "IWM|2026-07-18".into(),
555 TrackedPosition {
556 position_id: "IWM|2026-07-18".into(),
557 underlying: "IWM".into(),
558 expiry: "2026-07-18".into(),
559 opened_at: older,
560 ..Default::default()
561 },
562 );
563 let monitors = list_spread_monitors(&rules, &state, None);
564 assert_eq!(monitors.len(), 2);
565 assert_eq!(monitors[0].expiry, "2026-07-18");
566 assert_eq!(monitors[1].expiry, "2026-08-14");
567 }
568}