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;
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
119/// Credit spread rail: stop debit (left, bad) → target debit (right, good).
120/// Matches the equity monitor: moving right is winning.
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 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    // Options path: far OTM + healthy POP/delta means the thesis is intact even if MTM is red.
176    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    // Path-first: do not brand a far-OTM credit as LOSING just because marks widened.
197    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    // Must pass live analytics so OTM-cushion stop rules match the agent.
290    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        // ~4.4% OTM — path healthy even with ugly marks.
504        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}