Skip to content

Hedge advisor: match basket overrides on (code, currency) only#95

Merged
sujitn merged 7 commits into
mainfrom
hedge-advisor/match-on-code-currency
May 10, 2026
Merged

Hedge advisor: match basket overrides on (code, currency) only#95
sujitn merged 7 commits into
mainfrom
hedge-advisor/match-on-code-currency

Conversation

@sujitn

@sujitn sujitn commented May 10, 2026

Copy link
Copy Markdown
Owner

Summary

Two correctness fixes plus one cleanup in the hedge advisor's strategy layer.

1. Match basket overrides on (code, currency) only

Override matcher in lookup_basket_override keyed on (contract_code, currency, delivery_months) with delivery_months hardcoded to 3. Only matched when settlement fell at the start of a quarter; for any mid-quarter settle the actual front-month is 1 or 2 months out, so well-formed basket overrides silently fell through to the synthetic at-current-coupon CTD.

Match now keys on (contract_code, currency). delivery_months is a tiebreaker among multiple matches (prefer DEFAULT_DELIVERY_MONTHS, else smallest). Single-match cases — back-month-only, and mid-quarter front-months — now adopt the override.

back_month_only_override_falls_back_to_synthetic test flipped and renamed back_month_only_override_is_used.

2. Pick swap tenor by KRD projection, not MD bucket

pick_swap_tenor bucketed modified duration into a fixed liquid set with hard < thresholds. Two failure modes: (a) MD is the wrong signal at the book level when KRD has structure — a book with MD 7.5y but KRD concentrated at 10Y was hedged with a 5Y swap that did nothing for the actual exposure; (b) the < 7.5 boundary mapped near-7.5 MDs to 5Y nondeterministically depending on the seventh decimal place of float math.

Replaced with a single-leg KRD projection: for each candidate liquid tenor, build a unit par swap, compute its risk on the position's KRD ladder, scale to neutralize parallel DV01, measure residual L2 norm; pick the tenor that minimizes residual, tie-break to longer. MD-based snap is the fallback only when the position has no usable KRD ladder.

Shared by interest_rate_swap and cash_bond_pair. Existing strategy tests that asserted tenor = 10Y keep passing because the long_10y_corporate fixture's KRD already points to 10Y.

3. Drive-by: demote two crate-internal helpers

approximate_cme_cf and deliverable_to_bond were pub-exported through three layers but had no consumer outside convex-analytics — only used by make_default_future and unit tests. Demoted to pub(crate) and dropped from re-exports in hedging/mod.rs, risk/mod.rs, and the convex facade.

4. Workspace version bump

0.12.10.13.0.

Test plan

  • cargo test --package convex-analytics --lib risk::hedging — 106/106 pass (was 103; +3 new picker tests)
  • cargo build --workspace --release — clean, no warnings
  • Smoke-check on a mid-quarter RX override: pass delivery_months: 1 and confirm the supplied basket flows through to bond_future_risk rather than the synthetic Aug-2036 CTD
  • Smoke-check on a 7.5y-MD / 10Y-KRD-peak book: confirm propose_hedges IRS leg picks 10Y

Summary by CodeRabbit

  • Chores

    • Version bumped to 0.13.0
  • Refactor

    • Enhanced swap tenor selection with improved KRD-based tenor matching
    • Refined basket override matching for synthetic bond futures with better contract-code selection logic
  • Tests

    • Added unit tests validating NaN handling, KRD-based selection, and tenor snapping scenarios

Review Change Stack

sujitn added 3 commits May 10, 2026 15:15
The override matcher was keyed on (contract_code, currency,
delivery_months) with delivery_months hardcoded to 3. This only matched
when settlement fell at the start of a quarter; for any mid-quarter
settle the actual front-month is 1 or 2 months out, so well-formed
overrides silently fell through to the synthetic at-current-coupon CTD.

Match on (contract_code, currency) only. delivery_months becomes a
tiebreaker among multiple matches: prefer DEFAULT_DELIVERY_MONTHS,
else smallest. Single-match cases — including back-month-only and
mid-quarter front-months — now adopt the override.

