use optionstratlib::model::Position;
use optionstratlib::prelude::{Decimal, Positive};
use optionstratlib::visualization::GraphData;
use optionstratlib::{ExpirationDate, OptionType, Options, Side as OptionSide};
use super::payoff_build::{break_even_points, empty_series, expiration_series};
use crate::chain::{DEFAULT_DIVIDEND_YIELD, DEFAULT_RISK_FREE_RATE};
use crate::replay::{PositionRow, PositionSide, parse_contract_id};
const GRID_POINTS: usize = 121;
const GRID_MARGIN_TENTHS: i64 = 3;
const REPLAY_SERIES_NAME: &str = "payoff @ expiration";
const NANOS_PER_DAY: i64 = 86_400_000_000_000;
const IV_PLACEHOLDER: Positive = Positive::ONE;
const REPLAY_CURVE: &str = "replay payoff at head";
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ReplayCurve {
Priced {
graph: GraphData,
break_evens: Vec<Positive>,
},
ComputeFailed,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ReplayPayoffGeometry {
pub(crate) curve: ReplayCurve,
pub(crate) mark_pnl_cents: Option<i64>,
pub(crate) open_legs: usize,
}
impl ReplayPayoffGeometry {
#[must_use]
fn flat() -> Self {
Self {
curve: ReplayCurve::Priced {
graph: empty_series(),
break_evens: Vec::new(),
},
mark_pnl_cents: None,
open_legs: 0,
}
}
}
#[must_use]
pub(crate) fn build(open: &[&PositionRow], head_ts_ns: Option<i64>) -> ReplayPayoffGeometry {
build_with(open, head_ts_ns, curve_at_head)
}
#[must_use]
fn build_with(
open: &[&PositionRow],
head_ts_ns: Option<i64>,
curve: impl FnOnce(&[&PositionRow], Option<i64>) -> ReplayCurve,
) -> ReplayPayoffGeometry {
if open.is_empty() {
return ReplayPayoffGeometry::flat();
}
let open_legs = open.len();
let mark_pnl_cents = net_unrealized_cents(open);
let curve = crate::terminal::contained(|| curve(open, head_ts_ns)).unwrap_or_else(|| {
super::payoff_build::warn_curve_compute_failed(REPLAY_CURVE);
ReplayCurve::ComputeFailed
});
ReplayPayoffGeometry {
curve,
mark_pnl_cents,
open_legs,
}
}
#[must_use]
fn curve_at_head(open: &[&PositionRow], head_ts_ns: Option<i64>) -> ReplayCurve {
let positions = build_positions(open, head_ts_ns);
let Some(grid) = price_grid(&positions) else {
return ReplayCurve::Priced {
graph: empty_series(),
break_evens: Vec::new(),
};
};
let graph = per_contract_expiration_series(&positions, &grid);
let break_evens = break_even_points(&graph);
ReplayCurve::Priced { graph, break_evens }
}
#[must_use]
fn per_contract_expiration_series(positions: &[Position], grid: &[Positive]) -> GraphData {
match expiration_series(positions, grid) {
GraphData::Series(mut series) => {
REPLAY_SERIES_NAME.clone_into(&mut series.name);
GraphData::Series(series)
}
other => other,
}
}
#[must_use]
fn net_unrealized_cents(open: &[&PositionRow]) -> Option<i64> {
let mut total: i64 = 0;
for row in open {
total = total.checked_add(row.unrealized_cents)?;
}
Some(total)
}
#[must_use]
fn build_positions(open: &[&PositionRow], head_ts_ns: Option<i64>) -> Vec<Position> {
let mut positions = Vec::with_capacity(open.len());
for row in open {
let Ok(parsed) = parse_contract_id(&row.contract_id) else {
continue;
};
let (Some(strike), Some(premium)) = (
positive_from_cents(parsed.strike_cents),
positive_from_cents(row.avg_price_cents),
) else {
continue;
};
let Ok(quantity) = Positive::new_decimal(Decimal::from(row.quantity)) else {
continue;
};
let side = match row.side {
PositionSide::Long => OptionSide::Long,
PositionSide::Short => OptionSide::Short,
};
let dte = head_dte(parsed.expiration_ns, head_ts_ns);
let option = Options::new(
OptionType::European,
side,
parsed.underlying.clone(),
strike,
ExpirationDate::Days(dte),
IV_PLACEHOLDER,
quantity,
strike,
DEFAULT_RISK_FREE_RATE,
parsed.style,
DEFAULT_DIVIDEND_YIELD,
None,
);
positions.push(Position::new(
option,
premium,
chrono::DateTime::from_timestamp_nanos(head_ts_ns.unwrap_or(0)),
Positive::ZERO,
Positive::ZERO,
None,
None,
));
}
positions
}
#[must_use]
fn head_dte(expiration_ns: i64, head_ts_ns: Option<i64>) -> Positive {
let span = head_ts_ns
.and_then(|head| expiration_ns.checked_sub(head))
.filter(|ns| *ns > 0)
.and_then(|ns| Decimal::from(ns).checked_div(Decimal::from(NANOS_PER_DAY)))
.and_then(|days| Positive::new_decimal(days).ok());
span.unwrap_or(Positive::ONE)
}
#[must_use]
fn positive_from_cents(cents: u64) -> Option<Positive> {
let mantissa = i64::try_from(cents).ok()?;
Positive::new_decimal(Decimal::new(mantissa, 2)).ok()
}
#[must_use]
fn price_grid(positions: &[Position]) -> Option<Vec<Positive>> {
let mut bounds: Option<(Decimal, Decimal)> = None;
for position in positions {
let strike = position.option.strike_price.to_dec();
bounds = Some(match bounds {
Some((lo, hi)) => (lo.min(strike), hi.max(strike)),
None => (strike, strike),
});
}
let (lo, hi) = bounds?;
let margin = Decimal::new(GRID_MARGIN_TENTHS, 1);
let lo = lo.checked_mul(Decimal::ONE.checked_sub(margin)?)?;
let hi = hi.checked_mul(Decimal::ONE.checked_add(margin)?)?;
if hi <= lo {
return None;
}
let span = hi.checked_sub(lo)?;
let last = GRID_POINTS.checked_sub(1)?;
let divisor = Decimal::from(u32::try_from(last).ok()?);
let mut grid = Vec::with_capacity(GRID_POINTS + positions.len());
for i in 0..GRID_POINTS {
let numerator = Decimal::from(u32::try_from(i).ok()?);
let offset = span.checked_mul(numerator)?.checked_div(divisor)?;
let x = lo.checked_add(offset)?;
if let Ok(point) = Positive::new_decimal(x) {
grid.push(point);
}
}
for position in positions {
if position.option.strike_price != Positive::MAX {
grid.push(position.option.strike_price);
}
}
grid.sort_by_key(|point| point.to_dec());
grid.dedup();
if grid.len() < 2 {
return None;
}
Some(grid)
}
#[cfg(test)]
mod tests {
use optionstratlib::prelude::Decimal;
use optionstratlib::visualization::GraphData;
use super::{
REPLAY_SERIES_NAME, ReplayCurve, ReplayPayoffGeometry, build, build_with,
net_unrealized_cents, positive_from_cents,
};
use crate::replay::{PositionRow, PositionSide};
use optionstratlib::prelude::Positive;
const HEAD_TS: i64 = 1_700_000_000_000_000_000;
const EXP_NS: i64 = 1_735_286_400_000_000_000;
fn row(
style: char,
strike_cents: u64,
side: PositionSide,
quantity: u32,
avg_price_cents: u64,
mark_cents: u64,
unrealized_cents: i64,
) -> PositionRow {
PositionRow {
step: 0,
ts_ns: HEAD_TS,
position_id: 1,
trade_id: 7,
contract_id: format!("v1:BTC:{EXP_NS}:{strike_cents}:{style}"),
side,
quantity,
avg_price_cents,
mark_cents,
unrealized_cents,
stale_mark: false,
exit_reason: None,
open_at_end: false,
}
}
#[track_caller]
fn series_x_len(graph: &GraphData) -> usize {
match graph {
GraphData::Series(series) => {
assert_eq!(series.x.len(), series.y.len(), "x and y are paired");
series.x.len()
}
other => panic!("expected a single Series, got {other:?}"),
}
}
#[track_caller]
fn priced(geometry: &ReplayPayoffGeometry) -> (&GraphData, &[Positive]) {
match &geometry.curve {
ReplayCurve::Priced { graph, break_evens } => (graph, break_evens),
ReplayCurve::ComputeFailed => {
panic!("expected a priced curve, the pricing math panicked")
}
}
}
#[track_caller]
fn curve_x_len(geometry: &ReplayPayoffGeometry) -> usize {
series_x_len(priced(geometry).0)
}
#[track_caller]
fn curve_break_evens(geometry: &ReplayPayoffGeometry) -> &[Positive] {
priced(geometry).1
}
#[test]
fn test_positive_from_cents_is_exact_dollars() {
match positive_from_cents(12_345) {
Some(p) => assert_eq!(p.to_dec(), optionstratlib::prelude::Decimal::new(12_345, 2)),
None => panic!("12_345 cents must convert"),
}
assert!(
positive_from_cents(0).is_some(),
"zero cents is a valid $0.00"
);
}
#[test]
fn test_build_flat_when_no_open_positions() {
let geometry = build(&[], Some(HEAD_TS));
assert_eq!(geometry.open_legs, 0, "no legs at the head");
assert_eq!(geometry.mark_pnl_cents, None, "flat has no mark P&L");
assert!(curve_break_evens(&geometry).is_empty());
assert_eq!(curve_x_len(&geometry), 0, "the flat series is empty");
}
#[test]
fn test_build_long_call_has_expiration_curve_and_break_even() {
let legs = [row(
'C',
6_000_000,
PositionSide::Long,
1,
12_500,
11_800,
-700,
)];
let open: Vec<&PositionRow> = legs.iter().collect();
let geometry = build(&open, Some(HEAD_TS));
assert_eq!(geometry.open_legs, 1);
assert!(
curve_x_len(&geometry) >= 2,
"a priced leg yields a sampled curve",
);
assert_eq!(
curve_break_evens(&geometry).len(),
1,
"a long call has one break-even (strike + premium)",
);
for be in curve_break_evens(&geometry) {
let value = be.to_f64();
assert!(
(60_000.0..=61_000.0).contains(&value),
"the break-even sits just above the strike: {value}",
);
}
}
#[test]
fn test_net_unrealized_cents_sums_the_writer_field() {
let long = row('C', 6_000_000, PositionSide::Long, 1, 12_500, 11_800, -700);
assert_eq!(net_unrealized_cents(&[&long]), Some(-700));
let short = row('C', 6_000_000, PositionSide::Short, 1, 12_500, 11_800, 200);
assert_eq!(net_unrealized_cents(&[&short]), Some(200));
assert_eq!(net_unrealized_cents(&[&long, &short]), Some(-500));
}
#[test]
fn test_net_unrealized_cents_is_not_the_multiplier_dropping_recompute() {
let recompute_cents = 11_800_i64 - 12_500; let writer_unrealized = recompute_cents * 100; let leg = row(
'C',
6_000_000,
PositionSide::Long,
1,
12_500,
11_800,
writer_unrealized,
);
assert_eq!(
net_unrealized_cents(&[&leg]),
Some(-70_000),
"the MTM is the writer's multiplier-applied unrealized",
);
assert_ne!(
net_unrealized_cents(&[&leg]),
Some(recompute_cents),
"never the 100x-too-small recompute",
);
}
#[test]
fn test_build_vertical_spread_prices_both_legs() {
let long = row('C', 6_000_000, PositionSide::Long, 1, 12_500, 11_800, -700);
let short = row('C', 6_200_000, PositionSide::Short, 1, 5_000, 4_800, 200);
let open = vec![&long, &short];
let geometry = build(&open, Some(HEAD_TS));
assert_eq!(geometry.open_legs, 2);
assert!(curve_x_len(&geometry) >= 2, "both legs priced");
assert_eq!(geometry.mark_pnl_cents, Some(-500));
}
#[test]
fn test_price_grid_is_exact_decimal_not_f64() {
let legs = [row(
'C',
6_000_000,
PositionSide::Long,
1,
12_500,
11_800,
-700,
)];
let open: Vec<&PositionRow> = legs.iter().collect();
let geometry = build(&open, Some(HEAD_TS));
match priced(&geometry).0 {
GraphData::Series(series) => assert_eq!(
series.x.first().copied(),
Some(Decimal::from(42_000)),
"the grid lo is exact Decimal 0.7·60000, no f64 round-trip",
),
other => panic!("expected a series, got {other:?}"),
}
}
#[test]
fn test_replay_series_is_labelled_per_contract() {
let legs = [row(
'C',
6_000_000,
PositionSide::Long,
1,
12_500,
11_800,
-700,
)];
let open: Vec<&PositionRow> = legs.iter().collect();
let geometry = build(&open, Some(HEAD_TS));
match priced(&geometry).0 {
GraphData::Series(series) => assert_eq!(series.name, REPLAY_SERIES_NAME),
other => panic!("expected a series, got {other:?}"),
}
}
#[test]
fn test_build_is_deterministic() {
let leg = row('P', 5_800_000, PositionSide::Short, 1, 9_000, 8_500, 500);
let open = vec![&leg];
assert_eq!(
build(&open, Some(HEAD_TS)),
build(&open, Some(HEAD_TS)),
"identical inputs yield an identical geometry (no clock, no RNG)",
);
}
#[test]
fn test_build_skips_unparseable_contract_id_without_panic() {
let mut bad = row('C', 6_000_000, PositionSide::Long, 1, 12_500, 11_800, -700);
bad.contract_id = "not-a-valid-id".to_owned();
let open = vec![&bad];
let geometry = build(&open, Some(HEAD_TS));
assert_eq!(geometry.open_legs, 1);
assert_eq!(curve_x_len(&geometry), 0);
}
#[test]
fn test_build_with_contains_a_panicking_curve_build() {
let leg = row('C', 6_000_000, PositionSide::Long, 1, 12_500, 11_800, -700);
let open = vec![&leg];
let geometry = build_with(&open, Some(HEAD_TS), |_, _| panic!("upstream pricing math"));
assert_eq!(
geometry.curve,
ReplayCurve::ComputeFailed,
"a panicking curve build is contained, not unwound",
);
assert_eq!(
geometry.open_legs, 1,
"the open-leg count survives the panic"
);
assert_eq!(
geometry.mark_pnl_cents,
Some(-700),
"the writer's mark-to-market survives the panic",
);
}
#[test]
fn test_build_keeps_unpriceable_distinct_from_compute_failed() {
let mut bad = row('C', 6_000_000, PositionSide::Long, 1, 12_500, 11_800, -700);
bad.contract_id = "not-a-valid-id".to_owned();
let open = vec![&bad];
let geometry = build(&open, Some(HEAD_TS));
assert_eq!(curve_x_len(&geometry), 0, "an honest empty series");
assert_ne!(
geometry.curve,
ReplayCurve::ComputeFailed,
"nothing panicked; the leg simply could not be priced",
);
}
#[test]
fn test_style_recovered_from_join_key() {
let put = row('P', 6_000_000, PositionSide::Long, 1, 12_500, 11_800, -700);
let open = vec![&put];
let geometry = build(&open, Some(HEAD_TS));
assert_eq!(geometry.open_legs, 1);
assert_eq!(curve_break_evens(&geometry).len(), 1);
for be in curve_break_evens(&geometry) {
assert!(
(59_000.0..=60_000.0).contains(&be.to_f64()),
"the put break-even sits just below the strike: {}",
be.to_f64(),
);
}
}
}