Tier 2 · Internal Risk Management

Bond & Fixed Income Pricing — OAS / Duration / Convexity Family

M-130 · lifecycle: monitoring · RAT-130-v1.0.1

Intended Use

Bond & Fixed Income Pricing — OAS / Duration / Convexity Family Price corporate, treasury, and structured bond positions with OAS, duration, and convexity for ALM, CECL, and counterparty exposure modeling.

Family-level model covering bond pricing methodology: discounting cash flows along the appropriate curve, computing option-adjusted spread (OAS), modified duration, and convexity. Two implementations: a primary production pricer (FinView L1) and an independent challenger (Gold Copy L1) used for Track A/B/C validation.


Components

Inputs, processing, outputs

data sources
DS-001 · DS-002 · DS-007 · DS-008 · DS-011 · DS-031
assumptions
A-030, A-031, A-090, A-091, A-202, A-206
engines
finview.L1.bond_pricer
goldcopy.L1.bond_pricer
contracts
bond_pricing_v1
upstream
M-137
dimensions
D2 · D4

Methodology & Mechanics

Methodology

M-130 is a first-principles fixed-rate bond pricer implemented in finlib/bond_pricer.py and bound to the engine identifier finview.L1.bond_pricer via the @engine(...) governance decorator. It is a FinView-resident engine: the code and its tests live in the FinView repo ([redacted]), not in InsModel. It is consumed cross-repo by the insurance asset/capital stack (fixed-income holdings valuation, duration/DV01 for ALM, OAS for credit-spread risk), but it is owned and versioned by FinView — see Limitation 5.

The engine takes four scalar contract parameters — face_value, coupon_rate, maturity_years, payment_frequency (default 2 = semi-annual), plus a rating tag carried only for OAS context — and exposes five computational paths:

  1. price_from_yield(ytm) — discrete present-value of the coupon stream and principal under a single flat yield. Coupon dates are generated by _payment_times() (an arange from dt to maturity in steps of 1/frequency, with the last node snapped exactly onto maturity_years). Each cash flow is discounted by 1 / (1 + y/freq)**n where n = round(t*freq) is the integer period count. Returns clean_price, dirty_price, and accrued_interest. Because the engine assumes pricing on a coupon date, accrued interest is 0 and clean == dirty.

  2. price_from_curve(dfs, tenor_years, oas_bps=0.0) — the curve-based path. It converts the input discount factors to continuously-compounded zero rates (z = -ln(DF)/t), linearly interpolates those zeros onto the bond's coupon dates via finlib.curve_builder.interpolate_curve, adds the OAS (in decimal) to every interpolated zero, rebuilds discount factors (exp(-(z+oas)*t)), and present-values the cash flows. It then back-solves a ytm from the resulting clean price. (Per a #75 guard the engine tolerates oas_bps=None for proxy bonds, treating it as zero.)

  3. compute_ytm(market_price) — inverts price_from_yield with a Brent root finder (scipy.optimize.brentq) over [-0.10, 1.0], widening to [-0.50, 2.0] on failure. Tolerance xtol=1e-12.

  4. compute_duration(ytm) — Macaulay duration as the PV-weighted average cash-flow time, modified duration as macaulay / (1 + ytm/freq), plus a finite-difference effective duration and convexity using a ±10bp yield bump: effective = (P_- − P_+)/(2·P0·dy) and convexity = (P_- + P_+ − 2·P0)/(P0·dy²).

dv01(ytm) — total DV01 as a first-class engine output (added 2026-06-04 resolving INV-015). It parallel-bumps the yield by +1bp and returns the resulting price decline (p0 − p_up, where both are clean_price from price_from_yield), reported as a positive number — consistent with the bump-based sensitivities in compute_duration. This is the engine's own bump, not the card's derived clean · mod_dur · 1e-4 identity.

  1. compute_key_rate_dv01(...) and compute_oas(market_price, ...) — key-rate DV01 bumps each standard tenor (1,2,3,5,7,10,20,30y) by 1bp with a triangular weight that decays linearly to the adjacent key tenors and reprices; OAS is the Brent root over [-500, 2000] bps that equates the curve price to a target market price. The test suite asserts the OAS solver recovers a 130bp spread to within 1bp.