Test back_month_only_override_falls_back_to_synthetic flips and is
renamed back_month_only_override_is_used.
Both helpers were pub-exported through hedging::, risk::, and the
convex facade, but no caller outside convex-analytics imports them —
they're only used by make_default_future and the unit tests. Drop
the cross-crate exports and tighten the function visibility to
pub(crate).
@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR bumps the version to 0.13.0, restricts two internal helper functions to crate-private visibility, removes their public re-exports across module boundaries, and refactors basket override matching to accept back-month-only overrides by matching on (contract_code, currency) with delivery_months as a secondary tiebreaker.

Changes

API Visibility and Hedging Logic

Layer / File(s) Summary
Version Bump
Cargo.toml
Workspace package version incremented from 0.12.1 to 0.13.0.
Visibility Restriction
crates/convex-analytics/src/risk/hedging/ctd.rs
Functions deliverable_to_bond and approximate_cme_cf changed from pub to pub(crate) to restrict visibility to crate scope.
Re-export Propagation
crates/convex-analytics/src/risk/hedging/mod.rs, crates/convex-analytics/src/risk/mod.rs, crates/convex/src/lib.rs
Removed public re-exports of deliverable_to_bond and approximate_cme_cf across hedging module, risk module, and convex crate facades.
Basket Override Matching Refactor
crates/convex-analytics/src/risk/hedging/strategies.rs
Refactored lookup_basket_override to match overrides by (contract_code, currency) only, with delivery_months used as a tiebreaker (prefer DEFAULT_DELIVERY_MONTHS, otherwise select smallest); updated make_default_future to use new matching behavior.
Swap Tenor Selection Refactor
crates/convex-analytics/src/risk/hedging/strategies.rs
Replaced MD-bucketing pick_swap_tenor with a tenor-grid selection that projects KRD across candidate tenors to minimize residual KRD L2 norm; updated call sites to pass curve and settlement.
Tests / Documentation
crates/convex-analytics/src/risk/hedging/strategies.rs
Updated and extended unit tests for tenor selection NaN handling, KRD-driven choice, MD-snap fallback, snapping behavior, and modified back-month override test to assert adoption and basket retention.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • sujitn/convex#85: Prior PR that introduced basket_overrides and related lookup logic; this PR refactors the matching behavior and test expectations.

Poem

🐰 A version leap, from point-one-two to point-three-oh,
Helpers step back into shadow, their public glow,
Basket matches learn to bend, no strict delivery chains,
Back-month overrides flow through now, where logic reigns.
thumpety-thump goes the refactor drum! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Hedge advisor: match basket overrides on (code, currency) only' directly describes the main change: updating basket override matching logic to key on (contract_code, currency) instead of the previous 3-tuple, which is the primary focus of the changeset.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hedge-advisor/match-on-code-currency

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 7 files

sujitn added 4 commits May 10, 2026 15:48
The auto-tenor heuristic in pick_swap_tenor bucketed modified duration
into a fixed liquid set with hard thresholds. Two failure modes:

1. MD is the wrong signal at the book level when KRD has structure.
   A book with weighted-average MD 7.5y but KRD concentrated at 10Y
   was hedged with a 5Y swap that did nothing for the actual exposure.
2. Float boundary at d < 7.5 mapped near-7.5 MDs to the lower bucket
   nondeterministically depending on the seventh decimal place.

Replace with a single-leg KRD projection: for each candidate liquid
tenor, build a unit par swap, compute its risk on the position's KRD
ladder, scale to neutralize parallel DV01, and measure residual L2
norm. Pick the tenor that minimizes residual; tie-break to longer.
Fall back to MD-based snap (with longer-on-tie) only when the position
has no usable KRD ladder.

Both interest_rate_swap and cash_bond_pair share the picker. Existing
strategy tests that assert tenor=10Y (long_10y_corporate fixture) keep
passing because projection picks the same answer when KRD already
points there.

Three new tests:
- pick_swap_tenor_uses_krd_projection_not_md_bucket: book MD 7.5,
  KRD heavy at 10Y -> 10Y (was 5Y under MD-bucketing).
- pick_swap_tenor_falls_back_to_md_snap_when_ladder_empty: empty
  ladder, MD 7.5 -> 10Y (round-up tie at midpoint).
