use std::collections::BTreeSet;
use chrono::{DateTime, Utc};
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::{BasicCurves, BasicSurfaces, Decimal, Positive, VolatilitySmile};
use optionstratlib::visualization::{GraphData, Series2D, Surface3D};
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use super::SurfaceAxis;
use crate::chain::{ChainStore, GreeksOrigin, InstrumentKey, MIN_PLAUSIBLE_LOCAL_IV};
const GEOMETRY_STYLE: OptionStyle = OptionStyle::Call;
const GEOMETRY_SIDE: Side = Side::Long;
const VOL_SAMPLES: usize = 5;
const SECONDS_PER_DAY: i64 = 86_400;
#[must_use]
pub(crate) fn empty_series() -> GraphData {
GraphData::Series(Series2D::default())
}
#[must_use]
pub(crate) fn empty_surface() -> GraphData {
GraphData::GraphSurface(Surface3D::default())
}
#[must_use]
fn degenerate_series() -> GraphData {
GraphData::Series(Series2D {
x: vec![Decimal::ZERO],
y: Vec::new(),
..Series2D::default()
})
}
#[must_use]
fn degenerate_surface() -> GraphData {
GraphData::GraphSurface(Surface3D {
x: vec![Decimal::ZERO],
y: Vec::new(),
z: Vec::new(),
..Surface3D::default()
})
}
#[must_use]
pub(crate) fn build_smile(store: &ChainStore) -> GraphData {
match prepared_chain(store) {
Some(chain) => named(GraphData::from(chain.smile()), "IV smile"),
None => empty_series(),
}
}
#[must_use]
pub(crate) fn build_curve(store: &ChainStore, axis: SurfaceAxis) -> GraphData {
let Some(chain) = prepared_chain(store) else {
return empty_series();
};
match chain.curve(&axis.to_basic(), &GEOMETRY_STYLE, &GEOMETRY_SIDE) {
Ok(curve) => named(
GraphData::from(curve),
&format!("{} vs strike", axis.metric_name()),
),
Err(_) => degenerate_series(),
}
}
#[must_use]
pub(crate) fn build_surface(store: &ChainStore, axis: SurfaceAxis) -> GraphData {
if axis == SurfaceAxis::Volatility {
return empty_surface();
}
let Some(chain) = prepared_chain(store) else {
return empty_surface();
};
let vols = vol_samples(&chain);
if vols.is_empty() {
return empty_surface();
}
match chain.surface(
&axis.to_basic(),
&GEOMETRY_STYLE,
Some(vols),
&GEOMETRY_SIDE,
) {
Ok(surface) => named(
GraphData::from(surface),
&format!("{} surface", axis.metric_name()),
),
Err(_) => degenerate_surface(),
}
}
#[must_use]
fn named(mut graph: GraphData, name: &str) -> GraphData {
match &mut graph {
GraphData::Series(series) => series.name = name.to_owned(),
GraphData::GraphSurface(surface) => surface.name = name.to_owned(),
GraphData::MultiSeries(_) => {}
}
graph
}
#[must_use]
fn prepared_chain(store: &ChainStore) -> Option<OptionChain> {
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 symbol = chain.symbol.clone();
let underlying = chain.underlying_price;
let expiry = ExpirationDate::Days(dte);
let mut options: BTreeSet<OptionData> = BTreeSet::new();
for od in &chain.options {
let Some(iv) = resolve_strike_iv(store, &symbol, od, expiration_utc) else {
continue;
};
let mut od = od.clone();
od.symbol = Some(symbol.clone());
od.underlying_price = Some(Box::new(underlying));
od.expiration_date = Some(expiry);
od.implied_volatility = iv;
let _ = options.insert(od);
}
if options.is_empty() {
return None;
}
let mut prepared = chain.clone();
prepared.options = options;
Some(prepared)
}
#[must_use]
fn resolve_strike_iv(
store: &ChainStore,
symbol: &str,
od: &OptionData,
expiration_utc: DateTime<Utc>,
) -> Option<Positive> {
for style in [OptionStyle::Call, OptionStyle::Put] {
let key = InstrumentKey {
underlying: symbol.to_owned(),
expiration_utc,
strike: od.strike_price,
style,
};
if let Some(sidecar) = store.leg_greeks(&key)
&& let Some(iv) = sidecar.iv
&& usable_iv(iv)
&& let Some(plausible) = plausible_iv(iv, sidecar.iv_origin)
{
return Some(plausible);
}
}
usable_iv(od.implied_volatility).then_some(od.implied_volatility)
}
#[must_use]
fn vol_samples(chain: &OptionChain) -> Vec<Positive> {
let mut lo = f64::MAX;
let mut hi = f64::MIN;
let mut any = false;
for od in &chain.options {
let iv = od.implied_volatility;
if usable_iv(iv) {
let v = iv.to_f64();
if v.is_finite() {
lo = lo.min(v);
hi = hi.max(v);
any = true;
}
}
}
if !any {
return Vec::new();
}
let (lo, hi) = if hi > lo {
(lo, hi)
} else {
(lo * 0.5, lo * 1.5)
};
let Ok(den) = u16::try_from(VOL_SAMPLES.max(2) - 1) else {
return Vec::new();
};
let mut out = Vec::with_capacity(VOL_SAMPLES);
for i in 0..VOL_SAMPLES {
let Ok(num) = u16::try_from(i) else {
break;
};
let t = f64::from(num) / f64::from(den);
let v = lo + (hi - lo) * t;
if let Ok(p) = Positive::new(v) {
out.push(p);
}
}
out.dedup();
out
}
#[must_use]
fn usable_iv(iv: Positive) -> bool {
iv > Positive::ZERO && iv != Positive::MAX
}
#[must_use]
fn plausible_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 absolute_expiry(chain: &OptionChain) -> Option<DateTime<Utc>> {
match chain.get_expiration()? {
ExpirationDate::DateTime(dt) => Some(dt),
ExpirationDate::Days(_) => None,
}
}
#[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()
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use chrono::{DateTime, Utc};
use optionstratlib::chains::OptionData;
use optionstratlib::chains::chain::OptionChain;
use optionstratlib::prelude::{BasicCurves, Decimal, Positive};
use optionstratlib::visualization::GraphData;
use optionstratlib::{ExpirationDate, OptionStyle, Side};
use super::{SurfaceAxis, build_curve, build_smile, build_surface, named};
use crate::chain::{
AliasCatalog, ChainFetch, ChainSource, ChainStore, ExpirySource, ProviderId,
};
use crate::ui::graph::{EmptyReason, project};
const EXP: i64 = 1_700_000_000;
const A: f64 = 60_000.0;
const B: f64 = 62_000.0;
const C: f64 = 64_000.0;
#[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 utc(secs: i64) -> DateTime<Utc> {
match DateTime::<Utc>::from_timestamp(secs, 0) {
Some(t) => t,
None => panic!("invalid test timestamp: {secs}"),
}
}
#[track_caller]
fn pos(value: f64) -> Positive {
match Positive::new(value) {
Ok(p) => p,
Err(e) => panic!("invalid test positive `{value}`: {e}"),
}
}
fn full_row(strike: f64) -> OptionData {
let mut od = OptionData {
strike_price: pos(strike),
call_bid: Some(pos(3_000.0)),
call_ask: Some(pos(3_100.0)),
put_bid: Some(pos(2_000.0)),
put_ask: Some(pos(2_100.0)),
implied_volatility: pos(0.5),
..Default::default()
};
od.set_mid_prices();
od
}
fn bare_row(strike: f64) -> OptionData {
OptionData {
strike_price: pos(strike),
implied_volatility: Positive::ZERO,
..Default::default()
}
}
fn store_from(chain: OptionChain) -> ChainStore {
ChainStore::seed(
ChainFetch::new(
chain,
ExpirySource::new("BTC", utc(EXP), pid("deribit")),
AliasCatalog::new(),
),
ChainSource::Merged,
Duration::from_secs(2),
utc(1_735_689_600),
)
}
fn full_store() -> ChainStore {
let mut chain = OptionChain::new("BTC", pos(A), "2025-06-27".to_owned(), None, None);
let _ = chain.options.insert(full_row(A));
let _ = chain.options.insert(full_row(B));
let _ = chain.options.insert(full_row(C));
store_from(chain)
}
#[track_caller]
fn series_len(graph: &GraphData) -> usize {
match graph {
GraphData::Series(s) => {
assert_eq!(s.x.len(), s.y.len(), "x and y are paired");
s.x.len()
}
other => panic!("expected a Series, got {other:?}"),
}
}
#[track_caller]
fn surface_points(graph: &GraphData) -> usize {
match graph {
GraphData::GraphSurface(s) => {
assert_eq!(s.x.len(), s.y.len());
assert_eq!(s.x.len(), s.z.len());
s.x.len()
}
other => panic!("expected a GraphSurface, got {other:?}"),
}
}
#[test]
fn test_build_smile_is_nonempty_and_no_iv_is_zero() {
let store = full_store();
let smile = build_smile(&store);
let n = series_len(&smile);
assert!(n >= 3, "one point per reliable strike (>= the 3 seeded)");
if let GraphData::Series(s) = &smile {
for y in &s.y {
assert!(*y > optionstratlib::prelude::Decimal::ZERO, "IV filled > 0");
}
}
}
#[test]
fn test_build_smile_drops_the_no_iv_strike() {
let mut chain = OptionChain::new("BTC", pos(A), "2025-06-27".to_owned(), None, None);
let _ = chain.options.insert(full_row(A));
let _ = chain.options.insert(full_row(B));
let _ = chain.options.insert(bare_row(C));
let store = store_from(chain);
assert_eq!(
series_len(&build_smile(&store)),
2,
"the bare strike is dropped"
);
}
#[test]
fn test_build_smile_empty_chain_is_empty_series() {
let store = store_from(OptionChain::new(
"BTC",
pos(A),
"2025-06-27".to_owned(),
None,
None,
));
assert_eq!(
series_len(&build_smile(&store)),
0,
"no strikes → empty smile"
);
}
#[test]
fn test_build_curve_delta_is_nonempty_series() {
let store = full_store();
assert!(
series_len(&build_curve(&store, SurfaceAxis::Delta)) >= 3,
"a delta curve has a point per reliable strike",
);
}
#[test]
fn test_build_surface_delta_is_nonempty_graphsurface() {
let store = full_store();
assert!(
surface_points(&build_surface(&store, SurfaceAxis::Delta)) > 3,
"a delta surface sweeps strike × vol",
);
}
#[test]
fn test_build_surface_refuses_the_volatility_axis() {
let store = full_store();
assert_eq!(
surface_points(&build_surface(&store, SurfaceAxis::Volatility)),
0,
"the Volatility axis is refused for the 3D surface",
);
}
#[track_caller]
fn precomputed_dte(store: &ChainStore) -> Positive {
let chain = store.chain();
let expiration_utc = match chain.get_expiration() {
Some(ExpirationDate::DateTime(dt)) => dt,
other => panic!("expected an absolute expiry, got {other:?}"),
};
let as_of = match store.last_full_poll() {
Some(t) => t,
None => panic!("the seeded store carries a poll instant"),
};
let seconds = expiration_utc.signed_duration_since(as_of).num_seconds();
assert!(seconds > 0, "the fixture expiry is in the future");
let days = match Decimal::from(seconds).checked_div(Decimal::from(86_400_i64)) {
Some(d) => d,
None => panic!("day-count division failed"),
};
match Positive::new_decimal(days) {
Ok(p) => p,
Err(e) => panic!("positive dte: {e}"),
}
}
#[test]
fn test_greek_curve_prices_a_precomputed_frozen_days_expectation() {
let store = full_store();
let expected_dte = precomputed_dte(&store);
let prepared = match super::prepared_chain(&store) {
Some(c) => c,
None => panic!("the fixture prepares a chain"),
};
assert!(
!prepared.options.is_empty(),
"the prepared chain has strikes"
);
for od in &prepared.options {
assert_eq!(
od.expiration_date,
Some(ExpirationDate::Days(expected_dte)),
"every priced option carries the precomputed fixed Days, not the wall clock",
);
}
let curve = match prepared.curve(
&SurfaceAxis::Theta.to_basic(),
&OptionStyle::Call,
&Side::Long,
) {
Ok(c) => c,
Err(e) => panic!("the fixture curve prices: {e:?}"),
};
let expected = named(
GraphData::from(curve),
&format!("{} vs strike", SurfaceAxis::Theta.metric_name()),
);
assert_eq!(
build_curve(&store, SurfaceAxis::Theta),
expected,
"the theta curve equals the fixed-Days expectation",
);
}
#[test]
fn test_builds_are_deterministic_across_two_passes() {
let a = full_store();
let b = full_store();
assert_eq!(build_smile(&a), build_smile(&b), "smile is deterministic");
assert_eq!(
build_surface(&a, SurfaceAxis::Vega),
build_surface(&b, SurfaceAxis::Vega),
"the vega surface is deterministic (frozen Days, no clock)",
);
}
#[test]
fn test_hard_build_err_sentinels_project_to_degenerate_not_no_data() {
assert_eq!(
project(&super::degenerate_series()).empty_reason(),
Some(EmptyReason::Degenerate),
"the curve-Err sentinel projects degenerate geometry",
);
assert_eq!(
project(&super::degenerate_surface()).empty_reason(),
Some(EmptyReason::Degenerate),
"the surface-Err sentinel projects degenerate geometry",
);
assert_eq!(
project(&super::empty_series()).empty_reason(),
Some(EmptyReason::NoData),
);
assert_eq!(
project(&super::empty_surface()).empty_reason(),
Some(EmptyReason::NoData),
);
}
#[test]
fn test_curve_price_axis_uses_frozen_days_not_the_wall_clock() {
let store = full_store();
let first = build_curve(&store, SurfaceAxis::Price);
let second = build_curve(&store, SurfaceAxis::Price);
assert_eq!(first, second, "the price curve is clock-free and stable");
assert!(series_len(&first) >= 3, "a price curve has points");
}
#[test]
fn test_build_curve_all_greek_axes_are_nonempty() {
let store = full_store();
for axis in [
SurfaceAxis::Delta,
SurfaceAxis::Gamma,
SurfaceAxis::Theta,
SurfaceAxis::Vega,
SurfaceAxis::Volatility,
SurfaceAxis::Price,
] {
assert!(
series_len(&build_curve(&store, axis)) >= 3,
"the {axis:?} curve has a point per reliable strike",
);
}
}
}