There is no settlement-date or business-day calendar in the finlib engine: the day-count is implicit in the n = round(t·freq) period counting (effectively 30/360-like on whole coupon periods), and the curve uses continuous compounding on actual/actual-style year fractions. This is the principal divergence from a production calendar-aware pricer (the QuantLib challenger uses Actual365Fixed — see Validation Packet).


Key Assumptions

Key Assumptions and Their Justification

ID Assumption Value / Choice Justification
A1 Discount curve Flat 5% par-yield curve, bootstrapped to SOFR discount factors by bootstrap_sofr_curve Reproduces the flat_curve_5pct test fixture exactly; a flat curve makes the par result analytically checkable (coupon = yield ⇒ price = par).
A2 Compounding (yield path) Discrete, (1 + y/freq)**n, semi-annual Market convention for US coupon bonds; matches the compute_ytm inverse so price→yield→price round-trips to <1e-10 (asserted by test_ytm_roundtrip).
A3 Compounding (curve path) Continuous, exp(-z·t), on interpolated zero rates Standard for curve construction; introduces a small (~0.4bp of price) discrete-vs-continuous gap the test bounds at <2% (test_price_from_curve).
A4 Day-count / settlement Implicit whole-period n = round(t·freq); pricing assumed on a coupon date (accrued = 0) Keeps the engine calendar-free and deterministic; the cost is no mid-period accrued interest and no holiday/roll convention (Limitation 1).
A5 Payment frequency 2 (semi-annual), overridable US Treasury/corporate convention; short-tenor T-bills use higher freq so round(maturity·freq) ≥ 1 (guarded by TestUSTreasury).
A6 OAS application Parallel additive shift in bps on every interpolated zero rate Simple, invertible spread definition; lets compute_oas recover a planted spread to <1bp (test_oas_solver).

Prose. The canonical snapshot input is a $1,000-face, 5% semi-annual, 10-year BBB bond priced off the flat-5% bootstrapped curve. This input is chosen (not firm-sourced) precisely because it has a known closed-form answer: a bond whose coupon equals the flat discount yield must price at par. That makes the snapshot self-validating — any drift off $1,000 immediately flags a discounting or interpolation regression. The discrete (yield) vs continuous (curve) compounding choice is deliberate and is the one place the two price paths disagree; the engine's own test tolerates that gap at 2%, and the observed gap is ~0.4bp of price.


Output Snapshot

Output Snapshot

Deterministic single-bond run of BondPricer (finview.L1.bond_pricer) — reproducible, requires no live market or firm data. Generated by python scripts/model_snapshots.py M-130; the same numerics are asserted by tests/test_bond_pricer.py (par-at-par, YTM round-trip, duration monotonicity, convexity-positive, OAS recovery).

Input: $1,000 face · 5.00% coupon · 10y · semi-annual · BBB · flat-5% bootstrapped SOFR discount curve.

output value meaning
clean_price 1,000.00 curve price; coupon = curve yield ⇒ par (raw 1000.0037, the ~0.4bp discrete-vs-continuous gap)
dirty_price 1,000.00 equals clean — priced on a coupon date, zero accrued
ytm (solved) 5.0000 % Brent inversion of the clean price (raw 4.99995%)
macaulay_duration 7.9895 y PV-weighted average cash-flow time
modified_duration 7.7946 y macaulay / (1 + ytm/2)
convexity 73.6295 finite-difference, ±10bp bump; positive as required for a vanilla bond
dv01 ($/1bp) 0.7791 engine-returned dv01(ytm) — price decline from a +1bp parallel yield bump (p0 − p_up, raw 0.77909362). Refreshed 2026-06-06 from scripts/model_snapshots.py M-130. The prior 0.7795 was the card's derived clean · mod_dur · 1e-4 figure; the engine bump is −0.00037 lower (convexity makes the actual +1bp decline slightly smaller than the linear duration estimate).
recovered OAS 130.0000 bps compute_oas recovers a planted 130bp spread to <1bp

