use std::rc::Rc; use gpui::{ App, Div, ElementId, IntoElement, RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::*, }; use super::quotes::price; use crate::{ charts::{Geometry, Ink, Layout, Line, Linear, Pick, Plot, Scene, Tips, nice, nice_step, plot}, theme::ActiveTheme, typography::format, }; /// A leg of an options position: a call or a put at a strike, how many (negative when sold), and the premium paid or taken for each. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Leg { pub call: bool, pub strike: f64, pub quantity: f64, pub premium: f64, } /// What a position pays at expiry if the underlying ends at `at`. pub(crate) fn payoff(legs: &[Leg], at: f64) -> f64 { legs.iter() .map(|leg| { let worth = if leg.call { (at - leg.strike).max(0.0) } else { (leg.strike - at).max(0.0) }; leg.quantity * (worth - leg.premium) }) .sum() } /// Where a payoff sampled at `prices` crosses zero, read between samples. pub(crate) fn breakevens(prices: &[f64], values: &[f64]) -> Vec { prices .windows(2) .zip(values.windows(2)) .filter(|(_, pair)| pair[0].signum() != pair[1].signum() && pair[0] != pair[1]) .map(|(at, pair)| at[0] + (at[1] - at[0]) * pair[0] / (pair[0] - pair[1])) .collect() } /// What an options position pays at expiry across prices of the underlying: profit shaded above zero, loss below, the break-evens and the price marked. Hover reads the result at any price. #[derive(IntoElement)] pub struct PayoffDiagram { base: Div, id: ElementId, legs: Vec, range: (f64, f64), spot: f64, red_up: bool, } impl PayoffDiagram { /// Prices from `range.0` to `range.1`, widened to round prices, and the underlying's price now. pub fn new( id: impl Into, legs: impl IntoIterator, range: (f64, f64), spot: f64, ) -> Self { assert!( range.0 < range.1 && range.0.is_finite() && range.1.is_finite(), "a range runs low to high" ); Self { base: div(), id: id.into(), legs: legs.into_iter().collect(), range, spot, red_up: false, } } pub fn red_up(mut self) -> Self { self.red_up = true; self } } /// Samples between two round prices on the axis. const PER_STEP: usize = 20; impl Styled for PayoffDiagram { fn style(&mut self) -> &mut StyleRefinement { self.base.style() } } impl RenderOnce for PayoffDiagram { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let ((start, end), ticks) = nice(self.range.0, self.range.1, 6); let step = nice_step(end - start, 6); let samples = ticks.len().saturating_sub(1).max(1) * PER_STEP + 1; let prices: Vec = (0..samples) .map(|ix| start + (end - start) * ix as f64 / (samples - 1) as f64) .collect(); let values: Rc> = Rc::new(prices.iter().map(|at| payoff(&self.legs, *at)).collect()); let labels: Vec = prices.iter().map(|at| price(*at, 2).into()).collect(); let places = (-step.log10().floor()).max(0.0) as usize; let words: Vec = prices .iter() .enumerate() .map(|(ix, at)| { if ix % PER_STEP == 0 { price(*at, places).into() } else { SharedString::default() } }) .collect(); let (gain, loss) = if self.red_up { (Ink::Fall, Ink::Rise) } else { (Ink::Rise, Ink::Fall) }; let drawn = values.clone(); let layout: Layout = Rc::new(move |scene: &Scene| { let frame = scene.frame; let (low, high) = drawn.iter().fold((0.0f64, 0.0f64), |(low, high), value| { (low.min(*value), high.max(*value)) }); let ((low, high), ticks) = nice(low, high, 5); let ys = Linear::new((low, high), (frame.y + frame.h, frame.y)); let x = |ix: usize| frame.x + frame.w * ix as f32 / (drawn.len() - 1) as f32; let zero = ys.at(0.0); let side = |pick: fn(f64) -> f64| { let mut outline: Vec<(f32, f32)> = drawn .iter() .enumerate() .map(|(ix, value)| (x(ix), ys.at(pick(*value)))) .collect(); outline.extend([(x(drawn.len() - 1), zero), (x(0), zero)]); outline }; Geometry { ticks: ticks.iter().map(|tick| (ys.at(*tick), *tick)).collect(), labels: words .iter() .enumerate() .map(|(ix, word)| (x(ix), word.clone())) .collect(), lines: vec![Line { ink: Ink::Strong, points: drawn .iter() .enumerate() .map(|(ix, value)| (x(ix), ys.at(*value))) .collect(), floor: None, }], shapes: vec![ (gain, side(|value| value.max(0.0))), (loss, side(|value| value.min(0.0))), ], ..Geometry::default() } }); let (named, read) = (labels.clone(), values.clone()); let tips: Tips = Rc::new(move |ix, _| { let value = read[ix]; let sign = if value > 0.0 { "+" } else { "" }; let ink = if value >= 0.0 { gain } else { loss }; ( named[ix].clone(), vec![( Some(ink), "At expiry".into(), format!("{sign}{}", format::currency(value, "USD")), )], ) }); let mut chart = Plot::new(self.id.clone(), Pick::Point, layout, tips); let evens = breakevens(&prices, &values); let nearest = |at: f64| { (((at - start) / (end - start)).clamp(0.0, 1.0) * (samples - 1) as f64).round() as usize }; chart.notes = evens .iter() .map(|at| { ( nearest(*at), SharedString::from(format!("Break-even {}", price(*at, 2))), ) }) .collect(); if (start..=end).contains(&self.spot) { chart.notes.push((nearest(self.spot), "Now".into())); } chart.labels = labels; chart.format = Rc::new(|value| format::currency(value, "USD")); let mut base = div() .debug_selector(|| "chart-root".into()) .h(cx.theme().chart().height); base.style().refine(self.base.style()); plot(chart, base, window, cx) } } #[cfg(test)] mod tests { use super::*; #[test] fn a_spread_pays_between_its_strikes_and_breaks_even_where_it_crosses() { let spread = [ Leg { call: true, strike: 100.0, quantity: 1.0, premium: 5.0, }, Leg { call: true, strike: 110.0, quantity: -1.0, premium: 2.0, }, ]; assert_eq!( payoff(&spread, 90.0), -3.0, "both expire worthless: the net premium is lost" ); assert_eq!( payoff(&spread, 120.0), 7.0, "the most it pays: the width less the premium" ); let prices: Vec = (90..=120).map(f64::from).collect(); let values: Vec = prices.iter().map(|at| payoff(&spread, *at)).collect(); let evens = breakevens(&prices, &values); assert_eq!(evens, [103.0]); } }