use chrono::{DateTime, Utc};
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::model::Position;
use optionstratlib::prelude::{Decimal, Positive};
use optionstratlib::visualization::{GraphData, Series2D, TraceMode};
use optionstratlib::{ExpirationDate, OptionType, Options, Side as OptionSide};
use super::{BuilderLeg, Side};
use crate::chain::{
ChainStore, DEFAULT_DIVIDEND_YIELD, DEFAULT_RISK_FREE_RATE, GreeksOrigin, InstrumentKey,
MIN_PLAUSIBLE_LOCAL_IV,
};
const GRID_POINTS: usize = 121;
const GRID_MARGIN: f64 = 0.3;
const SECONDS_PER_DAY: i64 = 86_400;
const ENTRY_IV_PLACEHOLDER: Positive = Positive::ONE;
#[derive(Debug)]
pub(crate) struct PayoffGeometry {
pub(crate) grid: Vec<Positive>,
pub(crate) entry_positions: Vec<Position>,
pub(crate) expiration: GraphData,
pub(crate) tplus0: GraphData,
pub(crate) break_evens: Vec<Positive>,
}
#[must_use]
pub(crate) fn empty_series() -> GraphData {
GraphData::Series(Series2D::default())
}
#[derive(Debug)]
pub(crate) enum GeometryBuild {
Priced(Box<PayoffGeometry>),
NotPriceable,
ComputeFailed,
}
#[derive(Debug)]
pub(crate) enum TPlus0Build {
Series(GraphData),
ComputeFailed,
}
const GEOMETRY_CURVE: &str = "live payoff geometry";
const TPLUS0_CURVE: &str = "live payoff t+0";
#[cold]
#[inline(never)]
pub(crate) fn warn_curve_compute_failed(curve: &'static str) {
tracing::warn!(
curve,
"upstream pricing math panicked; the payoff curve is unavailable"
);
}
#[must_use]
pub(crate) fn build_geometry(legs: &[BuilderLeg], store: &ChainStore) -> GeometryBuild {
build_geometry_with(legs, store, build_geometry_inner)
}
#[must_use]
fn build_geometry_with(
legs: &[BuilderLeg],
store: &ChainStore,
inner: impl FnOnce(&[BuilderLeg], &ChainStore) -> Option<PayoffGeometry>,
) -> GeometryBuild {
match crate::terminal::contained(|| inner(legs, store)) {
Some(Some(geometry)) => GeometryBuild::Priced(Box::new(geometry)),
Some(None) => GeometryBuild::NotPriceable,
None => {
warn_curve_compute_failed(GEOMETRY_CURVE);
GeometryBuild::ComputeFailed
}
}
}
#[must_use]
fn build_geometry_inner(legs: &[BuilderLeg], store: &ChainStore) -> Option<PayoffGeometry> {
let chain = store.chain();
let entry_positions = build_entry_positions(legs, store)?;
let grid = price_grid(legs, chain.underlying_price)?;
let expiration = expiration_series(&entry_positions, &grid);
let break_evens = break_even_points(&expiration);
let tplus0 = tplus0_curve(&entry_positions, legs, store, &grid);
Some(PayoffGeometry {
grid,
entry_positions,
expiration,
tplus0,
break_evens,
})
}
#[must_use]
pub(crate) fn rebuild_tplus0(
legs: &[BuilderLeg],
store: &ChainStore,
grid: &[Positive],
entry_positions: &[Position],
) -> TPlus0Build {
rebuild_tplus0_with(legs, store, grid, entry_positions, tplus0_curve)
}
#[must_use]
fn rebuild_tplus0_with(
legs: &[BuilderLeg],
store: &ChainStore,
grid: &[Positive],
entry_positions: &[Position],
inner: impl FnOnce(&[Position], &[BuilderLeg], &ChainStore, &[Positive]) -> GraphData,
) -> TPlus0Build {
match crate::terminal::contained(|| inner(entry_positions, legs, store, grid)) {
Some(series) => TPlus0Build::Series(series),
None => {
warn_curve_compute_failed(TPLUS0_CURVE);
TPlus0Build::ComputeFailed
}
}
}
#[must_use]
fn build_entry_positions(legs: &[BuilderLeg], store: &ChainStore) -> Option<Vec<Position>> {
let chain = store.chain();
let expiration_utc = absolute_expiry(chain)?;
let as_of = store.last_full_poll()?;
let dte = days_between(as_of, expiration_utc)?;
let mut positions = Vec::with_capacity(legs.len());
for leg in legs {
let premium = leg.mark_in(chain)?;
let quantity = Positive::new(f64::from(leg.qty)).ok()?;
let side = match leg.side {
Side::Buy => OptionSide::Long,
Side::Sell => OptionSide::Short,
};
let option = Options::new(
OptionType::European,
side,
chain.symbol.clone(),
leg.strike,
ExpirationDate::Days(dte),
ENTRY_IV_PLACEHOLDER,
quantity,
chain.underlying_price,
DEFAULT_RISK_FREE_RATE,
leg.style,
DEFAULT_DIVIDEND_YIELD,
None,
);
positions.push(Position::new(
option,
premium,
as_of,
Positive::ZERO,
Positive::ZERO,
None,
None,
));
}
Some(positions)
}
#[must_use]
fn tplus0_curve(
entry_positions: &[Position],
legs: &[BuilderLeg],
store: &ChainStore,
grid: &[Positive],
) -> GraphData {
match repriced_for_tplus0(entry_positions, legs, store) {
Some(priced) => tplus0_series(&priced, grid),
None => empty_series(),
}
}
#[must_use]
fn repriced_for_tplus0(
entry_positions: &[Position],
legs: &[BuilderLeg],
store: &ChainStore,
) -> Option<Vec<Position>> {
let ivs = resolve_leg_ivs(legs, store)?;
let expiration_utc = absolute_expiry(store.chain())?;
let expiration = ExpirationDate::Days(days_between(store.analytics_as_of(), expiration_utc)?);
let mut priced = Vec::with_capacity(entry_positions.len());
for (position, iv) in entry_positions.iter().zip(ivs.iter()) {
let mut position = position.clone();
position.option.implied_volatility = *iv;
position.option.expiration_date = expiration;
priced.push(position);
}
Some(priced)
}
#[must_use]
fn resolve_leg_ivs(legs: &[BuilderLeg], store: &ChainStore) -> Option<Vec<Positive>> {
let expiration_utc = absolute_expiry(store.chain())?;
let mut ivs = Vec::with_capacity(legs.len());
for leg in legs {
ivs.push(resolve_plausible_iv(store, leg, expiration_utc)?);
}
Some(ivs)
}
#[must_use]
fn absolute_expiry(chain: &OptionChain) -> Option<DateTime<Utc>> {
match chain.get_expiration()? {
ExpirationDate::DateTime(dt) => Some(dt),
ExpirationDate::Days(_) => None,
}
}
#[must_use]
fn resolve_plausible_iv(
store: &ChainStore,
leg: &BuilderLeg,
expiration_utc: DateTime<Utc>,
) -> Option<Positive> {
let chain = store.chain();
let key = InstrumentKey {
underlying: chain.symbol.clone(),
expiration_utc,
strike: leg.strike,
style: leg.style,
};
if let Some(sidecar) = store.leg_greeks(&key)
&& let Some(iv) = sidecar.iv
&& usable_iv(iv)
{
return plausible_leg_iv(iv, sidecar.iv_origin);
}
let od = chain
.options
.iter()
.find(|o| o.strike_price == leg.strike)?;
usable_iv(od.implied_volatility).then_some(od.implied_volatility)
}
#[must_use]
fn plausible_leg_iv(iv: Positive, origin: GreeksOrigin) -> Option<Positive> {
match origin {
GreeksOrigin::Provider => Some(iv),
GreeksOrigin::ComputedLocally => (iv.to_dec() >= MIN_PLAUSIBLE_LOCAL_IV).then_some(iv),
}
}
#[must_use]
fn usable_iv(iv: Positive) -> bool {
iv > Positive::ZERO && iv != Positive::MAX
}
#[must_use]
fn days_between(as_of: DateTime<Utc>, expiration_utc: DateTime<Utc>) -> Option<Positive> {
let seconds = expiration_utc.signed_duration_since(as_of).num_seconds();
if seconds <= 0 {
return None;
}
let days = Decimal::from(seconds).checked_div(Decimal::from(SECONDS_PER_DAY))?;
Positive::new_decimal(days).ok()
}
#[must_use]
fn price_grid(legs: &[BuilderLeg], spot: Positive) -> Option<Vec<Positive>> {
let spot_f = spot.to_f64();
let mut lo = spot_f;
let mut hi = spot_f;
for leg in legs {
let strike = leg.strike.to_f64();
lo = lo.min(strike);
hi = hi.max(strike);
}
let lo = lo * (1.0 - GRID_MARGIN);
let hi = hi * (1.0 + GRID_MARGIN);
if !lo.is_finite() || !hi.is_finite() || hi <= lo {
return None;
}
let span = hi - lo;
let last = GRID_POINTS.checked_sub(1)?;
let divisor = f64::from(u16::try_from(last).ok()?);
let mut grid = Vec::with_capacity(GRID_POINTS + legs.len());
for i in 0..GRID_POINTS {
let numerator = f64::from(u16::try_from(i).ok()?);
let x = lo + span * (numerator / divisor);
if let Ok(point) = Positive::new(x) {
grid.push(point);
}
}
for leg in legs {
if leg.strike.to_f64().is_finite() {
grid.push(leg.strike);
}
}
grid.sort_by_key(|point| point.to_dec());
grid.dedup();
if grid.len() < 2 {
return None;
}
Some(grid)
}
#[must_use]
pub(crate) fn expiration_series(positions: &[Position], grid: &[Positive]) -> GraphData {
let mut xs = Vec::with_capacity(grid.len());
let mut ys = Vec::with_capacity(grid.len());
for price in grid {
if let Some(pnl) = expiration_pnl(positions, price) {
xs.push(price.to_dec());
ys.push(pnl);
}
}
series("payoff @ expiration", xs, ys)
}
#[must_use]
fn tplus0_series(positions: &[Position], grid: &[Positive]) -> GraphData {
let mut xs = Vec::with_capacity(grid.len());
let mut ys = Vec::with_capacity(grid.len());
for price in grid {
if let Some(pnl) = tplus0_pnl(positions, price) {
xs.push(price.to_dec());
ys.push(pnl);
}
}
series("payoff @ t+0", xs, ys)
}
#[must_use]
fn expiration_pnl(positions: &[Position], price: &Positive) -> Option<Decimal> {
let mut total = Decimal::ZERO;
for position in positions {
let pnl = position.pnl_at_expiration(&Some(price)).ok()?;
total = total.checked_add(pnl)?;
}
Some(total)
}
#[must_use]
fn tplus0_pnl(positions: &[Position], price: &Positive) -> Option<Decimal> {
let mut total = expiration_pnl(positions, price)?;
for position in positions {
let mut option = position.option.clone();
option.underlying_price = *price;
let bs = option.calculate_price_black_scholes().ok()?;
let mark = bs.checked_mul(option.quantity.to_dec())?;
let intrinsic = option.intrinsic_value(*price).ok()?;
let time_value = mark.checked_sub(intrinsic)?;
total = total.checked_add(time_value)?;
}
Some(total)
}
#[must_use]
pub(crate) fn break_even_points(expiration: &GraphData) -> Vec<Positive> {
let series = match expiration {
GraphData::Series(series) => series,
GraphData::MultiSeries(_) | GraphData::GraphSurface(_) => return Vec::new(),
};
let mut out = Vec::new();
let mut prev: Option<(Decimal, Decimal)> = None;
for (x, y) in series.x.iter().zip(series.y.iter()) {
if let Some((px, py)) = prev
&& crosses_zero(py, *y)
&& let Some(root) = interpolate_zero(px, py, *x, *y)
&& let Ok(point) = Positive::new_decimal(root)
{
out.push(point);
}
prev = Some((*x, *y));
}
out
}
#[must_use]
fn crosses_zero(prev: Decimal, next: Decimal) -> bool {
(prev < Decimal::ZERO && next >= Decimal::ZERO)
|| (prev > Decimal::ZERO && next <= Decimal::ZERO)
}
#[must_use]
fn interpolate_zero(x0: Decimal, y0: Decimal, x1: Decimal, y1: Decimal) -> Option<Decimal> {
let dy = y1.checked_sub(y0)?;
if dy == Decimal::ZERO {
return None;
}
let dx = x1.checked_sub(x0)?;
let step = y0.checked_mul(dx)?.checked_div(dy)?;
x0.checked_sub(step)
}
#[must_use]
fn series(name: &str, x: Vec<Decimal>, y: Vec<Decimal>) -> GraphData {
GraphData::Series(Series2D {
x,
y,
name: name.to_owned(),
mode: TraceMode::Lines,
line_color: None,
line_width: Some(2.0),
})
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::model::Position;
use optionstratlib::prelude::{Decimal, Positive};
use optionstratlib::pricing::Profit;
use optionstratlib::strategies::custom::CustomStrategy;
use optionstratlib::visualization::{GraphData, Series2D};
use optionstratlib::{ExpirationDate, OptionStyle, OptionType, Options, Side as OptionSide};
use super::{
BuilderLeg, GeometryBuild, GreeksOrigin, MIN_PLAUSIBLE_LOCAL_IV, PayoffGeometry, Side,
TPlus0Build, break_even_points, build_geometry, build_geometry_with, expiration_series,
plausible_leg_iv, rebuild_tplus0, rebuild_tplus0_with, tplus0_series,
};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSource, ChainStore, ContractSpecFingerprint, ExerciseStyle,
ExpirySource, GreeksRow, Instrument, InstrumentKey, ProviderId, SettlementStyle,
};
#[track_caller]
fn pos(value: f64) -> Positive {
match Positive::new(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive `{value}`: {e}"),
}
}
#[track_caller]
fn priced(build: GeometryBuild) -> PayoffGeometry {
match build {
GeometryBuild::Priced(geometry) => *geometry,
GeometryBuild::NotPriceable => panic!("expected a priceable geometry"),
GeometryBuild::ComputeFailed => {
panic!("expected a priceable geometry, the pricing math panicked")
}
}
}
#[track_caller]
fn repriced(build: TPlus0Build) -> GraphData {
match build {
TPlus0Build::Series(series) => series,
TPlus0Build::ComputeFailed => {
panic!("expected a t+0 series, the pricing math panicked")
}
}
}
fn leg(side: OptionSide, style: OptionStyle, strike: f64, premium: f64) -> Position {
let option = Options::new(
OptionType::European,
side,
"TEST".to_owned(),
pos(strike),
ExpirationDate::Days(pos(30.0)),
pos(0.3),
Positive::ONE,
pos(100.0),
Decimal::ZERO,
style,
Positive::ZERO,
None,
);
Position::new(
option,
pos(premium),
match chrono::DateTime::<chrono::Utc>::from_timestamp(1_700_000_000, 0) {
Some(dt) => dt,
None => panic!("bad fixed test instant"),
},
Positive::ZERO,
Positive::ZERO,
None,
None,
)
}
fn call_spread() -> Vec<Position> {
vec![
leg(OptionSide::Long, OptionStyle::Call, 100.0, 3.0),
leg(OptionSide::Short, OptionStyle::Call, 105.0, 1.0),
]
}
fn grid() -> Vec<Positive> {
[80.0, 90.0, 100.0, 102.5, 105.0, 110.0, 120.0]
.into_iter()
.map(pos)
.collect()
}
#[track_caller]
fn xy(graph: &GraphData) -> (&Series2D,) {
match graph {
GraphData::Series(series) => (series,),
GraphData::MultiSeries(_) | GraphData::GraphSurface(_) => {
panic!("expected a single Series, got {graph:?}")
}
}
}
#[track_caller]
fn assert_close(actual: Decimal, expected: Decimal) {
let diff = (actual - expected).abs();
assert!(
diff < Decimal::new(1, 4),
"expected {expected}, got {actual} (diff {diff})",
);
}
#[test]
fn test_expiration_series_matches_calculate_profit_at() {
let positions = call_spread();
let grid = grid();
let expiration = expiration_series(&positions, &grid);
let (series,) = xy(&expiration);
assert_eq!(series.x.len(), grid.len(), "one sample per grid point");
let strategy = match CustomStrategy::new(
"t".to_owned(),
"TEST".to_owned(),
"spread".to_owned(),
pos(100.0),
positions,
pos(0.01),
1_000,
Positive::ONE,
) {
Ok(s) => s,
Err(e) => panic!("CustomStrategy::new failed: {e}"),
};
for (x, y) in series.x.iter().zip(series.y.iter()) {
let price = match Positive::new_decimal(*x) {
Ok(p) => p,
Err(e) => panic!("grid x not positive `{x}`: {e}"),
};
let expected = match strategy.calculate_profit_at(&price) {
Ok(v) => v,
Err(e) => panic!("calculate_profit_at failed at {price}: {e}"),
};
assert_close(*y, expected);
}
}
#[test]
fn test_tplus0_series_is_nonempty_and_differs_from_expiration() {
let positions = call_spread();
let grid = grid();
let expiration = expiration_series(&positions, &grid);
let tplus0 = tplus0_series(&positions, &grid);
let (exp,) = xy(&expiration);
let (t0,) = xy(&tplus0);
assert_eq!(t0.x.len(), grid.len(), "t+0 samples every grid point");
assert_ne!(exp.y, t0.y, "the t+0 curve differs from expiration");
}
#[test]
fn test_break_even_points_from_expiration_sign_changes() {
let positions = call_spread();
let grid: Vec<Positive> = (0..=200).map(|i| pos(60.0 + f64::from(i))).collect();
let expiration = expiration_series(&positions, &grid);
let break_evens = break_even_points(&expiration);
assert!(
!break_evens.is_empty(),
"a debit call spread has a break-even between its strikes",
);
for be in &break_evens {
let value = be.to_f64();
assert!(
(100.0..=105.0).contains(&value),
"break-even {value} sits between the 100 and 105 strikes",
);
}
}
#[test]
fn test_break_even_points_of_non_series_is_empty() {
let multi = GraphData::MultiSeries(vec![Series2D::default()]);
assert!(break_even_points(&multi).is_empty());
}
#[test]
fn test_series_build_is_deterministic() {
let grid = grid();
let first = tplus0_series(&call_spread(), &grid);
let second = tplus0_series(&call_spread(), &grid);
assert_eq!(first, second, "identical inputs yield an identical series");
let e1 = expiration_series(&call_spread(), &grid);
let e2 = expiration_series(&call_spread(), &grid);
assert_eq!(e1, e2, "the expiration series is deterministic too");
}
#[test]
fn test_plausible_leg_iv_floors_local_but_trusts_venue() {
let sub_floor = match Positive::new_decimal(MIN_PLAUSIBLE_LOCAL_IV) {
Ok(floor) => match Positive::new(floor.to_f64() / 2.0) {
Ok(v) => v,
Err(e) => panic!("half-floor is positive: {e}"),
},
Err(e) => panic!("floor is positive: {e}"),
};
assert_eq!(
plausible_leg_iv(sub_floor, GreeksOrigin::ComputedLocally),
None,
"a sub-floor LOCAL IV is rejected (t+0 unavailable, not a fake curve)",
);
assert_eq!(
plausible_leg_iv(sub_floor, GreeksOrigin::Provider),
Some(sub_floor),
"a sub-floor VENUE IV is trusted as-is (never floored)",
);
let plausible = pos(0.35);
assert_eq!(
plausible_leg_iv(plausible, GreeksOrigin::ComputedLocally),
Some(plausible),
"a plausible local IV passes the floor",
);
}
const CHAIN_EXPIRY: &str = "2025-06-27";
const AS_OF_EARLY: i64 = 1_700_000_000;
const AS_OF_LATE: i64 = 1_745_000_000;
#[track_caller]
fn utc(secs: i64) -> chrono::DateTime<chrono::Utc> {
match chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0) {
Some(t) => t,
None => panic!("invalid test timestamp: {secs}"),
}
}
#[track_caller]
fn pid(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("invalid provider id `{id}`: {e}"),
}
}
#[track_caller]
fn resolved_expiry() -> chrono::DateTime<chrono::Utc> {
let chain = OptionChain::new("BTC", pos(100.0), CHAIN_EXPIRY.to_owned(), None, None);
match chain.get_expiration() {
Some(ExpirationDate::DateTime(dt)) => dt,
other => panic!("expected an absolute chain expiry, got {other:?}"),
}
}
fn atm_row(strike: f64) -> OptionData {
let mut od = OptionData {
strike_price: pos(strike),
call_bid: Some(pos(9.0)),
call_ask: Some(pos(11.0)),
put_bid: Some(pos(9.0)),
put_ask: Some(pos(11.0)),
implied_volatility: pos(0.5),
..Default::default()
};
od.set_mid_prices();
od
}
fn atm_chain(spot: f64) -> OptionChain {
let mut chain = OptionChain::new("BTC", pos(spot), CHAIN_EXPIRY.to_owned(), None, None);
let _ = chain.options.insert(atm_row(spot));
chain
}
fn instrument(spot: f64) -> Instrument {
Instrument {
key: InstrumentKey {
underlying: "BTC".to_owned(),
expiration_utc: resolved_expiry(),
strike: pos(spot),
style: OptionStyle::Call,
},
provider: pid("deribit"),
native_symbol: "BTC-CALL".to_owned(),
stream_symbol: None,
spec: ContractSpecFingerprint {
contract_multiplier: 1,
settlement: SettlementStyle::Cash,
exercise: ExerciseStyle::European,
quote_currency: "USD".to_owned(),
venue_product_code: "BTC".to_owned(),
},
}
}
fn venue_iv_row(spot: f64, iv: f64, received: i64) -> GreeksRow {
GreeksRow {
instrument: instrument(spot),
iv: Some(pos(iv)),
delta: None,
gamma: None,
theta: None,
vega: None,
rho: None,
origin: GreeksOrigin::Provider,
event_time: None,
received_time: utc(received),
}
}
fn store_at(spot: f64, as_of: i64) -> ChainStore {
let mut store = ChainStore::seed(
ChainFetch::new(
atm_chain(spot),
ExpirySource::new("BTC", resolved_expiry(), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(as_of),
);
let _ = store.apply_greeks(&venue_iv_row(spot, 0.5, as_of));
store
}
fn atm_call_leg(spot: f64) -> Vec<BuilderLeg> {
vec![BuilderLeg {
strike: pos(spot),
style: OptionStyle::Call,
side: Side::Buy,
qty: 1,
}]
}
#[test]
fn test_tplus0_theta_decays_when_the_analytics_instant_advances() {
let spot = 100.0;
let legs = atm_call_leg(spot);
let early_store = store_at(spot, AS_OF_EARLY);
let late_store = store_at(spot, AS_OF_LATE);
let geometry = priced(build_geometry(&legs, &early_store));
let (early,) = xy(&geometry.tplus0);
let (exp,) = xy(&geometry.expiration);
let late = repriced(rebuild_tplus0(
&legs,
&late_store,
&geometry.grid,
&geometry.entry_positions,
));
let (late,) = xy(&late);
assert_eq!(
early.x, late.x,
"the shared grid is unchanged by the reprice"
);
assert_eq!(early.x, exp.x, "expiration shares the grid too");
assert_eq!(
late.x.len(),
geometry.grid.len(),
"one sample per grid point"
);
let eps = Decimal::new(1, 6);
let mut decayed = false;
for ((e, l), x) in early.y.iter().zip(late.y.iter()).zip(exp.y.iter()) {
assert!(
*l <= *e + eps,
"later t+0 at/below earlier (theta-decay direction): early {e}, late {l}",
);
assert!(
*l >= *x - eps,
"later t+0 stays at/above the frozen expiration: late {l}, exp {x}",
);
if *e - *l > Decimal::ONE {
decayed = true;
}
}
assert!(
decayed,
"theta decay is observable — the curve dropped toward expiration somewhere",
);
assert_ne!(
early.y, late.y,
"the t+0 curve changed with the fresh, smaller DTE"
);
}
#[test]
fn test_tplus0_rebuild_keeps_the_entry_premium_frozen() {
let spot = 100.0;
let legs = atm_call_leg(spot);
let early_store = store_at(spot, AS_OF_EARLY);
let late_store = store_at(spot, AS_OF_LATE);
let geometry = priced(build_geometry(&legs, &early_store));
let commit_mark = legs
.first()
.and_then(|leg| leg.mark_in(early_store.chain()));
let p0: Vec<Positive> = geometry.entry_positions.iter().map(|p| p.premium).collect();
assert_eq!(
p0.first().copied(),
commit_mark,
"the frozen premium is the commit-time mark P0",
);
let _ = repriced(rebuild_tplus0(
&legs,
&late_store,
&geometry.grid,
&geometry.entry_positions,
));
let p_after: Vec<Positive> = geometry.entry_positions.iter().map(|p| p.premium).collect();
assert_eq!(
p0, p_after,
"a later-instant rebuild never re-bases the frozen entry premium",
);
}
#[test]
fn test_tplus0_rebuild_is_deterministic_across_identical_inputs() {
let spot = 100.0;
let legs = atm_call_leg(spot);
let late_store = store_at(spot, AS_OF_LATE);
let geometry = priced(build_geometry(&legs, &store_at(spot, AS_OF_EARLY)));
let first = repriced(rebuild_tplus0(
&legs,
&late_store,
&geometry.grid,
&geometry.entry_positions,
));
let second = repriced(rebuild_tplus0(
&legs,
&late_store,
&geometry.grid,
&geometry.entry_positions,
));
assert_eq!(
first, second,
"identical inputs yield an identical t+0 series"
);
}
#[track_caller]
fn huge_positive() -> Positive {
let half = match Decimal::MAX.checked_div(Decimal::from(2)) {
Some(d) => d,
None => panic!("Decimal::MAX / 2 is representable"),
};
match Positive::new_decimal(half) {
Ok(p) => p,
Err(e) => panic!("half of Decimal::MAX is a valid Positive: {e}"),
}
}
const OVERFLOW_QTY: u32 = 3;
fn overflow_mark_chain(spot: f64) -> OptionChain {
let mut chain = OptionChain::new("BTC", pos(spot), CHAIN_EXPIRY.to_owned(), None, None);
let _ = chain.options.insert(OptionData {
strike_price: pos(spot),
call_middle: Some(huge_positive()),
put_middle: Some(huge_positive()),
implied_volatility: pos(0.5),
..Default::default()
});
chain
}
fn overflow_store(spot: f64) -> ChainStore {
let mut store = ChainStore::seed(
ChainFetch::new(
overflow_mark_chain(spot),
ExpirySource::new("BTC", resolved_expiry(), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(AS_OF_EARLY),
);
let _ = store.apply_greeks(&venue_iv_row(spot, 0.5, AS_OF_EARLY));
store
}
fn overflow_entry_position(spot: f64) -> Position {
let option = Options::new(
OptionType::European,
OptionSide::Long,
"BTC".to_owned(),
pos(spot),
ExpirationDate::Days(pos(30.0)),
pos(0.5),
pos(f64::from(OVERFLOW_QTY)),
pos(spot),
Decimal::ZERO,
OptionStyle::Call,
Positive::ZERO,
None,
);
Position::new(
option,
huge_positive(),
utc(AS_OF_EARLY),
Positive::ZERO,
Positive::ZERO,
None,
None,
)
}
#[test]
fn test_build_geometry_contains_an_upstream_pricing_panic() {
let spot = 100.0;
let legs = atm_call_leg(spot);
let store = overflow_store(spot);
match build_geometry_with(&legs, &store, |_, _| panic!("upstream pricing math")) {
GeometryBuild::ComputeFailed => {}
GeometryBuild::Priced(_) => panic!("a panicking build cannot yield a geometry"),
GeometryBuild::NotPriceable => {
panic!("a PANIC must not be reported as unpriceable legs (#131)")
}
}
}
#[test]
fn test_build_geometry_overflow_basis_is_a_typed_error_not_a_panic() {
let spot = 100.0;
let legs = vec![BuilderLeg {
strike: pos(spot),
style: OptionStyle::Call,
side: Side::Buy,
qty: OVERFLOW_QTY,
}];
match build_geometry(&legs, &overflow_store(spot)) {
GeometryBuild::Priced(geometry) => {
let (series,) = xy(&geometry.expiration);
assert!(
series.x.is_empty(),
"an overflowing basis prices no sample: {series:?}"
);
assert!(
geometry.break_evens.is_empty(),
"no break-even without a curve"
);
}
GeometryBuild::NotPriceable => panic!("the marks are present; the legs validate"),
GeometryBuild::ComputeFailed => {
panic!("a typed upstream error is not a contained panic (#131)")
}
}
}
#[test]
fn test_build_geometry_keeps_unpriceable_distinct_from_compute_failed() {
let legs = atm_call_leg(100.0);
let bare = ChainStore::seed(
ChainFetch::new(
OptionChain::new("BTC", pos(100.0), CHAIN_EXPIRY.to_owned(), None, None),
ExpirySource::new("BTC", resolved_expiry(), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(AS_OF_EARLY),
);
match build_geometry(&legs, &bare) {
GeometryBuild::NotPriceable => {}
GeometryBuild::Priced(_) => panic!("an empty chain has no mark to price"),
GeometryBuild::ComputeFailed => panic!("nothing panicked; the legs just lack marks"),
}
}
#[test]
fn test_rebuild_tplus0_contains_an_upstream_pricing_panic() {
let spot = 100.0;
let legs = atm_call_leg(spot);
let store = overflow_store(spot);
let grid = vec![pos(80.0), pos(100.0), pos(120.0)];
let entry_positions = vec![overflow_entry_position(spot)];
match rebuild_tplus0_with(&legs, &store, &grid, &entry_positions, |_, _, _, _| {
panic!("upstream pricing math")
}) {
TPlus0Build::ComputeFailed => {}
TPlus0Build::Series(series) => {
panic!("a panicking reprice cannot yield a series, got {series:?}")
}
}
}
#[test]
fn test_rebuild_tplus0_overflow_basis_is_a_typed_error_not_a_panic() {
let spot = 100.0;
let legs = atm_call_leg(spot);
let store = overflow_store(spot);
let grid = vec![pos(80.0), pos(100.0), pos(120.0)];
let entry_positions = vec![overflow_entry_position(spot)];
match rebuild_tplus0(&legs, &store, &grid, &entry_positions) {
TPlus0Build::Series(series) => {
let (series,) = xy(&series);
assert!(
series.x.is_empty(),
"an overflowing basis reprices no sample: {series:?}"
);
}
TPlus0Build::ComputeFailed => {
panic!("a typed upstream error is not a contained panic (#131)")
}
}
}
}