1use 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
14pub 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 ctx.draw(&Line::new(spot, y_min, spot, y_max, Color::Rgb(50, 110, 130)));
164
165 if show_expiry_ref {
166 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 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}