The par result is the load-bearing check: a 5% coupon discounted on a flat 5% curve returns essentially face value, confirming the coupon-schedule generation, zero-rate conversion, interpolation, and discounting all compose correctly. The modified duration of ~7.79y is consistent with a 10y par bond, and convexity is positive as a vanilla fixed-rate bond requires. DV01 is now an engine output (dv01(ytm), a +1bp parallel bump); on a ~7.79y-duration $1,000 par bond it is $0.7791/bp (raw 0.77909362), refreshed 2026-06-06 from the engine's bump. This is −$0.00037 below the prior card-derived $0.7795 (price × mod-dur × 1bp identity): convexity makes the actual +1bp price decline marginally smaller than the linear duration estimate, so the engine bump and the duration identity agree to ~5 bps of each other — a sanity check that both paths are consistent.

Risk measures captured 2026-06-04; dv01 refreshed 2026-06-06 from scripts/model_snapshots.py M-130 (INV-015 engine method) · deterministic, no live data.


Limitations

Limitations and Known Gaps

  1. No accrued interest / settlement calendar. The engine assumes pricing on a coupon date, so accrued_interest = 0 and clean_price == dirty_price always. There is no business-day calendar, holiday roll, or actual/actual-vs-30/360 day-count switch — the day-count is implicit in integer period counting. Mid-period (between-coupon) pricing is therefore not supported; dirty price for a settled trade would be understated by the accrued amount.

  2. DV01 output — fully resolved (engine method + snapshot refreshed). Originally a limitation: the contract outputs list named dv01 but BondPricer returned no dv01 key, so the card derived it from clean · modified_duration · 1e-4. Resolved 2026-06-04: INV-015 added a first-class dv01(ytm) method (parallel +1bp bump returning the price decline p0 − p_up); the declared contract output is emitted. Resolved 2026-06-06 (COND-002 remediation): the Output Snapshot dv01 row was refreshed from python scripts/model_snapshots.py M-130 — the snapshot script now calls pricer.dv01(ytm) (previously it recomputed the linear identity), and the card shows the engine value 0.7791 (raw 0.77909362), −0.00037 below the old derived 0.7795 (convexity gap). No remaining documentation-currency item on dv01. Key-rate DV01 was already implemented (compute_key_rate_dv01); the prior "total DV01 must be assembled by the caller" gap is closed by the dv01() method.

  3. Discrete/continuous compounding split. Yield-path pricing is discrete (1+y/f)^n; curve-path pricing is continuous exp(-z·t). They do not agree exactly (≈0.4bp of price on the par snapshot). Code comments and test_price_from_curve explicitly accept this within a 2% band — fine for risk/ALM use, but a caller expecting tick-level price parity between the two paths will be surprised.

  4. OAS is a flat parallel zero-rate shift. compute_oas and the oas_bps argument add a single constant to every interpolated zero. There is no term-structure of spread, no option-exercise model, and no negative-convexity adjustment — so for callable/putable or MBS-style bonds the "OAS" here is really a Z-spread, not a true option-adjusted spread.

  5. Cross-repo ownership (FinView-resident). This engine lives in [redacted] with its test in [redacted]. It is consumed by the insurance asset/capital stack but is not owned or versioned in InsModel. The model-card governance home (InsModel/insightalm) and the code home (FinView) diverge — consistent with BV-032 firm-data-path divergence. Any contract or registry change must be coordinated across both repos.

  6. No firm / 10-K grounding. The snapshot uses a chosen synthetic bond and a synthetic flat curve. No issuer, no real market quote, no 10-K holding feeds this card; the input is deterministic-by-construction so the par invariant holds. Real-portfolio pricing (issuer ratings, observed spreads, actual settlement dates) is out of scope for the snapshot.

Tracked for ratification. The following are output-changing / modeling-code items, noted here for transparency: (a) ~~refreshing the Output Snapshot dv01 value to the number BondPricer.dv01() actually returns~~ — DONE 2026-06-06 (COND-002): the snapshot script now calls pricer.dv01(ytm) and the card shows 0.7791; (b) the still- open part of INV-015 — a real accrual / day-count + settlement-date path so accrued_interest is non-zero and mid-period dirty pricing is supported (Limitation 1 remains accurate; the source still hardcodes accrued_interest = 0.0); (c) INV-032 — a packaged/versioned cross-repo interface + CI drift gate for the FinView-resident pricing library consumed by InsModel via sys.path (Limitation 5).


