From c951bb973146560dfdeb9d3c876844acb705db77 Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 27 May 2026 18:47:25 +0530 Subject: [PATCH 1/4] test(engine): chain assembler + comprehensive folding tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the row → AssetChains folding from `fetch_chains` into a pure helper `assemble_chains(rows, now)`. The I/O surface (the ClickHouse `argMax(...) GROUP BY ...` query) keeps its boundary but the row-folding logic — unknown asset/kind drop, finite-strike filter, call+put leg merge, per-expiry year-fraction — is now reachable from unit tests without a database. Nine new tests cover: - unknown asset dropped (warn + skip) - unknown kind dropped (warn + skip) - non-finite or non-positive strike dropped (NaN, ∞, 0, negative) - call + put for same strike fold into one ChainLeg - BTC and ETH chains isolated under the outer map - multiple expiries within an asset isolated under the inner map - non-finite mid + iv get filtered to `None` - `now` parameter drives the year-fraction computation - empty input yields empty map Engine coverage on `chain.rs` jumps from 19 % (13/67) to 62 % (43/69); the remaining 26 lines are the async query path which the e2e smoke exercises end-to-end. --- crates/engine/src/chain.rs | 161 ++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/chain.rs b/crates/engine/src/chain.rs index 43b31ce..2a37a4e 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,153 @@ 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()); + } } From 4714d0f56a63345a18d12f1a9fa998578822d2eb Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 27 May 2026 18:47:41 +0530 Subject: [PATCH 2/4] test(engine): strip edge cases + sinks envelope shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip builder — four new tests covering the regimes called out in #21: - irregular strike spacing (asymmetric, non-uniform) still produces a monotonic 801-point dense grid - very wide strike range (K spans an order of magnitude) covers the full listed range end-to-end - single-side wings (call-only above F, put-only below F) build via the IV fallback path - forward outside listed strike range rejects (the §4.3 step 3 guard against extrapolation) Sinks envelopes — three new tests pin the JSON wire shape for the `/v1/options/strip` endpoint that the Go API and methodology page consume: - `leg_envelope` field names and the `[K, Q, iv]` triple ordering - `strip_envelope` top-level `index_id` + `ts` + `near` + `next` - per-leg quote counts preserved (no truncation / aliasing between near and next) Bug found while writing the sinks tests: `strip_envelope` emits `ts` via `OffsetDateTime`'s default Serialize (numeric array) instead of the RFC 3339 form used everywhere else IndexValue is serialized. Filed as #73; the test pins "ts present in some form" so the fix-PR has a flip target. --- crates/engine/src/sinks.rs | 102 +++++++++++++++++++++++ crates/engine/src/strip.rs | 166 +++++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+) diff --git a/crates/engine/src/sinks.rs b/crates/engine/src/sinks.rs index cd0bc87..67e6fc2 100644 --- a/crates/engine/src/sinks.rs +++ b/crates/engine/src/sinks.rs @@ -229,3 +229,105 @@ 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` form + // — tracked as #73. The bug-fix PR flips this assertion to a + // proper string check. + assert!(!env["ts"].is_null(), "ts must be emitted in some form"); + + // 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..5ba6f7d 100644 --- a/crates/engine/src/strip.rs +++ b/crates/engine/src/strip.rs @@ -540,6 +540,172 @@ 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 single_side_wings_still_build_via_iv_fallback() { + // 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 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 below 50; the only two-sided leg pins F via a large + // C − P bias to push F above K_max. + 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), + }, + ]; + 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. From 035157191e356e06c9ef1697274e5e619b566860 Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 27 May 2026 18:48:23 +0530 Subject: [PATCH 3/4] chore(engine): tarpaulin profile for the #21 coverage gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo tarpaulin` (no args) now reads `tarpaulin.toml` and reports engine-only numerics coverage. The async I/O wrappers (`chain::fetch_chains`, `sinks::IndexSinks::publish`) and the binary entry (`engine/src/main.rs`) are excluded — they are exercised by the e2e smoke (`scripts/e2e-smoke.sh`, gated by CI on every PR), not by unit tests. Counting them against the unit-test target would push us toward brittle mocked-client tests that don't catch real-world regressions. Current numerics coverage: 86.5 % (351/406 lines), passing #21's 80 % gate. --- tarpaulin.toml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tarpaulin.toml diff --git a/tarpaulin.toml b/tarpaulin.toml new file mode 100644 index 0000000..2ce49f6 --- /dev/null +++ b/tarpaulin.toml @@ -0,0 +1,27 @@ +# 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. The async I/O wrappers (`chain::fetch_chains`, +# `sinks::IndexSinks::publish`) and the binary entrypoint +# (`engine/src/main.rs`) are exercised by the end-to-end smoke +# (`scripts/e2e-smoke.sh`, CI-gated). Counting them against the +# unit-test coverage target encourages brittle mocked-client tests that +# don't catch real-world regressions — drop them from the denominator +# instead. +# +# 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", + "crates/normalizer/src/*", + "crates/shared-types/src/*", +] +timeout = "120s" +engine = "Llvm" +out = ["Stdout"] From 0a14d77314937f3e7cfcf6c10e35c82ba45b8027 Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 27 May 2026 19:00:39 +0530 Subject: [PATCH 4/4] fix(engine): address review on engine tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH-1: `cargo fmt` run, two formatting drifts (chain.rs row() args, sinks.rs assert! lines) now match rustfmt. MED-1: tarpaulin.toml comment corrected — `chain::fetch_chains` is NOT excluded from the denominator (only main.rs + sinks.rs are). Reviewer caught the stale claim; the actual exclude list now matches the prose. MED-2: `single_side_wings_still_build_via_iv_fallback` renamed to `lower_wing_put_fallback_upper_wing_call_primary_still_build` to match what it actually exercises (only the lower wing tests the put-side fallback). Added the complementary test `upper_wing_put_fallback_still_build` so both directions of the fallback are now covered. MED-4: `rejects_when_forward_is_outside_listed_strike_range` now asserts the K_max=50 invariant up-front so a future maintainer adding a leg above K=50 fails this test loudly instead of exiting via a different `BuildError` variant. LOW-1: sinks.rs `ts` assertion tightened from `!is_null()` to `is_array() || is_string()`. Documents the current broken numeric-array shape (#73) and the post-#73 RFC 3339 string form explicitly, so the bug-fix PR flips the assertion in one line. LOW-2 rejected — `packages = ["volx-engine"]` does NOT filter the coverage scan; tarpaulin walks the engine dep graph and counts normalizer/shared-types lines unless excluded. Re-added those exclude-files entries with a comment. MED-3 not actionable — `assert_eq!(chain.legs.len(), 1)` is already present at chain.rs:323 in the assemble_drops_unknown_kind test; reviewer missed it. MED-5 + LOW-3 + LOW-4 skipped per the project's existing test-style convention (bare `unwrap()` in tests, hand-rolled fixture math). --- crates/engine/src/chain.rs | 9 +++++- crates/engine/src/sinks.rs | 21 +++++++------ crates/engine/src/strip.rs | 64 ++++++++++++++++++++++++++++++++++++-- tarpaulin.toml | 26 +++++++++++----- 4 files changed, 99 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/chain.rs b/crates/engine/src/chain.rs index 2a37a4e..698ebec 100644 --- a/crates/engine/src/chain.rs +++ b/crates/engine/src/chain.rs @@ -395,7 +395,14 @@ mod tests { 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, + "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); diff --git a/crates/engine/src/sinks.rs b/crates/engine/src/sinks.rs index 67e6fc2..abdee1f 100644 --- a/crates/engine/src/sinks.rs +++ b/crates/engine/src/sinks.rs @@ -302,20 +302,21 @@ mod tests { assert_eq!(env["index_id"], "BVOL"); // `ts` is currently serialized via `time::OffsetDateTime`'s default - // serializer (numeric array), not the IndexValue's `rfc3339` form - // — tracked as #73. The bug-fix PR flips this assertion to a - // proper string check. - assert!(!env["ts"].is_null(), "ts must be emitted in some form"); + // 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 - ); + 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"]); diff --git a/crates/engine/src/strip.rs b/crates/engine/src/strip.rs index 5ba6f7d..733480d 100644 --- a/crates/engine/src/strip.rs +++ b/crates/engine/src/strip.rs @@ -603,7 +603,7 @@ mod tests { } #[test] - fn single_side_wings_still_build_via_iv_fallback() { + 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 @@ -648,6 +648,50 @@ mod tests { 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 @@ -655,8 +699,10 @@ mod tests { // §4.3 step 3 rejects rather than extrapolating. let t = 0.25; let iv = 0.5; - // K's all below 50; the only two-sided leg pins F via a large - // C − P bias to push F above K_max. + // 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, @@ -694,6 +740,18 @@ mod tests { 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, diff --git a/tarpaulin.toml b/tarpaulin.toml index 2ce49f6..0cb8844 100644 --- a/tarpaulin.toml +++ b/tarpaulin.toml @@ -4,13 +4,20 @@ # # The 80 % coverage gate from #21 applies to engine *numerics* — strip # builder, variance integral, interpolation, BS pricer, spline, snapshot -# orchestrator. The async I/O wrappers (`chain::fetch_chains`, -# `sinks::IndexSinks::publish`) and the binary entrypoint -# (`engine/src/main.rs`) are exercised by the end-to-end smoke -# (`scripts/e2e-smoke.sh`, CI-gated). Counting them against the -# unit-test coverage target encourages brittle mocked-client tests that -# don't catch real-world regressions — drop them from the denominator -# instead. +# 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). @@ -19,7 +26,12 @@ 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"