Functional Weave
Code in Python

invest.bond-yield@1.0.2

impl/python.py

5,774 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import re

from .dates_add_days import CivilDate, days_in_month, epoch_day_from_iso, format_iso_date, parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune
from .dates_add_months import add_months  ← from dates.add-months ^1.0.0 · built alongside by fune
from .dates_day_count_fraction import day_count_fraction  ← from dates.day-count-fraction ^1.0.0 · built alongside by fune
from .invest_bond_yield_types import BondDayCount, BondYield
from .math_fractional_power import FIXED_SCALE, mul_fixed, pow_fixed, root_fixed  ← from math.fractional-power ^1.0.0 · built alongside by fune

_PRICE = re.compile(r"[0-9]+(\.[0-9]{1,6})?")
_MICRO = 10 ** 6


def _round_half_away(n: int, d: int) -> int:
    """n / d rounded half away from zero; d > 0."""
    magnitude = (abs(n) * 2 + d) // (2 * d)
    return -magnitude if n < 0 else magnitude


def _decimal(value: int, places: int) -> str:
    unit = 10 ** places
    magnitude = abs(value)
    return "%s%d.%0*d" % ("-" if value < 0 else "", magnitude // unit, places, magnitude % unit)


def _coupon_date(maturity: str, k: int, months: int, end_of_month: bool) -> str:
    """The coupon date k periods before maturity; month ends stay month ends."""
    date = add_months(maturity, -k * months)
    if not end_of_month:
        return date
    civil = parse_iso_date(date)
    return format_iso_date(CivilDate(year=civil.year, month=civil.month, day=days_in_month(civil.year, civil.month)))


def bond_yield(settlement: str, maturity: str, coupon_basis_points: int, clean_price: str, frequency: int, basis: BondDayCount) -> BondYield:
    """Yield to maturity by Excel's YIELD: the annual yield y, compounded at
    the coupon frequency, at which the discounted coupons and redemption at
    100 equal the clean price plus accrued interest, with the first period's
    exponent DSC/E. Solved by bisection on the per-day factor
    z = (1 + y/f)^(-1/E) in 18-place fixed point, so every power is whole.
    With one coupon period or less left, Excel's simple-yield formula, exactly."""
    if isinstance(frequency, bool) or frequency not in (1, 2, 4):
        raise ValueError("frequency must be 1, 2 or 4, received %r" % (frequency,))
    c = coupon_basis_points
    if isinstance(c, bool) or not isinstance(c, int) or c < 0 or c > 100000:
        raise ValueError("couponBasisPoints must be a whole number from 0 to 100000, received %r" % (c,))
    if not isinstance(clean_price, str) or not _PRICE.fullmatch(clean_price):
        raise ValueError('cleanPrice must be a positive decimal with at most 6 places, received "%s"' % (clean_price,))
    if basis not in ("30-360", "act-act"):
        raise ValueError('basis must be 30-360 or act-act, received "%s"' % (basis,))
    whole, _, fraction = clean_price.partition(".")
    price_micro = int(whole) * _MICRO + int(fraction.ljust(6, "0"))
    if price_micro == 0:
        raise ValueError("cleanPrice must be greater than zero")
    settle_day = epoch_day_from_iso(settlement)
    if settle_day >= epoch_day_from_iso(maturity):
        raise ValueError("settlement must be before maturity")

    months = 12 // frequency
    civil = parse_iso_date(maturity)
    end_of_month = civil.day == days_in_month(civil.year, civil.month)
    k = 1
    while epoch_day_from_iso(_coupon_date(maturity, k, months, end_of_month)) > settle_day:
        k += 1
        if k > 100 * frequency:
            raise ValueError("maturity must be within 100 years of settlement")
    previous = _coupon_date(maturity, k, months, end_of_month)
    nxt = _coupon_date(maturity, k - 1, months, end_of_month)
    if basis == "30-360":
        yf = day_count_fraction(previous, settlement, "30-360")
        a = yf.numerator * 360 // yf.denominator
        e = 360 // frequency
    else:
        a = settle_day - epoch_day_from_iso(previous)
        e = epoch_day_from_iso(nxt) - epoch_day_from_iso(previous)
    dsc = e - a
    if dsc <= 0:
        raise ValueError("by the 30/360 count settlement is not before the next coupon date")

    # Everything is scaled by M = 100 * f * E * 10^6 so it is a whole number.
    f = frequency
    coupon_term = c * e * _MICRO  # C * M, C = coupon / (100 f) per 100
    redemption_term = 100 * 100 * f * e * _MICRO  # 100 * M
    target = price_micro * 100 * f * e + c * a * _MICRO  # (clean + accrued) * M

    if k == 1:
        # ((100 + C) - dirty) / dirty * (f * E / DSC)
        numerator = (redemption_term + coupon_term - target) * f * e
        settled = _round_half_away(numerator * 10 ** 12, target * dsc)
    else:
        n = k

        def value(z: int) -> int:
            step = pow_fixed(z, e)
            p = pow_fixed(z, dsc)
            total = 0
            for i in range(1, n + 1):
                total += coupon_term * p
                if i < n:
                    p = mul_fixed(p, step)
            return total + redemption_term * p - target * FIXED_SCALE

        lo, hi = 0, root_fixed(2 * FIXED_SCALE, e)
        if value(hi) < 0:
            raise ValueError("the price implies a yield below -50% a coupon period")
        while hi - lo > 1:
            mid = (lo + hi) // 2
            if value(mid) >= 0:
                hi = mid
            else:
                lo = mid
        growth = pow_fixed(hi, e)
        if growth == 0:
            raise ValueError("the yield is too large to compute")
        per_period = FIXED_SCALE * FIXED_SCALE // growth - FIXED_SCALE
        settled = _round_half_away(per_period * f, 10 ** 6)
    accrued_micro = _round_half_away(c * a * _MICRO, 100 * f * e)
    return BondYield(
        yield_basis_points=_round_half_away(settled * 10000, 10 ** 12),
        yield_to_maturity=_decimal(_round_half_away(settled, 1000), 9),
        current_yield_basis_points=_round_half_away(c * 100 * _MICRO, price_micro),
        accrued_interest=_decimal(accrued_micro, 6),
        dirty_price=_decimal(price_micro + accrued_micro, 6),
        previous_coupon=previous,
        next_coupon=nxt,
        coupons_remaining=k,
    )