- snap_to_liquid_swap_tenor_round_up_at_midpoints: 3.5/7.5/15/25 all
  round to the longer tenor.

NaN-MD test signature updated for the new function shape.
- cargo fmt --all (re-flowed two long re-export lists)
- ctd module doc referenced approximate_cme_cf which is now pub(crate);
  rephrased as crate-internal context to drop the broken intra-doc link

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/convex-analytics/src/risk/hedging/strategies.rs (1)

733-738: ⚡ Quick win

Add regression coverage for the remaining tiebreak branches.

The new selector still has two untested branches here: preferring DEFAULT_DELIVERY_MONTHS over a smaller month like 1, and picking the smallest month when no default 3 exists (for example 6 vs 9). One focused unit test for each would pin down the new matching contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/convex-analytics/src/risk/hedging/strategies.rs` around lines 733 -
738, Add two unit tests that exercise the untested branches around the `chosen`
selection: (1) a test where the input `matches` contains an entry with
delivery_months == DEFAULT_DELIVERY_MONTHS and another with a smaller month
(e.g. 1) and assert the selector returns the DEFAULT_DELIVERY_MONTHS entry; (2)
a test where no entry has delivery_months == DEFAULT_DELIVERY_MONTHS (e.g.
entries 6 and 9) and assert the selector returns the entry with the smallest
delivery_months (6). Locate the selection logic that assigns to the `chosen`
variable and invoke that function/path with appropriately constructed match
structs (setting their `delivery_months` field) to verify each branch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/convex-analytics/src/risk/hedging/strategies.rs`:
- Around line 835-907: The code currently treats a ladder with any
NaN/non-finite bucket as usable, which later yields NaN l2 and can silently pick
a bad tenor; update the KRD usability check so usable_krd requires all
position.key_rate_buckets have finite partial_dv01 (and tenor_years if relevant)
before proceeding, and if any bucket value is non-finite return the
modified-duration fallback via
snap_to_liquid_swap_tenor(position.modified_duration_years); adjust the check
that builds usable_krd (and/or short-circuit before the candidate loop) rather
than trying to sanitize values inside the l2 computation (references:
position.key_rate_buckets, usable_krd, partial_dv01, unit_risk, l2, and
snap_to_liquid_swap_tenor).

---

Nitpick comments:
In `@crates/convex-analytics/src/risk/hedging/strategies.rs`:
- Around line 733-738: Add two unit tests that exercise the untested branches
around the `chosen` selection: (1) a test where the input `matches` contains an
entry with delivery_months == DEFAULT_DELIVERY_MONTHS and another with a smaller
month (e.g. 1) and assert the selector returns the DEFAULT_DELIVERY_MONTHS
entry; (2) a test where no entry has delivery_months == DEFAULT_DELIVERY_MONTHS
(e.g. entries 6 and 9) and assert the selector returns the entry with the
smallest delivery_months (6). Locate the selection logic that assigns to the
`chosen` variable and invoke that function/path with appropriately constructed
match structs (setting their `delivery_months` field) to verify each branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05c7e5b6-3dba-4d05-9054-427854aa5cd8

📥 Commits

Reviewing files that changed from the base of the PR and between 90a5eee and 94765f9.

📒 Files selected for processing (3)
  • crates/convex-analytics/src/risk/hedging/ctd.rs
  • crates/convex-analytics/src/risk/hedging/strategies.rs
  • crates/convex/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/convex/src/lib.rs
  • crates/convex-analytics/src/risk/hedging/ctd.rs

Comment on lines +835 to +907
let krd_threshold = position.dv01.abs() * 1e-9;
let usable_krd = !position.key_rate_buckets.is_empty()
&& position
.key_rate_buckets
.iter()
.any(|b| b.partial_dv01.abs() > krd_threshold);
if !usable_krd || !position.dv01.is_finite() || position.dv01 == 0.0 {
return Ok(snap_to_liquid_swap_tenor(position.modified_duration_years));
}

let (frequency, day_count, idx) = swap_conventions_for(position.currency)?;
let tenor_grid: Vec<f64> = position
.key_rate_buckets
.iter()
.map(|b| b.tenor_years)
.collect();
let curve_t = curve_tenor_to(discount_curve, settlement);

