Skip to content
All library documents

Matching Bond Day Count to Coupon and Accrued Interest Conventions

Article Quant Q&A · Author: CyBer_

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.