Files

243 lines
8.0 KiB
Rust

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<f64> {
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<Leg>,
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<ElementId>,
legs: impl IntoIterator<Item = Leg>,
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<f64> = (0..samples)
.map(|ix| start + (end - start) * ix as f64 / (samples - 1) as f64)
.collect();
let values: Rc<Vec<f64>> =
Rc::new(prices.iter().map(|at| payoff(&self.legs, *at)).collect());
let labels: Vec<SharedString> = prices.iter().map(|at| price(*at, 2).into()).collect();
let places = (-step.log10().floor()).max(0.0) as usize;
let words: Vec<SharedString> = 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<f64> = (90..=120).map(f64::from).collect();
let values: Vec<f64> = prices.iter().map(|at| payoff(&spread, *at)).collect();
let evens = breakevens(&prices, &values);
assert_eq!(evens, [103.0]);
}
}