let mut best: Option<(f64, f64)> = None;
for &candidate in LIQUID_SWAP_TENORS.iter() {
let par = RateCurveDyn::par_swap_rate(
discount_curve,
curve_t + candidate,
frequency.periods_per_year(),
)
.map_err(|e| AnalyticsError::CurveError(e.to_string()))?;
let unit = InterestRateSwap {
tenor_years: candidate,
fixed_rate_decimal: par,
fixed_frequency: frequency,
fixed_day_count: day_count,
floating_index: idx.into(),
// Direction is irrelevant; sign cancels in DV01-neutral sizing.
side: SwapSide::PayFixed,
notional: Decimal::ONE,
currency: position.currency,
};
let unit_risk = interest_rate_swap_risk(
&unit,
discount_curve,
discount_curve_id,
settlement,
Some(&tenor_grid),
)?;
if unit_risk.dv01.abs() < 1e-12 {
continue;
}
let scale = -position.dv01 / unit_risk.dv01;
let l2_sq: f64 = position
.key_rate_buckets
.iter()
.map(|pb| {
let unit_at = unit_risk
.buckets
.iter()
.find(|ub| (ub.tenor_years - pb.tenor_years).abs() < 1e-9)
.map(|ub| ub.partial_dv01)
.unwrap_or(0.0);
let residual = pb.partial_dv01 + scale * unit_at;
residual * residual
})
.sum();
let l2 = l2_sq.sqrt();
// `<=` with ascending iteration preserves "ties → longer" without
// a separate equality branch; on a real curve ties don't occur.
if best.map_or(true, |(_, cur)| l2 <= cur) {
best = Some((candidate, l2));
}
}

Ok(best
.map(|(t, _)| t)
.unwrap_or_else(|| snap_to_liquid_swap_tenor(position.modified_duration_years)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat non-finite KRD ladders as unusable before scoring candidates.

Right now a ladder with one finite bucket and one NaN bucket skips the MD fallback, then computes l2 = NaN. Because best starts empty, the first candidate is kept and later <= comparisons against NaN never replace it, so this can silently pick an arbitrary tenor instead of falling back.

Proposed fix
 fn pick_swap_tenor(
     position: &RiskProfile,
     discount_curve: &RateCurve<DiscreteCurve>,
     discount_curve_id: &str,
     settlement: Date,
 ) -> AnalyticsResult<f64> {
     let krd_threshold = position.dv01.abs() * 1e-9;
     let usable_krd = !position.key_rate_buckets.is_empty()
+        && position
+            .key_rate_buckets
+            .iter()
+            .all(|b| b.tenor_years.is_finite() && b.partial_dv01.is_finite())
         && position
             .key_rate_buckets
             .iter()
             .any(|b| b.partial_dv01.abs() > krd_threshold);
     if !usable_krd || !position.dv01.is_finite() || position.dv01 == 0.0 {
         return Ok(snap_to_liquid_swap_tenor(position.modified_duration_years));
@@
         let l2 = l2_sq.sqrt();
+        if !l2.is_finite() {
+            continue;
+        }
         // `<=` with ascending iteration preserves "ties → longer" without
         // a separate equality branch; on a real curve ties don't occur.
         if best.map_or(true, |(_, cur)| l2 <= cur) {
             best = Some((candidate, l2));
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/convex-analytics/src/risk/hedging/strategies.rs` around lines 835 -
907, The code currently treats a ladder with any NaN/non-finite bucket as
usable, which later yields NaN l2 and can silently pick a bad tenor; update the
KRD usability check so usable_krd requires all position.key_rate_buckets have
finite partial_dv01 (and tenor_years if relevant) before proceeding, and if any
bucket value is non-finite return the modified-duration fallback via
snap_to_liquid_swap_tenor(position.modified_duration_years); adjust the check
that builds usable_krd (and/or short-circuit before the candidate loop) rather
than trying to sanitize values inside the l2 computation (references:
position.key_rate_buckets, usable_krd, partial_dv01, unit_risk, l2, and
snap_to_liquid_swap_tenor).

@sujitn
sujitn merged commit 558fd9d into main May 10, 2026
9 checks passed
@sujitn
sujitn deleted the hedge-advisor/match-on-code-currency branch May 10, 2026 16:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant