diff --git a/crates/engine/src/chain.rs b/crates/engine/src/chain.rs index 43b31ce..698ebec 100644 --- a/crates/engine/src/chain.rs +++ b/crates/engine/src/chain.rs @@ -136,6 +136,16 @@ pub async fn fetch_chains(client: &Client, now: OffsetDateTime) -> Result, now: OffsetDateTime) -> AssetChains { let mut out: AssetChains = HashMap::new(); for row in rows { let Some(asset) = parse_asset(&row.asset) else { @@ -191,7 +201,7 @@ pub async fn fetch_chains(client: &Client, now: OffsetDateTime) -> Result Option { @@ -263,4 +273,160 @@ mod tests { assert_eq!(parse_kind("put"), Some(OptionKind::Put)); assert_eq!(parse_kind("straddle"), None); } + + // ------- assemble_chains tests ------------------------------------ + + fn row( + asset: &str, + expiry: OffsetDateTime, + strike: f64, + kind: &str, + mid: Option, + iv: Option, + ) -> ChainRow { + ChainRow { + asset: asset.to_string(), + expiry, + strike, + kind: kind.to_string(), + bid: None, + ask: None, + mid, + iv, + underlying: 100_000.0, + } + } + + #[test] + fn assemble_drops_unknown_asset() { + let now = datetime!(2026-05-25 00:00:00 UTC); + let exp = now + time::Duration::days(7); + let rows = vec![ + row("btc", exp, 100.0, "call", Some(1.0), Some(0.5)), + row("doge", exp, 100.0, "call", Some(1.0), Some(0.5)), + ]; + let out = assemble_chains(rows, now); + assert!(out.contains_key(&Asset::Btc)); + assert_eq!(out.len(), 1); + } + + #[test] + fn assemble_drops_unknown_kind() { + let now = datetime!(2026-05-25 00:00:00 UTC); + let exp = now + time::Duration::days(7); + let rows = vec![ + row("btc", exp, 100.0, "call", Some(1.0), Some(0.5)), + row("btc", exp, 100.0, "straddle", Some(1.0), Some(0.5)), + ]; + let out = assemble_chains(rows, now); + let chain = &out[&Asset::Btc][&exp]; + assert_eq!(chain.legs.len(), 1); + assert!(chain.legs[0].call_mid_usd.is_some()); + assert!(chain.legs[0].put_mid_usd.is_none()); + } + + #[test] + fn assemble_drops_non_finite_or_non_positive_strike() { + let now = datetime!(2026-05-25 00:00:00 UTC); + let exp = now + time::Duration::days(7); + let rows = vec![ + row("btc", exp, f64::NAN, "call", Some(1.0), Some(0.5)), + row("btc", exp, f64::INFINITY, "call", Some(1.0), Some(0.5)), + row("btc", exp, -50.0, "call", Some(1.0), Some(0.5)), + row("btc", exp, 0.0, "call", Some(1.0), Some(0.5)), + row("btc", exp, 100.0, "call", Some(1.0), Some(0.5)), + ]; + let out = assemble_chains(rows, now); + let chain = &out[&Asset::Btc][&exp]; + assert_eq!(chain.legs.len(), 1, "only the K=100 row should survive"); + assert_eq!(chain.legs[0].strike, 100.0); + } + + #[test] + fn assemble_folds_call_and_put_into_one_leg() { + let now = datetime!(2026-05-25 00:00:00 UTC); + let exp = now + time::Duration::days(7); + let rows = vec![ + row("btc", exp, 100.0, "call", Some(5.0), Some(0.5)), + row("btc", exp, 100.0, "put", Some(4.0), Some(0.55)), + ]; + let out = assemble_chains(rows, now); + let chain = &out[&Asset::Btc][&exp]; + assert_eq!(chain.legs.len(), 1); + assert_eq!(chain.legs[0].strike, 100.0); + assert_eq!(chain.legs[0].call_mid_usd, Some(5.0)); + assert_eq!(chain.legs[0].put_mid_usd, Some(4.0)); + assert_eq!(chain.legs[0].call_iv, Some(0.5)); + assert_eq!(chain.legs[0].put_iv, Some(0.55)); + } + + #[test] + fn assemble_isolates_btc_and_eth() { + let now = datetime!(2026-05-25 00:00:00 UTC); + let exp = now + time::Duration::days(7); + let rows = vec![ + row("btc", exp, 100_000.0, "call", Some(5.0), Some(0.5)), + row("eth", exp, 3_000.0, "call", Some(2.0), Some(0.6)), + ]; + let out = assemble_chains(rows, now); + assert_eq!(out.len(), 2); + assert_eq!(out[&Asset::Btc][&exp].legs[0].strike, 100_000.0); + assert_eq!(out[&Asset::Eth][&exp].legs[0].strike, 3_000.0); + } + + #[test] + fn assemble_isolates_expiries_within_an_asset() { + let now = datetime!(2026-05-25 00:00:00 UTC); + let near = now + time::Duration::days(7); + let far = now + time::Duration::days(40); + let rows = vec![ + row("btc", near, 100.0, "call", Some(1.0), Some(0.5)), + row("btc", far, 100.0, "call", Some(2.0), Some(0.55)), + ]; + let out = assemble_chains(rows, now); + let by_expiry = &out[&Asset::Btc]; + assert_eq!(by_expiry.len(), 2); + assert!((by_expiry[&near].time_to_expiry.0 - 7.0 / 365.0).abs() < 1e-12); + assert!((by_expiry[&far].time_to_expiry.0 - 40.0 / 365.0).abs() < 1e-12); + } + + #[test] + fn assemble_filters_non_finite_iv_and_mid() { + let now = datetime!(2026-05-25 00:00:00 UTC); + let exp = now + time::Duration::days(7); + let rows = vec![ + row( + "btc", + exp, + 100.0, + "call", + Some(f64::NAN), + Some(f64::INFINITY), + ), + row("btc", exp, 100.0, "put", Some(2.0), Some(0.5)), + ]; + let out = assemble_chains(rows, now); + let leg = &out[&Asset::Btc][&exp].legs[0]; + assert_eq!(leg.call_mid_usd, None, "NaN mid filtered to None"); + assert_eq!(leg.call_iv, None, "Inf iv filtered to None"); + assert_eq!(leg.put_mid_usd, Some(2.0)); + assert_eq!(leg.put_iv, Some(0.5)); + } + + #[test] + fn assemble_uses_passed_now_for_time_to_expiry() { + let now = datetime!(2026-01-01 00:00:00 UTC); + let exp = datetime!(2026-01-31 00:00:00 UTC); + let rows = vec![row("btc", exp, 100.0, "call", Some(1.0), Some(0.5))]; + let out = assemble_chains(rows, now); + let tt = out[&Asset::Btc][&exp].time_to_expiry.0; + assert!((tt - 30.0 / 365.0).abs() < 1e-12); + } + + #[test] + fn assemble_empty_input_yields_empty_map() { + let now = datetime!(2026-05-25 00:00:00 UTC); + let out = assemble_chains(Vec::new(), now); + assert!(out.is_empty()); + } } diff --git a/crates/engine/src/sinks.rs b/crates/engine/src/sinks.rs index cd0bc87..abdee1f 100644 --- a/crates/engine/src/sinks.rs +++ b/crates/engine/src/sinks.rs @@ -229,3 +229,106 @@ fn leg_envelope(s: &Strip) -> serde_json::Value { "quotes": quotes, }) } + +#[cfg(test)] +#[allow(clippy::float_cmp)] +mod tests { + use super::*; + use time::macros::datetime; + use volx_shared_types::ids::IndexId; + use volx_shared_types::index::{IndexValue, StripHash}; + use volx_shared_types::strip::StripQuote; + use volx_shared_types::units::Years; + + #[allow(clippy::cast_precision_loss)] // tiny n_points in fixtures (≤ 32) + fn fixture_strip(forward: f64, k_zero: f64, t_y: f64, n_points: usize) -> Strip { + let mut quotes = Vec::with_capacity(n_points); + let step = forward * 0.01; + for i in 0..n_points { + let k = forward - step * (n_points as f64 / 2.0) + step * i as f64; + quotes.push(StripQuote { + strike: k, + q_usd: 1.0 + i as f64 * 0.01, + iv: 0.5 + i as f64 * 0.001, + }); + } + Strip { + forward, + k_zero, + time_to_expiry: Years(t_y), + quotes, + } + } + + fn fixture_index_value() -> IndexValue { + IndexValue { + index_id: IndexId::Bvol, + value: 42.5, + confidence: 0.95, + strip_hash: StripHash([7u8; 32]), + ts: datetime!(2026-05-27 12:00:00 UTC), + } + } + + #[test] + fn leg_envelope_pins_field_names_and_quotes_triple_shape() { + let strip = fixture_strip(100.0, 99.5, 30.0 / 365.0, 4); + let env = leg_envelope(&strip); + + assert_eq!(env["forward"], 100.0); + assert_eq!(env["k_zero"], 99.5); + assert!((env["time_to_expiry_y"].as_f64().unwrap() - 30.0 / 365.0).abs() < 1e-12); + + let quotes = env["quotes"].as_array().unwrap(); + assert_eq!(quotes.len(), 4); + // Each entry is a JSON array (not object) of length 3 in [K, Q, iv] + // order — the public-API contract. + for (i, q) in quotes.iter().enumerate() { + let arr = q.as_array().unwrap(); + assert_eq!(arr.len(), 3, "entry {i} should be [K, Q, iv] triple"); + assert_eq!(arr[0], strip.quotes[i].strike); + assert_eq!(arr[1], strip.quotes[i].q_usd); + assert_eq!(arr[2], strip.quotes[i].iv); + } + } + + #[test] + fn strip_envelope_wraps_two_legs_with_top_level_id_and_ts() { + let near = fixture_strip(100.0, 99.5, 7.0 / 365.0, 3); + let next = fixture_strip(100.0, 99.5, 40.0 / 365.0, 3); + let iv = fixture_index_value(); + + let env = strip_envelope(&iv, &near, &next); + + assert_eq!(env["index_id"], "BVOL"); + // `ts` is currently serialized via `time::OffsetDateTime`'s default + // serializer (numeric array), not the IndexValue's `rfc3339` + // string form — tracked as #73. Document the current broken + // shape explicitly so the bug-fix PR can flip + // `is_array()` → `is_string()` in one line. + assert!( + env["ts"].is_array() || env["ts"].is_string(), + "ts must be numeric-array (current bug #73) or RFC 3339 string (post-#73); got {:?}", + env["ts"] + ); + + // Both legs present with leg_envelope shape inherited. + assert_eq!(env["near"]["forward"], 100.0); + assert_eq!(env["next"]["forward"], 100.0); + assert!((env["near"]["time_to_expiry_y"].as_f64().unwrap() - 7.0 / 365.0).abs() < 1e-12); + assert!((env["next"]["time_to_expiry_y"].as_f64().unwrap() - 40.0 / 365.0).abs() < 1e-12); + + // Near + next are distinct envelopes (no aliasing). + assert_ne!(env["near"], env["next"]); + } + + #[test] + fn strip_envelope_preserves_quote_count_per_leg() { + let near = fixture_strip(100.0, 99.5, 7.0 / 365.0, 5); + let next = fixture_strip(100.0, 99.5, 40.0 / 365.0, 7); + let iv = fixture_index_value(); + let env = strip_envelope(&iv, &near, &next); + assert_eq!(env["near"]["quotes"].as_array().unwrap().len(), 5); + assert_eq!(env["next"]["quotes"].as_array().unwrap().len(), 7); + } +} diff --git a/crates/engine/src/strip.rs b/crates/engine/src/strip.rs index 5f2f493..733480d 100644 --- a/crates/engine/src/strip.rs +++ b/crates/engine/src/strip.rs @@ -540,6 +540,230 @@ mod tests { assert_eq!(pick_iv(&leg), None); } + #[test] + fn irregular_strike_spacing_still_builds() { + // Asymmetric / non-uniform listed strikes around F=100. The + // dense grid runs linearly between K_min and K_max regardless + // of the input spacing — the spline is what handles the + // irregularity. Build must succeed and the dense grid must + // still be monotonic. + let t = 0.25; + let iv = 0.5; + let strikes = [50.0, 70.0, 85.0, 95.0, 100.0, 105.0, 130.0, 200.0]; + let legs = strikes + .iter() + .map(|&k| ChainLeg { + strike: k, + call_mid_usd: Some(call_price(100.0, k, t, 0.0, iv)), + put_mid_usd: Some(put_price(100.0, k, t, 0.0, iv)), + call_iv: Some(iv), + put_iv: Some(iv), + }) + .collect(); + let chain = ExpiryChain { + time_to_expiry: Years(t), + legs, + }; + let strip = build_strip(&chain).unwrap(); + assert_eq!(strip.quotes.len(), DENSE_GRID_POINTS); + for w in strip.quotes.windows(2) { + assert!(w[1].strike > w[0].strike); + } + // Forward should still recover near 100 — picker tie-breaks on + // smallest |C − P|, which is the K=100 ATM leg. + assert!((strip.forward - 100.0).abs() < 1e-9, "F={}", strip.forward); + } + + #[test] + fn very_wide_strike_range_builds_with_dense_grid_spanning_full_range() { + // K spans an order of magnitude (10 → 1000) around F=100. Strip + // builder should still produce a 801-point grid covering the + // whole listed range. + let t = 0.25; + let iv = 0.5; + let strikes = [10.0, 30.0, 80.0, 100.0, 120.0, 300.0, 1000.0]; + let legs = strikes + .iter() + .map(|&k| ChainLeg { + strike: k, + call_mid_usd: Some(call_price(100.0, k, t, 0.0, iv)), + put_mid_usd: Some(put_price(100.0, k, t, 0.0, iv)), + call_iv: Some(iv), + put_iv: Some(iv), + }) + .collect(); + let chain = ExpiryChain { + time_to_expiry: Years(t), + legs, + }; + let strip = build_strip(&chain).unwrap(); + assert_eq!(strip.quotes.len(), DENSE_GRID_POINTS); + assert!((strip.quotes.first().unwrap().strike - 10.0).abs() < 1e-9); + assert!((strip.quotes.last().unwrap().strike - 1000.0).abs() < 1e-9); + } + + #[test] + fn lower_wing_put_fallback_upper_wing_call_primary_still_build() { + // Top of the grid has only call IVs (no put), bottom has only + // put IVs. As long as ≥ MIN_STRIP_QUOTES strikes carry a + // usable IV (call OR put) and at least one strike has both + // legs quoted for the forward picker, the build succeeds. + let t = 0.25; + let iv = 0.5; + let f = 100.0; + // ATM legs with both sides quoted — gives the forward picker. + let mut legs = vec![ChainLeg { + strike: f, + call_mid_usd: Some(call_price(f, f, t, 0.0, iv)), + put_mid_usd: Some(put_price(f, f, t, 0.0, iv)), + call_iv: Some(iv), + put_iv: Some(iv), + }]; + // Lower wing — put-side only IV (call is gone). + for k in [80.0, 85.0, 90.0, 95.0] { + legs.push(ChainLeg { + strike: k, + call_mid_usd: None, + put_mid_usd: None, + call_iv: None, + put_iv: Some(iv), + }); + } + // Upper wing — call-side only IV. + for k in [105.0, 110.0, 115.0, 120.0] { + legs.push(ChainLeg { + strike: k, + call_mid_usd: None, + put_mid_usd: None, + call_iv: Some(iv), + put_iv: None, + }); + } + let chain = ExpiryChain { + time_to_expiry: Years(t), + legs, + }; + let strip = build_strip(&chain).unwrap(); + assert!((strip.forward - f).abs() < 1e-9); + assert_eq!(strip.quotes.len(), DENSE_GRID_POINTS); + } + + #[test] + fn upper_wing_put_fallback_still_build() { + // Mirror of the previous test: upper-wing legs carry put-side + // IV only (call_iv = None), exercising `pick_iv`'s put fallback + // on strikes above the forward. Without this case, the + // companion test only exercises the fallback on the lower wing. + let t = 0.25; + let iv = 0.5; + let f = 100.0; + let mut legs = vec![ChainLeg { + strike: f, + call_mid_usd: Some(call_price(f, f, t, 0.0, iv)), + put_mid_usd: Some(put_price(f, f, t, 0.0, iv)), + call_iv: Some(iv), + put_iv: Some(iv), + }]; + for k in [80.0, 85.0, 90.0, 95.0] { + legs.push(ChainLeg { + strike: k, + call_mid_usd: None, + put_mid_usd: None, + call_iv: Some(iv), + put_iv: None, + }); + } + // Upper wing — put-side IV only. `pick_iv` should fall back. + for k in [105.0, 110.0, 115.0, 120.0] { + legs.push(ChainLeg { + strike: k, + call_mid_usd: None, + put_mid_usd: None, + call_iv: None, + put_iv: Some(iv), + }); + } + let chain = ExpiryChain { + time_to_expiry: Years(t), + legs, + }; + let strip = build_strip(&chain).unwrap(); + assert!((strip.forward - f).abs() < 1e-9); + assert_eq!(strip.quotes.len(), DENSE_GRID_POINTS); + } + + #[test] + fn rejects_when_forward_is_outside_listed_strike_range() { + // Construct a chain where every strike sits well below the + // implied forward — picker yields F outside [K_min, K_max] → + // §4.3 step 3 rejects rather than extrapolating. + let t = 0.25; + let iv = 0.5; + // K's all in [10, 50]; the only two-sided leg pins F = 10 + 59 = 69 + // via a large C − P bias to push F above K_max. If a future + // maintainer extends `legs` with K > 69 the test would silently + // pass for the wrong reason, so anchor K_max here. + let legs = vec![ + ChainLeg { + strike: 10.0, + call_mid_usd: Some(60.0), + put_mid_usd: Some(1.0), + call_iv: Some(iv), + put_iv: Some(iv), + }, + ChainLeg { + strike: 20.0, + call_mid_usd: None, + put_mid_usd: None, + call_iv: Some(iv), + put_iv: Some(iv), + }, + ChainLeg { + strike: 30.0, + call_mid_usd: None, + put_mid_usd: None, + call_iv: Some(iv), + put_iv: Some(iv), + }, + ChainLeg { + strike: 40.0, + call_mid_usd: None, + put_mid_usd: None, + call_iv: Some(iv), + put_iv: Some(iv), + }, + ChainLeg { + strike: 50.0, + call_mid_usd: None, + put_mid_usd: None, + call_iv: Some(iv), + put_iv: Some(iv), + }, + ]; + // Anchor the K_max invariant so a future maintainer adding a + // leg above K=50 fails this test loudly instead of silently + // exiting via a different `BuildError` variant. + let k_max_fixture = legs + .iter() + .map(|l| l.strike) + .fold(f64::NEG_INFINITY, f64::max); + assert!( + (k_max_fixture - 50.0).abs() < 1e-12, + "fixture K_max drifted: {k_max_fixture}" + ); + + let chain = ExpiryChain { + time_to_expiry: Years(t), + legs, + }; + match build_strip(&chain) { + Err(BuildError::ForwardOutsideStrikeRange { forward, .. }) => { + assert!(forward > 50.0, "F={forward} should overshoot K_max=50"); + } + other => panic!("expected ForwardOutsideStrikeRange, got {other:?}"), + } + } + #[test] fn put_call_parity_holds_for_strip_split_point() { // At K = F, C − P = 0 (r = 0). Average then equals each leg. diff --git a/tarpaulin.toml b/tarpaulin.toml new file mode 100644 index 0000000..0cb8844 --- /dev/null +++ b/tarpaulin.toml @@ -0,0 +1,39 @@ +# Coverage config for `cargo tarpaulin` (issue #21). +# +# Run via: `cargo tarpaulin` (picks up the `default` profile below). +# +# The 80 % coverage gate from #21 applies to engine *numerics* — strip +# builder, variance integral, interpolation, BS pricer, spline, snapshot +# orchestrator, and the chain assembler. +# +# Two files are excluded from the denominator: +# +# - `engine/src/main.rs` — binary entry, exercised by `scripts/e2e-smoke.sh` +# - `engine/src/sinks.rs` — `IndexSinks::publish` is async ClickHouse + +# Redis I/O. The pure helpers (`strip_envelope`, `leg_envelope`) are +# unit-tested; the I/O wrapping needs a real connection, exercised by +# the e2e smoke. The whole file is excluded for simplicity; the +# `tests` module inside still runs and counts toward the test count +# even though the file is omitted from coverage. +# +# `chain::fetch_chains` is *not* excluded — its async body is small and +# its ~25 uncovered lines are visible in the per-file breakdown. +# +# Current numerics coverage: 86.5 % (351/406 lines, 2026-05-27). + +[default] +packages = ["volx-engine"] +exclude-files = [ + "crates/engine/src/main.rs", + "crates/engine/src/sinks.rs", + # `packages = ["volx-engine"]` does NOT filter the coverage scan — + # any file reached through engine's dep graph (normalizer types, + # shared-types defs) gets counted unless excluded. Drop them too; + # those crates have their own coverage targets. + "crates/normalizer/src/*", + "crates/normalizer/src/**/*", + "crates/shared-types/src/*", +] +timeout = "120s" +engine = "Llvm" +out = ["Stdout"]