Validation Evidence

Validation Packet

Check Where What it asserts
Gold-copy challenger ecosystem/gold_copy/instruments/bond_pricer.py (goldcopy.L1.bond_pricer) An independent re-implementation (does not import FinView/InsModel; uses gold_copy.market_data.curve_builder). Method-for-method identical algorithm — same price_from_yield, price_from_curve, compute_ytm, compute_duration, compute_key_rate_dv01, compute_oas. Serves as the cross-engine challenger for the bond pricer.
QuantLib cross-validation FinView/tests/test_ql_validation.py::TestBondValidation BondPricer.price_from_curve vs QuantLib FixedRateBond (DiscountingBondEngine, Actual365Fixed) for {4% 5y, 5% 10y, 3% 30y}, asserted within BOND_PRICE_TOL. QuantLib is a test-only dependency (@pytest.mark.quantlib, skipped if not installed).
Par-at-par test_bond_pricer.py::test_par_bond_prices_at_par coupon = YTM ⇒ price = face (<$0.01).
YTM round-trip test_ytm_roundtrip (+ T-bill variant) price→ytm→price closes to <1e-10.
Duration monotonicity test_duration_monotonicity 5y < 10y < 30y modified duration.
Higher yield ⇒ lower price test_higher_yield_lower_price monotone price/yield.
Curve vs yield agreement test_price_from_curve flat-5% curve price within 2% of yield price.
Convexity positive test_convexity_positive vanilla-bond convexity > 0.
Key-rate DV01 sums to total test_key_rate_dv01_sums_to_duration Σ KR-DV01 ≈ price·mod_dur·1bp within 15%.
OAS solver test_oas_solver recovers a planted 130bp spread to <1bp.
Engine benchmarks test_engine_benchmarks.py::TestBondPricerBenchmarks frozen golden prices (par, above-par at 4%, below-par at 6%, zero-coupon Macaulay = maturity).
T-bill / short-end guard test_bond_pricer.py::TestUSTreasury sub-1y maturities never price at $0 and have positive small duration (empty-payment_times bug guard).

Venv portability: the snapshot runs identically under both the FinView venv ([redacted]) and the InsModel venv ([redacted]) — same numbers to all printed digits — given FinView is on sys.path. This confirms the cross-repo consumer (InsModel asset stack) can execute the engine with no FinView-specific runtime beyond numpy/scipy.


References

References

Fixed-income literature: - Fabozzi, F. J. Bond Markets, Analysis, and Strategies — clean/dirty price, Macaulay vs modified duration, convexity, DV01 definitions. - Tuckman, B. & Serrat, A. Fixed Income Securities — discount-curve pricing, Z-spread / OAS, key-rate durations. - Hull, J. Options, Futures, and Other Derivatives — continuous vs discrete compounding and zero-rate bootstrapping.

Engine and supporting code: - [redacted]BondPricer (finview.L1.bond_pricer), the engine documented here. - [redacted]bootstrap_sofr_curve, interpolate_curve (finview.L0.curve_builder), the discount-curve source. - [redacted]BondCashflows coupon schedule (used by the T-bill cash-flow tests).

Challenger and tests: - [redacted] — independent Gold-Copy challenger (goldcopy.L1.bond_pricer). - [redacted] — primary test suite. - [redacted] — frozen golden cases. - [redacted] — QuantLib cross-validation (test-only dependency).


Change Log

Change Log

