Matching Bond Day Count to Coupon and Accrued Interest Conventions
Summary
The document investigates why a QuantLib fixed-rate bond gives coupon amounts that differ slightly from the stated rate when configured with ACT/ACT, while a 30/360 Bond Basis setting produces the expected coupon amounts but a different accrued-interest result. The example concerns an annual-coupon corporate bond and compares calculated cash flows and accrued interest with Bloomberg values. It highlights that a day-count label alone may not identify the exact convention needed for a particular bond.
The accepted answer explains that Actual/Actual ISDA and Actual/Actual ICMA are distinct conventions, and that QuantLib’s results follow the supplied inputs. It points to Actual/Actual ICMA as the convention that matches the reported Bloomberg accrued interest and related price-risk measures in the example. The evidence is a worked comparison, including values from another pricing library, rather than a general proof. Correct results still depend on specifying the bond’s conventions and schedule accurately; the document’s result should not be generalized to every bond called ACT/ACT.
Key ideas
- Actual/Actual ISDA and Actual/Actual ICMA are different day-count conventions.
- A bond’s coupon cash flows and accrued interest depend on the convention used to define accrual periods.
- The example’s Bloomberg comparison matches Actual/Actual ICMA inputs.
- QuantLib can be correct for the conventions supplied even when those inputs do not match market practice for the bond.
Tags
Full text
# BusinessDayConvention failing to obtain correct accured or correct coupons in ql.FixedRateBond()
# BusinessDayConvention failing to obtain correct accured or correct coupons in ql.FixedRateBond()
I am trying to price a fixed rate bond with Quantlib (BFCM 4 3/8 01/11/34 Corp or FR001400N3I5)
- Coupon 4.375%
- Day Cnt (Bloom) : ACT/ACT
- Issue date 11-Jan-24
- First CPn Date 11-Jan-25
- Mty 11-Jan-34
When I setup the bond with ACT/ACT then the accrued is properly valued : 2.9726027 (Bloomberg shows 29726.03 for a €1M not.) Thought the coupons are showing wrong amounts 4.375327494572945 instead of 4.375:
```
'accrued_interest': 2.972602739726038,
'accrued_period': 0.0,
'asof': '2025-09-12',
'bond_settlement_value': 116.96742569803662,
'cashflows': [{'amount': 4.375327494572945,
'discount_factor': 1.0,
'past': True,
'pay_date': '2025-01-13',
'pv': 0.0},
{'amount': 4.374999999999996,
'discount_factor': 0.9934756639336938,
'past': False,
'pay_date': '2026-01-12',
'pv': 4.3464560297099055},
{'amount': 4.374999999999996,
'discount_factor': 0.9742330398008944,
'past': False,
'pay_date': '2027-01-11',
'pv': 4.262269549128908},
{'amount': 4.374672505427046,
'discount_factor': 0.9538961080166873,
'past': False,
'pay_date': '2028-01-11',
'pv': 4.17298307677447},
{'amount': 4.375327494572945,
'discount_factor': 0.9322732541048638,
'past': False,
'pay_date': '2029-01-11',
'pv': 4.07900080114},
{'amount': 4.374999999999996,
'discount_factor': 0.9095511967919774,
'past': False,
'pay_date': '2030-01-11',
'pv': 3.979286485964897},
{'amount': 4.374999999999996,
'discount_factor': 0.8859034039235356,
'past': False,
'pay_date': '2031-01-13',
'pv': 3.8758273921654642},
{'amount': 4.374672505427046,
'discount_factor': 0.8619274988665826,
'past': False,
'pay_date': '2032-01-12',
'pv': 3.7706505309631404},
{'amount': 4.375327494572945,
'discount_factor': 0.8375123397374541,
'past': False,
'pay_date': '2033-01-11',
'pv': 3.6643907670974},
{'amount': 4.374999999999996,
'discount_factor': 0.8126137587074724,
'past': False,
'pay_date': '2034-01-11',
'pv': 3.555185194345188},
{'amount': 100.0,
'discount_factor': 0.8126137587074724,
'past': False,
'pay_date': '2034-01-11',
'pv': 81.26137587074724}],
'clean_price': 102.8800033973664,
'convexity': 57.01464193585733,
'coupons': [],
'dirty_price': 105.85260613709244,
'discount_factor_at_maturity': 0.8126137587074724,
'duration_macaulay': 6.973752770874606,
'duration_modified': 6.7081288089075235,
'npv': 116.96742569803662,
'rate_at_maturity': 0.025234916221780868,
'settlement_date': '2025-09-16',
'yield_to_maturity': 0.03959732580184938
```
However if I change the Day_count to 30/360 BOND, then coupons are good showing 4.375%, but Accured interest is wrong (2.977430555555549)
```
from __future__ import annotations
import QuantLib as ql
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Literal
# -------------------------------
# Utilities
# -------------------------------
_TENOR_UNITS = {"W": ql.Weeks, "M": ql.Months, "Y": ql.Years}
def parse_tenor(tenor: str) -> ql.Period:
t = tenor.strip().upper()
for suf, unit in _TENOR_UNITS.items():
if t.endswith(suf):
return ql.Period(int(t[:-1]), unit)
raise ValueError(f"Tenor inconnu: {tenor}")
def to_calendar(name: str) -> ql.Calendar:
n = name.strip().upper()
if n in ("TARGET", "EUR-TARGET", "TGT", "ECB"):
return ql.TARGET()
raise ValueError(f"Calendrier non supporté: {name}")
def to_convention(name: str) -> int:
mapping: Dict[str, int] = {
"UNADJUSTED": ql.Unadjusted,
"FOLLOWING": ql.Following,
"MODIFIEDFOLLOWING": ql.ModifiedFollowing,
"PRECEDING": ql.Preceding,
"MODIFIEDPRECEDING": ql.ModifiedPreceding,
}
key = name.strip().upper()
if key in mapping:
return mapping[key]
raise ValueError(f"Convention non supportée: {name}")
def to_frequency(name: str) -> int:
mapping: Dict[str, int] = {
"ANNUAL": ql.Annual,
"SEMIANNUAL": ql.Semiannual,
"QUARTERLY": ql.Quarterly,
"MONTHLY": ql.Monthly,
"WEEKLY": ql.Weekly,
"DAILY": ql.Daily,
"ONCE": ql.Once,
"NOFREQUENCY": ql.NoFrequency,
}
key = name.strip().upper()
if key in mapping:
return mapping[key]
raise ValueError(f"Fréquence non supportée: {name}")
def to_daycount(name: str) -> ql.DayCounter:
key = name.strip().upper()
if key in ("30E/360", "30/360", "THIRTY360", "30/360 BOND", "30/360 BOND BASIS"):
return ql.Thirty360(ql.Thirty360.BondBasis)
if key in ("ACT/ACT", "ACTUALACTUAL", "ACT/ACT ISDA", "ACTUAL/ACTUAL ISDA"):
return ql.ActualActual(ql.ActualActual.ISDA)
if key in ("ACT/360", "ACTUAL/360"):
return ql.Actual360()
if key in ("ACT/365", "ACTUAL/365", "ACTUAL/365F", "ACT/365F"):
return ql.Actual365Fixed()
raise ValueError(f"Day count non supporté: {name}")
def ql_date_from_iso(d: str) -> ql.Date:
y, m, dd = map(int, d.split("-"))
return ql.Date(dd, m, y)
def ql_date_to_iso(d: ql.Date) -> str:
return f"{d.year():04d}-{int(d.month()):02d}-{d.dayOfMonth():02d}"
# -------------------------------
# Data classes
# -------------------------------
@dataclass
class BondSpec:
face_amount: float
coupon_rate: float
issue_date: ql.Date
maturity_date: ql.Date
first_coupon_date: Optional[ql.Date]
frequency: int
calendar: ql.Calendar
convention: int
termination_convention: int
eom: bool
date_gen: int
day_count_coupon: ql.DayCounter
payment_convention: int
@dataclass
class MarketContext:
asof: ql.Date
settlement_days: int
clean_price: Optional[float] = None
# -------------------------------
# Pricer
# -------------------------------
class FixedRateBondPricer:
def __init__(self, bond_spec: BondSpec, ois_quotes_percent: Dict[str, float], context: MarketContext):
self.spec = bond_spec
self.ois_quotes_percent = dict(ois_quotes_percent)
self.ctx = context
ql.Settings.instance().evaluationDate = self.ctx.asof
self.schedule = self._build_schedule()
self.bond = ql.FixedRateBond(
self.ctx.settlement_days,
self.spec.face_amount,
self.schedule,
[self.spec.coupon_rate],
self.spec.day_count_coupon,
self.spec.payment_convention,
100.0,
ql.Date(),
self.spec.calendar,
)
self.settlement_date = self.bond.settlementDate()
self.curve = self._build_ois_curve()
self.discount_handle = ql.YieldTermStructureHandle(self.curve)
self.engine = ql.DiscountingBondEngine(self.discount_handle)
self.bond.setPricingEngine(self.engine)
def _build_schedule(self) -> ql.Schedule:
return ql.Schedule(
self.spec.issue_date,
self.spec.maturity_date,
ql.Period(self.spec.frequency),
self.spec.calendar,
ql.Unadjusted,
ql.Unadjusted,
self.spec.date_gen,
self.spec.eom,
self.spec.first_coupon_date if self.spec.first_coupon_date is not None else ql.Date(),
ql.Date(),
)
def _build_ois_curve(self) -> ql.YieldTermStructure:
eonia = ql.Eonia()
helpers = []
for tenor_str, rate_pct in self.ois_quotes_percent.items():
tenor = parse_tenor(tenor_str)
quote = ql.QuoteHandle(ql.SimpleQuote(rate_pct / 100.0))
helper = ql.OISRateHelper(self.ctx.settlement_days, tenor, quote, eonia)
helpers.append(helper)
curve = ql.PiecewiseLogLinearDiscount(self.settlement_date, helpers, self.spec.day_count_coupon)
curve.enableExtrapolation()
return curve
def _cashflows_with_df_pv(self) -> List[Dict[str, Any]]:
cfs: List[Dict[str, Any]] = []
ts = self.discount_handle
ref_date = ts.referenceDate()
for cf in self.bond.cashflows():
pay_date = cf.date()
amt = float(cf.amount())
if pay_date < ref_date:
df = 1.0
pv = 0.0
past = True
else:
df = float(ts.discount(pay_date))
pv = amt * df
past = False
cfs.append({
"pay_date": ql_date_to_iso(pay_date),
"amount": amt,
"discount_factor": df,
"pv": pv,
"past": past,
})
return cfs
def _collect_coupons(self) -> List[Dict[str, Any]]:
coupons: List[Dict[str, Any]] = []
for cf in self.bond.cashflows():
try:
a_start = cf.accrualStartDate()
a_end = cf.accrualEndDate()
accrual_period = cf.accrualPeriod()
except Exception:
continue
try:
coupon_rate = float(cf.rate())
except Exception:
coupon_rate = None
coupons.append({
"pay_date": ql_date_to_iso(cf.date()),
"amount": float(cf.amount()),
"accrual_start": ql_date_to_iso(a_start),
"accrual_end": ql_date_to_iso(a_end),
"accrual_dc": float(accrual_period),
"rate": coupon_rate,
})
return coupons
def _discount_and_zero_at_maturity(self) -> Dict[str, float]:
mat = self.spec.maturity_date
df = self.discount_handle.discount(mat)
zr = self.discount_handle.zeroRate(mat, ql.Actual365Fixed(), ql.Compounded, ql.Annual).rate()
return {"discount_factor_maturity": float(df), "zero_rate_maturity": float(zr)}
def _dur_conv_from_ir(self, ytm: float, ytm_dc: ql.DayCounter, freq: int, settlement_date: Optional[ql.Date] = None) -> Dict[str, float]:
ir = ql.InterestRate(ytm, ytm_dc, ql.Compounded, freq)
if settlement_date is None:
macaulay = ql.BondFunctions.duration(self.bond, ir, ql.Duration.Macaulay)
modified = ql.BondFunctions.duration(self.bond, ir, ql.Duration.Modified)
convexity = ql.BondFunctions.convexity(self.bond, ir)
else:
macaulay = ql.BondFunctions.duration(self.bond, ir, ql.Duration.Macaulay, settlement_date)
modified = ql.BondFunctions.duration(self.bond, ir, ql.Duration.Modified, settlement_date)
convexity = ql.BondFunctions.convexity(self.bond, ir, settlement_date)
return {"duration_macaulay": float(macaulay), "duration_modified": float(modified), "convexity": float(convexity)}
def _accrual_meta(self) -> Dict[str, Any]:
d = self.settlement_date
for cf in self.bond.cashflows():
try:
a_start = cf.accrualStartDate()
a_end = cf.accrualEndDate()
dc_coupon = cf.dayCounter()
except Exception:
continue
if a_start <= d < a_end or d == a_end:
days = dc_coupon.dayCount(a_start, d)
period_frac = dc_coupon.yearFraction(a_start, a_end)
elapsed_frac = dc_coupon.yearFraction(a_start, d)
return {
"accrued_days": int(days),
"accrued_period": float(elapsed_frac),
"accrual_period": float(period_frac),
"accrual_start": ql_date_to_iso(a_start),
"accrual_end": ql_date_to_iso(a_end),
}
return {
"accrued_days": 0,
"accrued_period": 0.0,
"accrual_period": 0.0,
"accrual_start": None,
"accrual_end": None,
}
def _results_from_clean_price(self, market_clean: float) -> Dict[str, Any]:
ytm_dc = ql.Thirty360(ql.Thirty360.BondBasis)
freq = ql.Annual
comp = ql.Compounded
settlement_date = self.bond.settlementDate()
bp = ql.BondPrice(market_clean, ql.BondPrice.Clean)
ytm = self.bond.bondYield(bp, ytm_dc, comp, freq, settlement_date)
clean = self.bond.cleanPrice(ytm, ytm_dc, comp, freq, settlement_date)
accrued = self.bond.accruedAmount(self.settlement_date)
dirty = clean + accrued
sens = self._dur_conv_from_ir(ytm, ytm_dc, freq, settlement_date)
npv = self.bond.NPV()
term = self._discount_and_zero_at_maturity()
accr_meta = self._accrual_meta()
settlement_value = self.bond.settlementValue()
return {
"asof": ql_date_to_iso(self.ctx.asof),
"settlement_date": ql_date_to_iso(settlement_date),
"cashflows": self._cashflows_with_df_pv(),
"coupons": self._collect_coupons(),
"discount_factor_at_maturity": term["discount_factor_maturity"],
"rate_at_maturity": term["zero_rate_maturity"],
"clean_price": float(clean),
"dirty_price": float(dirty),
"accrued_interest": float(accrued),
"bond_settlement_value": float(settlement_value),
"accrued_days": accr_meta["accrued_days"],
"accrued_period": accr_meta["accrued_period"],
"accrual_period": accr_meta["accrual_period"],
"accrual_start": accr_meta["accrual_start"],
"accrual_end": accr_meta["accrual_end"],
"duration_macaulay": sens["duration_macaulay"],
"duration_modified": sens["duration_modified"],
"convexity": sens["convexity"],
"yield_to_maturity": float(ytm),
"npv": float(npv),
}
def _results_from_curve(self) -> Dict[str, Any]:
npv = self.bond.NPV()
clean = self.bond.cleanPrice()
accrued = self.bond.accruedAmount(self.settlement_date)
dirty = clean + accrued
ytm_dc = ql.Thirty360(ql.Thirty360.BondBasis)
freq = ql.Annual
ytm = self.bond.bondYield(ytm_dc, ql.Compounded, freq)
sens = self._dur_conv_from_ir(ytm, ytm_dc, freq)
term = self._discount_and_zero_at_maturity()
accr_meta = self._accrual_meta()
settlement_value = self.bond.settlementValue()
return {
"asof": ql_date_to_iso(self.ctx.asof),
"settlement_date": ql_date_to_iso(self.bond.settlementDate()),
"cashflows": self._cashflows_with_df_pv(),
"coupons": self._collect_coupons(),
"discount_factor_at_maturity": term["discount_factor_maturity"],
"rate_at_maturity": term["zero_rate_maturity"],
"clean_price": float(clean),
"dirty_price": float(dirty),
"accrued_interest": float(accrued),
"bond_settlement_value": float(settlement_value),
"accrued_days": accr_meta["accrued_days"],
"accrued_period": accr_meta["accrued_period"],
"accrual_period": accr_meta["accrual_period"],
"accrual_start": accr_meta["accrual_start"],
"accrual_end": accr_meta["accrual_end"],
"duration_macaulay": sens["duration_macaulay"],
"duration_modified": sens["duration_modified"],
"convexity": float(sens["convexity"]),
"yield_to_maturity": float(ytm),
"npv": float(npv),
}
def results(self) -> Dict[str, Any]:
if self.ctx.clean_price is not None:
return self._results_from_clean_price(self.ctx.clean_price)
else:
return self._results_from_curve()
# -------------------------------
# Facades JSON
# -------------------------------
AllowedBDC = Literal[
"Unadjusted",
"Following",
"ModifiedFollowing",
"Preceding",
"ModifiedPreceding",
]
def load_bond_spec_from_json(bond_json: Dict[str, Any]) -> BondSpec:
def date_val(v):
if isinstance(v, ql.Date):
return v
if isinstance(v, str):
return ql_date_from_iso(v)
raise ValueError("Date doit être ql.Date ou 'YYYY-MM-DD'.")
return BondSpec(
face_amount=float(bond_json["face_amount"]),
coupon_rate=float(bond_json["coupon_rate"]),
issue_date=date_val(bond_json["issue_date"]),
maturity_date=date_val(bond_json["maturity_date"]),
first_coupon_date=date_val(bond_json["first_coupon_date"]) if bond_json.get("first_coupon_date") else None,
frequency=to_frequency(bond_json["frequency"]) if isinstance(bond_json["frequency"], str) else int(bond_json["frequency"]),
calendar=to_calendar(bond_json["calendar"]) if isinstance(bond_json["calendar"], str) else bond_json["calendar"],
convention=to_convention(bond_json["convention"]) if isinstance(bond_json["convention"], str) else int(bond_json["convention"]),
termination_convention=to_convention(bond_json["terminationDateConvention"]) if isinstance(bond_json["terminationDateConvention"], str) else int(bond_json["terminationDateConvention"]),
eom=bool(bond_json["eom"]),
date_gen=getattr(ql.DateGeneration, bond_json["date_gen"]) if isinstance(bond_json["date_gen"], str) else int(bond_json["date_gen"]),
day_count_coupon=to_daycount(bond_json.get("day_count_coupon", "30/360 BOND")) if isinstance(bond_json.get("day_count_coupon", "30/360 BOND"), str) else bond_json["day_count_coupon"],
payment_convention=to_convention(bond_json.get("payment_convention", "ModifiedFollowing")) if isinstance(bond_json.get("payment_convention", "ModifiedFollowing"), str) else int(bond_json.get("payment_convention", ql.ModifiedFollowing)),
)
def load_context_from_json(ctx_json: Dict[str, Any]) -> MarketContext:
def date_val(v):
if isinstance(v, ql.Date):
return v
if isinstance(v, str):
return ql_date_from_iso(v)
raise ValueError("asof doit être ql.Date ou 'YYYY-MM-DD'.")
return MarketContext(
asof=date_val(ctx_json["asof"]),
settlement_days=int(ctx_json["settlement_days"]),
clean_price=float(ctx_json["clean_price"]) if ctx_json.get("clean_price") is not None else None,
)
# -------------------------------
# Exemple d’utilisation
# -------------------------------
if __name__ == "__main__":
bond_json = {
"face_amount": 100.0,
"coupon_rate": 0.04375,
"issue_date": "2024-01-11",
"maturity_date": "2034-01-11",
"first_coupon_date": "2025-01-11",
"frequency": "Annual",
"calendar": "TARGET",
"convention": "Unadjusted",
"terminationDateConvention": "Unadjusted",
"payment_convention": "ModifiedFollowing",
"eom": False,
"date_gen": "Backward",
"day_count_coupon": "30/360 BOND", # "30/360 BOND"
}
ois_quotes = {
"1W": 2.008, "2W": 2.008, "1M": 2.009, "2M": 2.009, "3M": 2.009,
"4M": 2.003, "5M": 1.999, "6M": 1.995, "7M": 1.989, "8M": 1.982,
"9M": 1.978, "10M": 1.974, "11M": 1.971, "12M": 1.969, "18M": 1.966,
"2Y": 1.994, "3Y": 2.070, "4Y": 2.152, "5Y": 2.232, "6Y": 2.307,
"7Y": 2.376, "8Y": 2.445, "9Y": 2.510, "10Y": 2.569, "11Y": 2.624,
"12Y": 2.672, "15Y": 2.782, "20Y": 2.858, "25Y": 2.867, "30Y": 2.860,
"40Y": 2.844, "50Y": 2.806,
}
ctx_json = {
"asof": "2025-09-12",
"settlement_days": 2,
"clean_price": 102.88,
}
spec = load_bond_spec_from_json(bond_json)
ctx = load_context_from_json(ctx_json)
pricer = FixedRateBondPricer(spec, ois_quotes, ctx)
out = pricer.results()
from pprint import pprint
pprint(out)
```
## Answer by Attack68 (score 2)
https://quant.stackexchange.com/a/84055
As mentioned in my comment ActActISDA is not the same as ActActICMA. Quantlib is correct according to your inputs.
For what its worth I think you should get these values (calculated from `rateslib`):
```
# rateslib==2.1.1
bond = FixedRateBond(
effective=dt(2024, 1, 11),
termination=dt(2034, 1, 11),
fixed_rate=4.375,
frequency="A",
convention="ActActICMA",
modifier="None",
calendar="tgt",
calc_mode="FR_GB"
)
print("Accrued: ", bond.accrued(settlement=dt(2025, 9, 16)))
y = bond.ytm(price=102.880000, settlement=dt(2025, 9, 16))
print("YTM: ", y)
print("Duration: ", bond.duration(y, settlement=dt(2025, 9, 16), metric="duration"))
print("Modified: ", bond.duration(y, settlement=dt(2025, 9, 16), metric="modified"))
print("Risk: ", bond.duration(y, settlement=dt(2025, 9, 16), metric="risk"))
print("Convexity: ", bond.convexity(y, settlement=dt(2025, 9, 16)) * 100 / 105.85)
```
You get the following values matching Bloomberg for `ActActICMA`:
```
Accrued: 2.9726027397260273
YTM: 3.9593229975378144
Duration: 6.9744408744764606
Modified: 6.708817134795736
Risk: 7.101457550251064
Convexity: 0.5702813753792715
```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.