1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use ratatui::style::{Color, Modifier, Style};
7use ratatui::text::{Line, Span};
8
9use crate::agent::exits::{
10 evaluate_exit_from_mark, spread_exit_thresholds, SpreadMark,
11};
12use crate::agent::spread_analytics::{price_cushion_rail, spread_win_score, SpreadAnalytics};
13use crate::agent::state::{AgentState, TrackedPosition};
14use crate::rules::RulesConfig;
15
16#[derive(Debug, Clone, Default)]
17pub struct SpreadLiveSnapshot {
18 pub marks: HashMap<String, SpreadPositionMark>,
19 pub last_fetch: Option<DateTime<Utc>>,
20 pub last_error: Option<String>,
21}
22
23#[derive(Debug, Clone)]
24pub struct SpreadPositionMark {
25 pub mark: SpreadMark,
26 pub analytics: Option<SpreadAnalytics>,
27 pub imminent_exit: Option<String>,
28 pub mark_age_secs: Option<i64>,
29}
30
31#[derive(Debug, Clone)]
32pub struct SpreadMonitorView {
33 pub underlying: String,
34 pub expiry: String,
35 pub strategy: String,
36 pub contracts: u32,
37 pub entry_credit: f64,
38 pub debit_to_close: f64,
39 pub target_debit: f64,
40 pub stop_debit: f64,
41 pub profit_pct: f64,
42 pub pnl_usd: f64,
43 pub pct_toward_target: f64,
44 pub pct_cushion_from_stop: f64,
45 pub dte: i64,
46 pub dte_close: u32,
47 pub imminent_exit: Option<String>,
48 pub mark_source: String,
49 pub mark_age_secs: Option<i64>,
50 pub analytics: Option<SpreadAnalytics>,
51}
52
53pub fn build_spread_monitor(
54 tracked: &TrackedPosition,
55 live: Option<&SpreadPositionMark>,
56 exit_rules: &crate::rules::ExitRules,
57) -> SpreadMonitorView {
58 let entry_credit = tracked
59 .entry_credit
60 .filter(|c| *c > f64::EPSILON)
61 .unwrap_or(0.0);
62 let contracts = tracked.contracts.max(1);
63 let (target_debit, stop_debit) = spread_exit_thresholds(entry_credit, exit_rules);
64
65 let (debit_to_close, profit_pct, dte, mark_source, mark_age_secs, imminent_exit, analytics) =
66 if let Some(live) = live {
67 (
68 live.mark.debit_to_close,
69 live.mark.profit_pct,
70 live.mark.dte,
71 live.mark.source.clone(),
72 live.mark_age_secs,
73 live.imminent_exit.clone(),
74 live.analytics.clone(),
75 )
76 } else {
77 (
78 entry_credit,
79 0.0,
80 0,
81 "stale".into(),
82 None,
83 None,
84 None,
85 )
86 };
87
88 let pnl_usd = (entry_credit - debit_to_close) * 100.0 * contracts as f64;
89
90 let target_span = (entry_credit - target_debit).max(0.0001);
91 let pct_toward_target =
92 ((entry_credit - debit_to_close) / target_span * 100.0).clamp(-100.0, 150.0);
93
94 let stop_span = (stop_debit - entry_credit).max(0.0001);
95 let pct_cushion_from_stop =
96 ((stop_debit - debit_to_close) / stop_span * 100.0).clamp(0.0, 200.0);
97
98 SpreadMonitorView {
99 underlying: tracked.underlying.clone(),
100 expiry: tracked.expiry.clone(),
101 strategy: tracked.strategy.clone(),
102 contracts,
103 entry_credit,
104 debit_to_close,
105 target_debit,
106 stop_debit,
107 profit_pct,
108 pnl_usd,
109 pct_toward_target,
110 pct_cushion_from_stop,
111 dte,
112 dte_close: exit_rules.dte_close,
113 imminent_exit,
114 mark_source,
115 mark_age_secs,
116 analytics,
117 }
118}
119
120pub 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 lo = target_debit.min(stop_debit);
130 let hi = stop_debit.max(target_debit);
131 let span = (hi - lo).max(0.0001);
132 let mut chars: Vec<char> = vec!['·'; width];
133 let entry_idx =
134 ((entry_debit.clamp(lo, hi) - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
135 let current_idx =
136 ((current_debit.clamp(lo, hi) - lo) / span * (width.saturating_sub(1) as f64)).round() as usize;
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 fn pnl_style(profit_pct: f64) -> Style {
147 if profit_pct >= 25.0 {
148 Style::default().fg(Color::Green)
149 } else if profit_pct <= -25.0 {
150 Style::default().fg(Color::Red)
151 } else if profit_pct >= 0.0 {
152 Style::default().fg(Color::LightGreen)
153 } else {
154 Style::default().fg(Color::Yellow)
155 }
156}
157
158#[derive(Debug, Clone, Copy)]
159pub struct SpreadHealth {
160 pub label: &'static str,
161 pub arrow: &'static str,
162 pub color: Color,
163}
164
165pub fn spread_health(m: &SpreadMonitorView) -> SpreadHealth {
166 let pop = m.analytics.as_ref().and_then(|a| a.spread_pop_pct);
167 let delta = m
168 .analytics
169 .as_ref()
170 .and_then(|a| a.short_delta.map(|d| d.abs()));
171 let near_stop = m.pct_cushion_from_stop < 30.0;
172
173 if m.imminent_exit.is_some() {
174 return SpreadHealth {
175 label: "EXIT SOON",
176 arrow: "!",
177 color: Color::Red,
178 };
179 }
180 if m.profit_pct <= -25.0 || (near_stop && m.profit_pct < -10.0) {
181 return SpreadHealth {
182 label: "LOSING",
183 arrow: "▼",
184 color: Color::Red,
185 };
186 }
187 if m.profit_pct < 0.0 || pop.is_some_and(|p| p < 50.0) || delta.is_some_and(|d| d >= 0.35) {
188 return SpreadHealth {
189 label: "AT RISK",
190 arrow: "▼",
191 color: Color::Yellow,
192 };
193 }
194 if delta.is_some_and(|d| d >= 0.28) || pop.is_some_and(|p| p < 60.0) {
195 return SpreadHealth {
196 label: "WATCH",
197 arrow: "◆",
198 color: Color::Magenta,
199 };
200 }
201 if m.profit_pct >= 40.0 || m.pct_toward_target >= 90.0 {
202 return SpreadHealth {
203 label: "STRONG WIN",
204 arrow: "▲▲",
205 color: Color::LightGreen,
206 };
207 }
208 if m.profit_pct > 0.0 {
209 return SpreadHealth {
210 label: "WINNING",
211 arrow: "▲",
212 color: Color::Green,
213 };
214 }
215 SpreadHealth {
216 label: "HOLDING",
217 arrow: "═",
218 color: Color::Cyan,
219 }
220}
221
222fn meter_spans(ratio: f64, width: usize, fill: Color) -> Vec<Span<'static>> {
223 let ratio = ratio.clamp(0.0, 1.0);
224 let filled = (ratio * width as f64).round() as usize;
225 let empty = width.saturating_sub(filled);
226 vec![
227 Span::styled("█".repeat(filled), Style::default().fg(fill)),
228 Span::styled("░".repeat(empty), Style::default().fg(Color::DarkGray)),
229 ]
230}
231
232fn spread_type_label(a: &SpreadAnalytics) -> &'static str {
233 if a.is_put_spread {
234 "put credit"
235 } else {
236 "call credit"
237 }
238}
239
240fn strike_line(a: &SpreadAnalytics) -> String {
241 let leg = if a.is_put_spread { "puts" } else { "calls" };
242 format!(
243 "{leg} ${:.0}/${:.0} width ${:.0}",
244 a.short_strike, a.long_strike, a.width
245 )
246}
247
248fn spot_line(a: &SpreadAnalytics) -> String {
249 let chg = a
250 .underlying_change_pct
251 .map(|c| format!(" ({c:+.1}% today)"))
252 .unwrap_or_default();
253 let otm = a
254 .short_otm_pct
255 .map(|p| format!(" short {p:.1}% OTM"))
256 .unwrap_or_default();
257 let dist = a.distance_to_short_strike_usd.map(|d| {
258 if d >= 0.0 {
259 format!(" (${d:.0} above short)")
260 } else {
261 format!(" (${:.0} below short)", d.abs())
262 }
263 });
264 format!(
265 "spot ${:.2}{chg}{otm}{}",
266 a.underlying_price,
267 dist.unwrap_or_default()
268 )
269}
270
271fn health_banner_line(m: &SpreadMonitorView) -> Line<'static> {
272 let health = spread_health(m);
273 let win = m
274 .analytics
275 .as_ref()
276 .map(|a| spread_win_score(m.profit_pct, a, m.pct_cushion_from_stop))
277 .unwrap_or(50.0);
278 let win_color = if win >= 70.0 {
279 Color::Green
280 } else if win >= 45.0 {
281 Color::Yellow
282 } else {
283 Color::Red
284 };
285 let mut spans = vec![
286 Span::styled(
287 format!("{} {} ", health.arrow, health.label),
288 Style::default()
289 .fg(health.color)
290 .add_modifier(Modifier::BOLD),
291 ),
292 Span::styled("win ", Style::default().fg(Color::DarkGray)),
293 Span::styled(
294 format!("{win:.0}%"),
295 Style::default()
296 .fg(win_color)
297 .add_modifier(Modifier::BOLD),
298 ),
299 Span::raw(" "),
300 ];
301 spans.extend(meter_spans(win / 100.0, 10, win_color));
302 spans.push(Span::styled(
303 format!(" {:+.1}% P&L", m.profit_pct),
304 pnl_style(m.profit_pct),
305 ));
306 Line::from(spans)
307}
308
309fn probability_line(_m: &SpreadMonitorView, a: &SpreadAnalytics) -> Line<'static> {
310 let pop = a.spread_pop_pct.unwrap_or(0.0);
311 let pop_color = if pop >= 70.0 {
312 Color::Green
313 } else if pop >= 50.0 {
314 Color::Yellow
315 } else {
316 Color::Red
317 };
318 let otm_expire = a
319 .approx_short_otm_prob_pct
320 .map(|p| format!("{p:.0}%"))
321 .unwrap_or_else(|| "—".into());
322 let touch_short = a
323 .short_delta
324 .map(|d| format!("{:.0}%", d.abs() * 100.0))
325 .unwrap_or_else(|| "—".into());
326 let mut spans = vec![
327 Span::styled("POP vs BE ", Style::default().fg(Color::DarkGray)),
328 Span::styled(
329 format!("{pop:.0}%"),
330 Style::default().fg(pop_color).add_modifier(Modifier::BOLD),
331 ),
332 Span::raw(" "),
333 ];
334 spans.extend(meter_spans(pop / 100.0, 8, pop_color));
335 spans.push(Span::raw(format!(
336 " short ~{otm_expire} expire OTM · ~{touch_short} touch short"
337 )));
338 Line::from(spans)
339}
340
341fn fmt_opt_f(v: Option<f64>, decimals: usize) -> String {
342 v.map(|x| format!("{x:.prec$}", prec = decimals))
343 .unwrap_or_else(|| "—".into())
344}
345
346fn analytics_lines(m: &SpreadMonitorView) -> Vec<Line<'static>> {
347 let Some(a) = &m.analytics else {
348 return vec![Line::from(Span::styled(
349 "greeks: (waiting for chain refresh…)",
350 Style::default().fg(Color::DarkGray),
351 ))];
352 };
353
354 let mut lines = Vec::new();
355
356 lines.push(Line::from(vec![
357 Span::styled(strike_line(a), Style::default().fg(Color::Cyan)),
358 Span::raw(format!(" exp {}", m.expiry)),
359 ]));
360
361 lines.push(Line::from(Span::styled(
362 spot_line(a),
363 Style::default().fg(Color::White),
364 )));
365
366 lines.push(probability_line(m, a));
367
368 let delta_s = a
369 .short_delta
370 .map(|d| format!("{d:+.2}"))
371 .unwrap_or_else(|| "—".into());
372 let delta_l = a
373 .long_delta
374 .map(|d| format!("{d:+.2}"))
375 .unwrap_or_else(|| "—".into());
376 let iv = fmt_opt_f(a.chain_iv_pct, 1);
377 let theta = a
378 .net_theta_per_day_usd
379 .map(|t| format!("{:+.2}/d", t))
380 .unwrap_or_else(|| "—".into());
381
382 lines.push(Line::from(format!(
383 "δ short {delta_s} long {delta_l} IV {iv}% θ {theta}"
384 )));
385
386 let be = fmt_opt_f(a.break_even_price, 2);
387 let be_cushion = a
388 .distance_to_be_pct
389 .map(|p| format!("{p:+.1}%"))
390 .unwrap_or_else(|| "—".into());
391 let ctw = a
392 .credit_to_width_pct
393 .map(|p| format!("{p:.0}%"))
394 .unwrap_or_else(|| "—".into());
395
396 lines.push(Line::from(format!(
397 "BE ${be} cushion {be_cushion} cr/width {ctw}"
398 )));
399
400 if let (Some(em), Some(em_pct)) = (a.expected_move_1sigma_usd, a.expected_move_1sigma_pct) {
401 let inside_style = if a.short_strike_inside_1sigma == Some(true) {
402 Style::default().fg(Color::Yellow)
403 } else {
404 Style::default().fg(Color::DarkGray)
405 };
406 let inside = a
407 .short_strike_inside_1sigma
408 .map(|b| if b { "short inside 1σ" } else { "short outside 1σ" })
409 .unwrap_or("");
410 lines.push(Line::from(vec![
411 Span::raw(format!("1σ move ±${em:.2} ({em_pct:.1}%) ")),
412 Span::styled(inside, inside_style),
413 ]));
414 }
415
416 if let Some(be_px) = a.break_even_price {
417 let (rail, _) = price_cushion_rail(
418 be_px,
419 a.underlying_price,
420 a.short_strike,
421 a.is_put_spread,
422 28,
423 );
424 lines.push(Line::from(vec![
425 Span::styled("spot ", Style::default().fg(Color::DarkGray)),
426 Span::styled(rail, Style::default().fg(Color::Blue)),
427 Span::raw(" B=BE S=short ●=spot"),
428 ]));
429 }
430
431 lines
432}
433
434pub fn spread_monitor_lines(
435 rules: &RulesConfig,
436 state: &AgentState,
437 live: Option<&SpreadLiveSnapshot>,
438) -> Vec<Line<'static>> {
439 if state.open_positions.is_empty() {
440 return vec![Line::from(Span::styled(
441 "(flat — no open positions)",
442 Style::default().fg(Color::DarkGray),
443 ))];
444 }
445
446 let mut positions: Vec<_> = state.open_positions.values().collect();
447 positions.sort_by(|a, b| a.underlying.cmp(&b.underlying));
448
449 let mut lines = Vec::new();
450 for (i, pos) in positions.iter().enumerate() {
451 if i > 0 {
452 lines.push(Line::from(""));
453 }
454 let live_mark = live.and_then(|l| l.marks.get(&pos.position_id));
455 let m = build_spread_monitor(pos, live_mark, &rules.exit_rules);
456
457 let type_label = m
458 .analytics
459 .as_ref()
460 .map(spread_type_label)
461 .unwrap_or(m.strategy.as_str());
462
463 lines.push(Line::from(vec![
464 Span::styled(
465 format!("{} ", m.underlying),
466 Style::default()
467 .fg(Color::Cyan)
468 .add_modifier(Modifier::BOLD),
469 ),
470 Span::raw(format!(
471 "×{} {type_label} {}d DTE",
472 m.contracts, m.dte
473 )),
474 ]));
475
476 lines.push(health_banner_line(&m));
477
478 let age = m
479 .mark_age_secs
480 .map(|s| format!(" mark {s}s ago"))
481 .unwrap_or_else(|| " (no live mark)".into());
482
483 lines.push(Line::from(vec![
484 Span::styled(
485 format!("{:+.1}% ${:+.2}", m.profit_pct, m.pnl_usd),
486 pnl_style(m.profit_pct),
487 ),
488 Span::raw(format!(
489 " debit ${:.2} cr ${:.2} tgt ≤${:.2}{age}",
490 m.debit_to_close, m.entry_credit, m.target_debit
491 )),
492 ]));
493
494 lines.extend(analytics_lines(&m));
495
496 lines.push(Line::from(vec![
497 Span::styled("stop ", Style::default().fg(Color::Red)),
498 Span::raw(format!("${:.2}", m.stop_debit)),
499 Span::styled(" entry ", Style::default().fg(Color::DarkGray)),
500 Span::raw(format!("${:.2}", m.entry_credit)),
501 Span::styled(" target ", Style::default().fg(Color::Green)),
502 Span::raw(format!("${:.2}", m.target_debit)),
503 ]));
504
505 let rail = spread_exit_rail(
506 m.stop_debit,
507 m.entry_credit,
508 m.target_debit,
509 m.debit_to_close,
510 28,
511 );
512 let rail_style = if m.profit_pct >= 0.0 {
513 Style::default().fg(Color::Green)
514 } else {
515 Style::default().fg(Color::Yellow)
516 };
517 lines.push(Line::from(vec![
518 Span::styled("P/L ", Style::default().fg(Color::DarkGray)),
519 Span::styled(rail, rail_style),
520 Span::raw(format!(
521 " {:.0}%→target {:.0}% from stop",
522 m.pct_toward_target, m.pct_cushion_from_stop
523 )),
524 ]));
525
526 let mut footer_spans = vec![
527 Span::styled(
528 format!("close ≤{} DTE", m.dte_close),
529 Style::default().fg(Color::DarkGray),
530 ),
531 ];
532 if let Some(reason) = &m.imminent_exit {
533 footer_spans.push(Span::raw(" │ "));
534 footer_spans.push(Span::styled(
535 format!("EXIT: {reason}"),
536 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
537 ));
538 } else if m.mark_source != "chain" && m.mark_source != "portfolio" {
539 footer_spans.push(Span::raw(format!(" │ mark: {}", m.mark_source)));
540 }
541 lines.push(Line::from(footer_spans));
542 }
543
544 if let Some(live) = live {
545 if let Some(at) = live.last_fetch {
546 let ago = (Utc::now() - at).num_seconds().max(0);
547 lines.push(Line::from(""));
548 lines.push(Line::from(Span::styled(
549 format!("chain refresh {ago}s ago"),
550 Style::default().fg(Color::DarkGray),
551 )));
552 }
553 }
554
555 if let Some(err) = live.and_then(|l| l.last_error.as_ref()) {
556 lines.push(Line::from(vec![
557 Span::styled("mark feed: ", Style::default().fg(Color::Red)),
558 Span::raw(err.clone()),
559 ]));
560 }
561
562 lines
563}
564
565pub fn attach_exit_hint(mark: &mut SpreadPositionMark, rules: &RulesConfig, entry_credit: f64) {
566 if entry_credit <= f64::EPSILON {
567 return;
568 }
569 if let Some(eval) = evaluate_exit_from_mark(rules, Some(entry_credit), &mark.mark) {
570 mark.imminent_exit = Some(eval.reason);
571 } else if mark.mark.dte <= rules.exit_rules.dte_close as i64 {
572 mark.imminent_exit = Some("dte_close".into());
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::agent::spread_analytics::compute_vertical_analytics;
580 use crate::agent::spread_analytics::VerticalAnalyticsInput;
581 use crate::rules::ExitRules;
582
583 #[test]
584 fn spread_rail_places_markers() {
585 let rail = spread_exit_rail(0.58, 0.29, 0.145, 0.20, 20);
586 assert!(rail.contains('│'));
587 assert!(rail.contains('●'));
588 }
589
590 #[test]
591 fn profit_pct_maps_to_target_progress() {
592 let exit_rules = ExitRules::default();
593 let tracked = TrackedPosition {
594 position_id: "SPY|2026-07-18".into(),
595 account_hash: "h".into(),
596 underlying: "SPY".into(),
597 expiry: "2026-07-18".into(),
598 strategy: "vertical".into(),
599 opened_at: Utc::now(),
600 entry_credit: Some(0.40),
601 max_loss_usd: 200.0,
602 contracts: 2,
603 entry_params: None,
604 ..Default::default()
605 };
606 let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
607 is_put_spread: true,
608 underlying_price: 520.0,
609 short_strike: 500.0,
610 long_strike: 498.0,
611 credit: 0.40,
612 dte: 25,
613 chain_iv_pct: Some(18.0),
614 short_delta: Some(-0.20),
615 long_delta: Some(-0.12),
616 short_theta: Some(-0.10),
617 long_theta: Some(-0.06),
618 contracts: 2,
619 underlying_change_pct: Some(-0.3),
620 });
621 let live = SpreadPositionMark {
622 mark: SpreadMark {
623 entry_credit: 0.40,
624 debit_to_close: 0.20,
625 profit_pct: 50.0,
626 dte: 25,
627 source: "test".into(),
628 },
629 analytics: Some(analytics),
630 imminent_exit: Some("profit_target".into()),
631 mark_age_secs: Some(5),
632 };
633 let m = build_spread_monitor(&tracked, Some(&live), &exit_rules);
634 assert!((m.pct_toward_target - 100.0).abs() < 0.1);
635 assert!((m.pnl_usd - 40.0).abs() < 0.01);
636 assert!(m.analytics.is_some());
637 }
638
639 #[test]
640 fn winning_position_gets_winning_health() {
641 let exit_rules = ExitRules::default();
642 let tracked = TrackedPosition {
643 position_id: "IWM|2026-07-31".into(),
644 account_hash: "h".into(),
645 underlying: "IWM".into(),
646 expiry: "2026-07-31".into(),
647 strategy: "vertical".into(),
648 opened_at: Utc::now(),
649 entry_credit: Some(0.32),
650 max_loss_usd: 136.0,
651 contracts: 2,
652 entry_params: None,
653 ..Default::default()
654 };
655 let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
656 is_put_spread: true,
657 underlying_price: 301.0,
658 short_strike: 282.0,
659 long_strike: 280.0,
660 credit: 0.32,
661 dte: 30,
662 chain_iv_pct: Some(29.0),
663 short_delta: Some(-0.16),
664 long_delta: Some(-0.14),
665 short_theta: Some(-0.06),
666 long_theta: Some(-0.04),
667 contracts: 2,
668 underlying_change_pct: Some(0.4),
669 });
670 let live = SpreadPositionMark {
671 mark: SpreadMark {
672 entry_credit: 0.32,
673 debit_to_close: 0.27,
674 profit_pct: 14.7,
675 dte: 30,
676 source: "test".into(),
677 },
678 analytics: Some(analytics),
679 imminent_exit: None,
680 mark_age_secs: Some(1),
681 };
682 let m = build_spread_monitor(&tracked, Some(&live), &exit_rules);
683 let h = spread_health(&m);
684 assert!(matches!(h.label, "WINNING" | "STRONG WIN" | "WATCH"));
685 assert!(spread_win_score(m.profit_pct, m.analytics.as_ref().unwrap(), m.pct_cushion_from_stop)
686 > 60.0);
687 }
688}