import re from .dates_add_days import CivilDate, days_in_month, epoch_day_from_iso, format_iso_date, parse_iso_date from .dates_add_months import add_months from .dates_day_count_fraction import day_count_fraction from .invest_bond_yield_types import BondDayCount, BondYield from .math_fractional_power import FIXED_SCALE, mul_fixed, pow_fixed, root_fixed _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, )