Skip to main content

schwab_cli/ui/
spread_payoff.rs

1//! Expiry payoff (risk graph) for vertical credit spreads.
2
3use ratatui::layout::Rect;
4use ratatui::style::{Color, Modifier};
5use ratatui::symbols::Marker;
6use ratatui::widgets::canvas::{Canvas, Line};
7use ratatui::widgets::{Block, Paragraph};
8use ratatui::Frame;
9
10use super::chart_markers::{draw_chart_marker, marker_spans};
11use super::spread_live::SpreadMonitorView;
12use super::theme::{self, label_style};
13
14/// P/L in dollars at expiry for one vertical credit spread.
15pub fn vertical_credit_payoff_usd(
16    spot: f64,
17    is_put_spread: bool,
18    short_strike: f64,
19    long_strike: f64,
20    credit: f64,
21    contracts: u32,
22) -> f64 {
23    let intrinsic = if is_put_spread {
24        (short_strike - spot).max(0.0) - (long_strike - spot).max(0.0)
25    } else {
26        (spot - short_strike).max(0.0) - (spot - long_strike).max(0.0)
27    };
28    (credit - intrinsic) * 100.0 * contracts.max(1) as f64
29}
30
31#[derive(Debug, Clone)]
32pub struct PayoffBounds {
33    pub x_min: f64,
34    pub x_max: f64,
35    pub y_min: f64,
36    pub y_max: f64,
37    pub max_profit: f64,
38    pub max_loss: f64,
39}
40
41pub fn payoff_bounds(m: &SpreadMonitorView) -> Option<PayoffBounds> {
42    let a = m.analytics.as_ref()?;
43    let width = a.width.max(0.01);
44    let credit = m.entry_credit.max(0.0);
45    let contracts = m.contracts.max(1);
46    let max_profit = credit * 100.0 * contracts as f64;
47    let max_loss = a
48        .max_loss_per_spread_usd
49        .map(|l| l * contracts as f64)
50        .unwrap_or((width - credit) * 100.0 * contracts as f64);
51
52    let pad = (a.underlying_price * 0.04).max(width * 1.5);
53    let (lo_strike, hi_strike) = if a.is_put_spread {
54        (a.long_strike, a.short_strike)
55    } else {
56        (a.short_strike, a.long_strike)
57    };
58    let x_min = lo_strike.min(a.underlying_price) - pad;
59    let x_max = hi_strike.max(a.underlying_price) + pad;
60    let expiry_at_spot = vertical_credit_payoff_usd(
61        a.underlying_price,
62        a.is_put_spread,
63        a.short_strike,
64        a.long_strike,
65        credit,
66        contracts,
67    );
68    let y_lo = expiry_at_spot.min(-max_loss).min(m.pnl_usd);
69    let y_hi = expiry_at_spot.max(max_profit).max(m.pnl_usd);
70    let y_pad = y_hi.abs().max(y_lo.abs()) * 0.15 + 1.0;
71    Some(PayoffBounds {
72        x_min,
73        x_max,
74        y_min: y_lo - y_pad,
75        y_max: y_hi + y_pad,
76        max_profit,
77        max_loss,
78    })
79}
80
81pub fn render_payoff_chart(f: &mut Frame, area: Rect, m: &SpreadMonitorView) {
82    let Some(a) = m.analytics.as_ref() else {
83        f.render_widget(
84            Paragraph::new("payoff chart\n(waiting for chain)")
85                .style(label_style())
86                .block(
87                    Block::default()
88                        .title(" Payoff @ expiry ")
89                        .title_style(label_style().add_modifier(Modifier::ITALIC)),
90                ),
91            area,
92        );
93        return;
94    };
95    let Some(bounds) = payoff_bounds(m) else {
96        return;
97    };
98
99    let is_put = a.is_put_spread;
100    let short = a.short_strike;
101    let long = a.long_strike;
102    let credit = m.entry_credit;
103    let contracts = m.contracts;
104    let spot = a.underlying_price;
105
106    let sample_count = 48usize;
107    let x_step = (bounds.x_max - bounds.x_min) / sample_count as f64;
108    let mut payoff_coords: Vec<(f64, f64)> = Vec::with_capacity(sample_count + 1);
109    let mut x = bounds.x_min;
110    for _ in 0..=sample_count {
111        let y = vertical_credit_payoff_usd(x, is_put, short, long, credit, contracts);
112        payoff_coords.push((x, y));
113        x += x_step;
114    }
115    let spot_y =
116        vertical_credit_payoff_usd(spot, is_put, short, long, credit, contracts);
117    let now_y = m.pnl_usd;
118    let show_expiry_ref = (now_y - spot_y).abs() > 1.0;
119
120    let x_min = bounds.x_min;
121    let x_max = bounds.x_max;
122    let y_min = bounds.y_min;
123    let y_max = bounds.y_max;
124    let (hx, hy) = marker_spans(x_min, x_max, y_min, y_max);
125
126    let chart_title = format!(
127        " +${:.0}/-${:.0}  spot ${:.0}  @expiry ${:+.0}  mtm ${:+.0}",
128        bounds.max_profit, bounds.max_loss, spot, spot_y, now_y
129    );
130    let legend = if show_expiry_ref {
131        " ● path@expiry   ○ mtm if closed now "
132    } else {
133        " ● path@expiry "
134    };
135
136    let canvas = Canvas::default()
137        .block(
138            Block::default()
139                .title(format!(" Payoff @ expiry{chart_title} "))
140                .title_bottom(legend)
141                .title_style(label_style().add_modifier(Modifier::ITALIC)),
142        )
143        .marker(Marker::Braille)
144        .x_bounds([x_min, x_max])
145        .y_bounds([y_min, y_max])
146        .paint(move |ctx| {
147            ctx.draw(&Line::new(x_min, 0.0, x_max, 0.0, Color::DarkGray));
148
149            for window in payoff_coords.windows(2) {
150                let (x1, y1) = window[0];
151                let (x2, y2) = window[1];
152                let color = if y1 >= 0.0 && y2 >= 0.0 {
153                    theme::PROFIT
154                } else if y1 <= 0.0 && y2 <= 0.0 {
155                    theme::LOSS
156                } else {
157                    theme::WARN
158                };
159                ctx.draw(&Line::new(x1, y1, x2, y2, color));
160            }
161
162            // Spot price — dim full-height guide.
163            ctx.draw(&Line::new(spot, y_min, spot, y_max, Color::Rgb(50, 110, 130)));
164
165            if show_expiry_ref {
166                // MTM if closed now — secondary (hollow).
167                draw_chart_marker(
168                    ctx,
169                    spot,
170                    now_y,
171                    hx * 0.55,
172                    hy * 0.55,
173                    Color::Gray,
174                    "○",
175                );
176                ctx.draw(&Line::new(
177                    spot,
178                    now_y,
179                    spot,
180                    spot_y,
181                    Color::Magenta,
182                ));
183            }
184
185            // Path if expired at today's spot — primary focal point.
186            draw_chart_marker(
187                ctx,
188                spot,
189                spot_y,
190                hx,
191                hy,
192                Color::LightYellow,
193                "●",
194            );
195        });
196
197    f.render_widget(canvas, area);
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::agent::exits::SpreadMark;
204    use crate::agent::spread_analytics::{compute_vertical_analytics, VerticalAnalyticsInput};
205    use crate::agent::state::TrackedPosition;
206    use crate::rules::ExitRules;
207    use crate::ui::spread_live::{build_spread_monitor, SpreadPositionMark};
208    use chrono::Utc;
209
210    fn sample_monitor() -> SpreadMonitorView {
211        let exit_rules = ExitRules::default();
212        let tracked = TrackedPosition {
213            position_id: "IWM|2026-08-14".into(),
214            account_hash: "h".into(),
215            underlying: "IWM".into(),
216            expiry: "2026-08-14".into(),
217            strategy: "vertical".into(),
218            opened_at: Utc::now(),
219            entry_credit: Some(0.28),
220            max_loss_usd: 172.0,
221            contracts: 1,
222            entry_params: None,
223            ..Default::default()
224        };
225        let analytics = compute_vertical_analytics(VerticalAnalyticsInput {
226            is_put_spread: true,
227            underlying_price: 294.81,
228            short_strike: 283.0,
229            long_strike: 281.0,
230            credit: 0.28,
231            dte: 35,
232            chain_iv_pct: Some(29.0),
233            realized_vol_pct: None,
234            short_delta: Some(-0.26),
235            long_delta: Some(-0.23),
236            short_theta: Some(-0.15),
237            long_theta: Some(-0.12),
238            contracts: 1,
239            underlying_change_pct: Some(-0.8),
240        });
241        let live = SpreadPositionMark {
242            mark: SpreadMark {
243                entry_credit: 0.28,
244                debit_to_close: 0.46,
245                profit_pct: -64.3,
246                dte: 35,
247                source: "test".into(),
248            },
249            analytics: Some(analytics),
250            imminent_exit: None,
251            mark_age_secs: Some(0),
252        };
253        build_spread_monitor(&tracked, Some(&live), &exit_rules)
254    }
255
256    #[test]
257    fn put_spread_payoff_plateau_and_max_loss() {
258        let p_win = vertical_credit_payoff_usd(295.0, true, 283.0, 281.0, 0.28, 1);
259        assert!((p_win - 28.0).abs() < 0.01);
260
261        let p_max_loss = vertical_credit_payoff_usd(270.0, true, 283.0, 281.0, 0.28, 1);
262        assert!((p_max_loss + 172.0).abs() < 0.01);
263
264        let p_mid = vertical_credit_payoff_usd(282.0, true, 283.0, 281.0, 0.28, 1);
265        assert!(p_mid < 0.0 && p_mid > -172.0);
266    }
267
268    #[test]
269    fn payoff_bounds_available_with_analytics() {
270        let m = sample_monitor();
271        assert!(payoff_bounds(&m).is_some());
272    }
273
274    #[test]
275    fn mark_pnl_can_differ_from_expiry_at_spot() {
276        let m = sample_monitor();
277        let a = m.analytics.as_ref().unwrap();
278        let expiry_at_spot = vertical_credit_payoff_usd(
279            a.underlying_price,
280            a.is_put_spread,
281            a.short_strike,
282            a.long_strike,
283            m.entry_credit,
284            m.contracts,
285        );
286        assert!(expiry_at_spot > 0.0);
287        assert!(m.pnl_usd < 0.0);
288        let bounds = payoff_bounds(&m).unwrap();
289        assert!(bounds.y_min < m.pnl_usd);
290        assert!(bounds.y_max > expiry_at_spot);
291    }
292}