Skip to content

Commit 8624150

Browse files
test: validate floating-bond IR01/CS01 against OpenGamma §5.2 FRN reference (#148)
Rebuilds the OpenGamma "Bond Pricing" (Henrard, Quantitative Research, 2011) §5.2 floating-rate-note price from eq. (3)+(4) directly — coupons estimated on the index/forward curve I, coupons+notional discounted on the issuer credit curve C — using three distinct curves, then checks the package against it: - price reproduces OpenGamma eq. (4) to machine precision - IR01 (1bp parallel shift of the risk-free curve, driving both I and C so coupons re-fix) maps to Effective; matches a 1bp bump of the reference PV - CS01 (1bp shift of the issuer spread only, coupons held) maps to Spread - the FRN signature |IR01| ~ 0 (next reset) << CS01 ~ maturity, with CS01 concentrated at the notional repayment The existing floating tests checked this only qualitatively and FD-checked Effective on a single curve; this adds the explicit three-curve reconstruction and the IR01/CS01 mapping. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 984a4e1 commit 8624150

1 file changed

Lines changed: 79 additions & 0 deletions

File tree

test/runtests.jl

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1480,6 +1480,85 @@ end
14801480
end
14811481
end
14821482

1483+
# Reference: OpenGamma "Bond Pricing" (M. Henrard, Quantitative Research, 2011),
1484+
# §5.2 Floating rate note (FRN). That note fixes the multi-curve convention this
1485+
# package implements: coupons are ESTIMATED on the forward/index curve I (eq. 3)
1486+
# and coupons + notional are DISCOUNTED on the issuer/credit curve C (eq. 4):
1487+
#
1488+
# F_i = (1/δ_i)(P^I(s_i)/P^I(e_i) − 1) (3)
1489+
# PV = Σ_i δ_i N_i (F_i + s_i) P^C(t_i) + N P^C(t_N) (4) (settle S = 0)
1490+
#
1491+
# Market risk of a credit FRN under this convention maps onto the package's
1492+
# floating-rate decomposition as:
1493+
# IR01 — 1bp parallel shift of the risk-free curve, which drives BOTH the index
1494+
# I (so coupons re-fix) AND the issuer discount C = rf + spread ⇒ Effective
1495+
# CS01 — 1bp parallel shift of the issuer credit spread only (C moves; the coupons
1496+
# estimated on I are held fixed) ⇒ Spread
1497+
# A floater therefore shows |IR01| ≈ 0 (≈ next reset) and CS01 ≈ maturity — the
1498+
# textbook FRN signature. The reference price / IR01 / CS01 are rebuilt below from
1499+
# eq. (3)–(4) directly (independent of the package's projection machinery), then the
1500+
# package is checked against them.
1501+
@testset "OpenGamma §5.2 FRN reference: IR01 / CS01" begin
1502+
tenors = [1.0, 2.0, 3.0, 4.0, 5.0]
1503+
rf_zeros = [0.020, 0.024, 0.027, 0.029, 0.030] # continuous-comp risk-free zeros
1504+
cs = 0.010 # 100bp issuer credit spread
1505+
margin = 0.005 # 50bp FRN quoted margin (coupon_rate)
1506+
m = 1 # annual coupons ⇒ accrual δ = 1/m
1507+
δ = 1 / m
1508+
1509+
rf = FM.Yield.Spline(FM.Spline.Linear(), tenors, rf_zeros) # index / Ibor forward, I
1510+
credit = rf + ((z, t) -> z + FC.Continuous(cs)) # issuer discount, C = rf + spread
1511+
fl = FM.Bond.Floating(margin, FC.Periodic(m), 5.0, "IDX")
1512+
1513+
# Independent OpenGamma eq. (3)+(4) PV with distinct curves I and C (N = 1, S = 0).
1514+
function og_pv(Icrv, Ccrv)
1515+
Pprev = 1.0 # P^I(t_0) = P^I(0) = 1
1516+
pv = 0.0
1517+
for t in tenors
1518+
Fi = m * (Pprev / FC.discount(Icrv, t) - 1) # eq. (3): forward over [t-1/m, t]
1519+
pv += δ * (Fi + margin) * FC.discount(Ccrv, t) # eq. (4): coupon δ(F+s) on C
1520+
Pprev = FC.discount(Icrv, t)
1521+
end
1522+
return pv + FC.discount(Ccrv, tenors[end]) # notional, discounted on C
1523+
end
1524+
1525+
bump(crv, d) = crv + ((z, t) -> z + FC.Continuous(d))
1526+
bp = 1e-4
1527+
pv_ir(d) = og_pv(bump(rf, d), bump(credit, d)) # IR01: rf moves ⇒ both I and C shift
1528+
pv_cs(d) = og_pv(rf, bump(credit, d)) # CS01: only C shifts; coupons held on I
1529+
1530+
pv_ref = og_pv(rf, credit)
1531+
ir01_ref = (pv_ir(-bp) - pv_ir(bp)) / 2 # dv01 ≈ (V(−1bp) − V(+1bp)) / 2
1532+
cs01_ref = (pv_cs(-bp) - pv_cs(bp)) / 2
1533+
1534+
s = sensitivities(fl, rf, credit, tenors)
1535+
1536+
@testset "reproduces OpenGamma eq.(3)+(4) price" begin
1537+
@test s.value pv_ref atol = 1e-12
1538+
@test FC.present_value(credit, reproject(fl, rf)) pv_ref atol = 1e-12
1539+
@test pv_ref 0.9760496203 atol = 1e-9 # regression anchor
1540+
end
1541+
1542+
@testset "IR01 ⇒ Effective, CS01 ⇒ Spread (vs eq.(3)+(4) 1bp bump)" begin
1543+
@test s.effective_dv01 ir01_ref rtol = 1e-4
1544+
@test s.spread_dv01 cs01_ref rtol = 1e-4
1545+
@test dv01(Effective(), fl, rf, credit, tenors) ir01_ref rtol = 1e-4 # public verbs
1546+
@test dv01(Spread(), fl, rf, credit, tenors) cs01_ref rtol = 1e-4
1547+
@test s.effective_dv01 s.forward_dv01 + s.spread_dv01 atol = 1e-12 # eff = fwd + spr
1548+
@test s.effective_dv01 -2.381601e-6 rtol = 1e-5 # regression anchors
1549+
@test s.spread_dv01 4.585068e-4 rtol = 1e-5
1550+
end
1551+
1552+
@testset "FRN signature: |IR01| ≈ 0 (next reset) ≪ CS01 ≈ maturity" begin
1553+
@test abs(s.effective_dv01) < abs(s.spread_dv01) / 50 # rate risk ≈ killed by re-fixing
1554+
@test 4.5 < s.spread_duration < 5.0 # ≈ time to maturity
1555+
@test abs(s.effective_duration) < 0.1 # ≈ time to next reset (≈ 0)
1556+
cs01_kr = s.spread_key_rate .* s.value ./ 1e4 # CS01 risk concentrates at...
1557+
@test argmax(cs01_kr) == length(tenors) # ...the notional repayment (maturity)
1558+
@test cs01_kr[end] > 0.9 * cs01_ref
1559+
end
1560+
end
1561+
14831562
using Aqua
14841563
@testset "Aqua.jl" begin
14851564
Aqua.test_all(ActuaryUtilities)

0 commit comments

Comments
 (0)