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,
)