QuantLib Swap Valuation: Undiscounted Cash Flows and Forward Rates
Summary
The discussion explains why manually summing projected swap cash flows can disagree with QuantLib’s net present value. A cash flow’s amount is undiscounted, so it must be multiplied by the discount factor for its payment date when calculating present value. For floating coupons, the accepted answer advises using the Ibor index’s fixing-date rate to obtain the relevant forward rate, rather than treating a curve forwardRate call with continuous compounding as that coupon rate.
The question’s example builds a discount curve from supplied dates and discount factors, then compares a one-year fixed-for-floating swap with manual quarterly floating-leg calculations and an annual fixed payment. The replies identify the key conceptual mismatch but do not provide a full, date-by-date reconstruction of the swap valuation. Exact replication also depends on the instrument’s schedules and conventions, so the brief answers alone do not resolve every date adjustment in the example.
Key ideas
- Cash flow amounts are undiscounted; discount them at their payment dates to compute present value.
- Use the index fixing for the relevant date to obtain a floating coupon’s Ibor forward rate.
- A curve forward rate with continuous compounding is not automatically the rate used for an Ibor coupon.
- Schedule and day-count conventions affect the dates and accruals used in swap valuation.
Tags
Full text
# Replicating QuantLib plain vanilla Interest Rate Swap valuation
# Replicating QuantLib plain vanilla Interest Rate Swap valuation
I'm learning QuantLib-Python and trying to replicate an Interest Rate Swap valuation on a custom curve constructed by passing lists of dates and discounting factors. Please see my code below
```
import QuantLib as ql
today = ql.Date(31, ql.May, 2023)
ql.Settings.instance().setEvaluationDate(today)
dates = [ql.Date(31, ql.May, 2023), ql.Date(28, ql.August, 2023), ql.Date(27, ql.November, 2023),
ql.Date(26, ql.February, 2024), ql.Date(27, ql.May, 2024), ql.Date(26, ql.May, 2025), ql.Date(26, ql.May, 2026)]
dfs = [1.00, 0.980626530240871, 0.961865563098355, 0.942995352345942, 0.923889577251464, 0.845638911735274, 0.770640534908645]
dayCounter = ql.ActualActual(ql.ActualActual.ISDA)
calendar = ql.UnitedStates()
curve = ql.DiscountCurve(dates, dfs, dayCounter, calendar)
curveHandle = ql.YieldTermStructureHandle(curve)
start = today
maturity = calendar.advance(start, ql.Period('1Y'))
fix_schedule = ql.MakeSchedule(start, maturity, ql.Period('1Y'))
float_schedule = ql.MakeSchedule(start, maturity, ql.Period('3M'))
customIndex = ql.IborIndex('index', ql.Period('3M'), 0, ql.USDCurrency(), ql.UnitedStates(), ql.ModifiedFollowing, True, dayCounter, curveHandle)
customIndex.addFixing(ql.Date(30, ql.May, 2023), 0.075)
notional = 100000000
swap = ql.VanillaSwap(ql.VanillaSwap.Payer, notional, fix_schedule, 0.0833, dayCounter,
float_schedule, customIndex, 0, dayCounter)
swap_engine = ql.DiscountingSwapEngine(curveHandle)
swap.setPricingEngine(swap_engine)
print(swap.NPV())
```
There is a possibility to print out undiscounted fixed and floating leg cashflows by
```
print("Net Present Value: {0}".format(swap.NPV()))
print()
print("Fixed leg cashflows:")
for i, cf in enumerate(swap.leg(0)):
print("%2d %-18s %10.2f"%(i+1, cf.date(), cf.amount()))
print()
print("Floating leg cashflows:")
for i, cf in enumerate(swap.leg(1)):
print("%2d %-18s %10.2f"%(i+1, cf.date(), cf.amount()))
```
The Net Present Value of a swap should be given by the difference of discounted floating and fixed leg cashflows. I tried to replicate calculations manually via
```
fwd1 = curve.forwardRate(ql.Date(31, 5, 2023), ql.Date(31, 8, 2023), dayCounter, ql.Continuous).rate()
df1 = curve.discount(ql.Date(31, 8, 2023))
tau1 = dayCounter.yearFraction(ql.Date(31, 5, 2023), ql.Date(31, 8, 2023))
cashflow1 = df1 * notional * fwd1 * tau1
print(cashflow1)
print()
fwd2 = curve.forwardRate(ql.Date(1, 9, 2023), ql.Date(30, 11, 2023), dayCounter, ql.Simple).rate()
df2 = curve.discount(ql.Date(30, 11, 2023))
tau2 = dayCounter.yearFraction(ql.Date(1, 9, 2023), ql.Date(30, 11, 2023))
cashflow2 = df2 * notional * fwd2 * tau2
print(cashflow2)
print()
fwd3 = curve.forwardRate(ql.Date(1, 12, 2023), ql.Date(29, 2, 2024), dayCounter, ql.Simple).rate()
df3 = curve.discount(ql.Date(29, 2, 2024))
tau3 = dayCounter.yearFraction(ql.Date(1, 12, 2023), ql.Date(29, 2, 2024))
cashflow3 = df3 * notional * fwd3 * tau3
print(cashflow3)
print()
fwd4 = curve.forwardRate(ql.Date(1, 3, 2024), ql.Date(31, 5, 2024), dayCounter, ql.Simple).rate()
df4 = curve.discount(ql.Date(31, 5, 2024))
tau4 = dayCounter.yearFraction(ql.Date(1, 3, 2024), ql.Date(31, 5, 2024))
cashflow4 = df4 * notional * fwd4 * tau4
print(cashflow4)
print()
floatCashFlow = cashflow1 + cashflow2 + cashflow3 + cashflow4
print(floatCashFlow)
print()
df = df4
fixRate = 0.0833
tau = dayCounter.yearFraction(ql.Date(31, 5, 2023), ql.Date(31, 5, 2024))
fixCashFlow = df * notional * fixRate * tau
print(fixCashFlow)
print()
valueSwap = floatCashFlow - fixCashFlow
print(valueSwap)
```
and got a staggeringly different NPV. Can anyone show me how to precisely arrive at a built-in NPV valuation using built-in methods for calling discounting factors and forward rates? I suppose the difference is hidden somewhere in payment schedules but I failed to match the numbers by playing around with dates.
## Answer by user35980 (score 3, accepted)
https://quant.stackexchange.com/a/75749
1/ the cf.amount() attribute returns undiscounted cashflows not PVd ones
2/ your curve.forwardRate() call is returning a continuously compounded zero rate, use customIndex(fixing date) to get the relevant ibor fwd
## Answer by Attack68 (score 1)
https://quant.stackexchange.com/a/76539
As a base for comparison you can also try `rateslib`
```
from rateslib import Curve, IRS
from pandas import Series
curve = Curve(
nodes={
dt(2023, 5, 31): 1.00,
dt(2023, 8, 28): 0.980626530240871,
dt(2023, 11, 27): 0.961865563098355,
dt(2024, 2, 26): 0.942995352345942,
dt(2024, 5, 27): 0.923889577251464,
dt(2025, 5, 26): 0.845638911735274,
dt(2026, 5, 26): 0.770640534908645,
},
interpolation = "log_linear",
calendar="nyc",
convention="act360",
)
irs = IRS(
effective=dt(2023, 5, 31),
termination="1Y",
calendar="nyc",
frequency="A",
convention="act360",
modifier="MF",
notional=100e6,
fixed_rate=8.33,
leg2_frequency="Q",
leg2_fixing_method="ibor",
leg2_method_param=1,
leg2_fixings=Series([7.5], index=[dt(2023, 5, 30)])
)
```Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)
This summary was written by Stratmill's research agent from the original; it is not a copy of the source.