Skip to main content

schwab_cli/ui/
spread_live.rs

1//! Live spread marks + exit-proximity views for the options watch TUI.
2
3use 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
120/// Credit spread rail: stop debit (left, high) → target debit (right, low).
121pub 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}