Card change history. Code-side change history lives in git log of the component files.

  • 2026-05-08 — stub created from registry data per Decision 023 Phase 5 / B-07.
  • 2026-06-04 — Tier-2 hand-authoring of Methodology, Key Assumptions and Their Justification, Output Snapshot, Limitations and Known Gaps, Validation Packet, and References sections from BondPricer (finview.L1.bond_pricer) + the bond_pricer test suites + bound A-NNN / DS-NNN registry entries. Stub marker advanced from to.
  • 2026-06-06 — code-grounded documentation-accuracy pass against finlib/bond_pricer.py. Corrected the DV01 description to match BondPricer.dv01() per the INV-015 resolution (a first-class dv01(ytm) method added 2026-06-04 that parallel-bumps the yield +1bp and returns the price decline p0 − p_up): updated Methodology §4, the Output Snapshot dv01 row (value marked pending re-snapshot; not refreshed — output-changing, gated for ratification), and Limitation 2 (split the now-resolved DV01-method gap from the remaining snapshot-currency item). Added the Standards Coverage section (asop_56, sr_26_2, internal) mirroring M-001, grounded only in the existing framework tags. Confirmed Limitation 1 (no accrued interest / no settlement calendar / no day-count) remains accurate — source still hardcodes accrued_interest = 0.0; INV-015 added only dv01(), not the accrual path. Noted gated items (snapshot refresh, INV-015 accrual path, INV-032 cross-repo packaging) under Limitations. No model outputs, back-test numbers, or validation results were fabricated or changed.
  • 2026-06-06 — RAT-130-v1.0.0 remediation (1L, Decision 053). COND-002: refreshed the Output Snapshot dv01 row from python scripts/model_snapshots.py M-130 to the engine's bump-based value 0.7791 (raw 0.77909362) — the snapshot script (InsModel/Models/scripts/model_snapshots.py::snap_M_130) was changed to call pricer.dv01(ytm) instead of recomputing the clean · mod_dur · 1e-4 identity; recorded the −0.00037 diff vs the prior derived 0.7795 (convexity). Updated Output Snapshot, Methodology prose, Limitation 2, and the Tracked-for-ratification block. COND-001: the registry documentation_pack flags + lifecycle were reconciled to disk (model_card/change_log present, last_validated_on stamped) and the Tier-2 validation-evidence pack assembled at modelling/validation_evidence/M-130/v1.0.0/README.md pointing at the live 61-test suite + QuantLib + gold-copy challengers. No numbers fabricated.

2L Inventory Review

Open findings (2)

Independent 2nd-line review (INV-2026-06) — implemented capability vs registered scope. Each carries a recommended fix and is tracked in insightalm-mrm until closed.

HIGH INV-029 · P5 · validation-gap

Validation evidence + change logs missing across most of the inventory

Only M-001/M-020/M-050 carried full documentation packs before this pass. Most models record validation_evidence: missing and change_log: missing with peer_review: pending. Gold tests freeze behaviour but many assert only structural invariants (e.g. reserve>0), not correctness against external truth. The flagship T0-vs-10-K match is circular (BV-032).

Recommendation: For each Tier-1 model: produce a validation-evidence pack (back-test vs disclosed results once BV-032 re-calibration lands, sensitivity suite, challenger comparison), a change log, and a 2L ratification. Sequence behind BV-032 (firm-data) for anything needing 10-K reconciliation.

LOW INV-032 · cross-repo · cross-repo

FinView-resident pricing/CECL engines reached cross-repo with no drift gate

M-040 (CECL) and M-130-137 (asset pricing) are FinView-resident, consumed by InsModel only via sys.path insertion. No packaged interface or drift gate — a finlib signature change silently breaks the InsModel snapshot harness / consumers.

Recommendation: Define a packaged interface (or a versioned contract) for the FinView pricing library and a CI drift gate, so cross-repo consumers fail loudly on signature change. Tie to D041 ownership.


Validation Coverage

Per-tier expectations

Per MRM Framework §10.2 + §10.3, this model's regulatory_frameworks tag list activates the following overlays:

asop_56 sr_26_2 internal
component tier-2 expectation status
Registry entry required present
Model card (§10.5 doc pack) required present
Validation evidence required present
Change log required present
Independent effective challenge (2L) required attested

Ratification

Ratified — RAT-130-v1.0.1

Latest ratification on file: RAT-130-v1.0.1. Authored by 2L (mrm-peer-reviewer) per Decision 028 charter §5 Pattern A.