Functional Weave
Code in Python

invest.bond-yield@1.0.2

impl/rust.rs

8,788 bytes · the Rust 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::{days_in_month, epoch_day_from_iso, format_iso_date, parse_iso_date, CivilDate};  ← from dates.add-days ^1.0.0 · built alongside by fune
use super::dates_add_months::add_months;  ← from dates.add-months ^1.0.0 · built alongside by fune
use super::dates_day_count_fraction::day_count_fraction;  ← from dates.day-count-fraction ^1.0.0 · built alongside by fune
use super::math_big_integer::{parse_big_integer, BigInt};  ← from math.big-integer ^1.0.0 · built alongside by fune
use super::math_fractional_power::{fixed_scale, mul_fixed, pow_fixed, root_fixed};  ← from math.fractional-power ^1.0.0 · built alongside by fune

/// n / d rounded half away from zero; d > 0.
fn round_half_away(n: &BigInt, d: &BigInt) -> BigInt {
    let two = BigInt::from_i64(2);
    let magnitude = n.abs().mul(&two).add(d).div(&two.mul(d));
    if n.is_negative() {
        magnitude.neg()
    } else {
        magnitude
    }
}

fn big(v: i64) -> BigInt {
    BigInt::from_i64(v)
}

fn decimal(value: &BigInt, places: u32) -> String {
    let unit = big(10i64.pow(places));
    let (whole, fraction) = value.abs().div_rem(&unit);
    format!(
        "{}{}.{:0width$}",
        if value.is_negative() { "-" } else { "" },
        whole,
        fraction.to_i64(),
        width = places as usize
    )
}

/// The coupon date k periods before maturity; month ends stay month ends.
fn coupon_date(maturity: &str, k: i64, months: i64, end_of_month: bool) -> String {
    let date = add_months(maturity, -k * months);
    if !end_of_month {
        return date;
    }
    let civil = parse_iso_date(&date);
    format_iso_date(&CivilDate { year: civil.year, month: civil.month, day: days_in_month(civil.year, civil.month) })
}

fn is_price(text: &str) -> bool {
    let (whole, fraction) = match text.split_once('.') {
        Some((w, f)) => (w, Some(f)),
        None => (text, None),
    };
    !whole.is_empty()
        && whole.bytes().all(|b| b.is_ascii_digit())
        && match fraction {
            None => true,
            Some(f) => !f.is_empty() && f.len() <= 6 && f.bytes().all(|b| b.is_ascii_digit()),
        }
}

/// 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.
///
/// # Panics
/// Panics on a bad frequency, coupon, price, basis or dates, a maturity more
/// than 100 years away, or a price no yield above -50% a period explains.
pub fn bond_yield(settlement: &str, maturity: &str, coupon_basis_points: i64, clean_price: &str, frequency: i64, basis: &str) -> BondYield {
    if frequency != 1 && frequency != 2 && frequency != 4 {
        panic!("frequency must be 1, 2 or 4, received {}", frequency);
    }
    let c = coupon_basis_points;
    if c < 0 || c > 100000 {
        panic!("couponBasisPoints must be a whole number from 0 to 100000, received {}", c);
    }
    if !is_price(clean_price) {
        panic!("cleanPrice must be a positive decimal with at most 6 places, received \"{}\"", clean_price);
    }
    if basis != "30-360" && basis != "act-act" {
        panic!("basis must be 30-360 or act-act, received \"{}\"", basis);
    }
    let (whole, fraction) = clean_price.split_once('.').unwrap_or((clean_price, ""));
    let mut digits = whole.to_string();
    digits.push_str(&format!("{:0<6}", fraction));
    let digits = digits.trim_start_matches('0');
    let price_micro = if digits.is_empty() { BigInt::zero() } else { parse_big_integer(digits) };
    if price_micro.is_zero() {
        panic!("cleanPrice must be greater than zero");
    }
    let settle_day = epoch_day_from_iso(settlement);
    if settle_day >= epoch_day_from_iso(maturity) {
        panic!("settlement must be before maturity");
    }

    let months = 12 / frequency;
    let civil = parse_iso_date(maturity);
    let end_of_month = civil.day == days_in_month(civil.year, civil.month);
    let mut k: i64 = 1;
    while epoch_day_from_iso(&coupon_date(maturity, k, months, end_of_month)) > settle_day {
        k += 1;
        if k > 100 * frequency {
            panic!("maturity must be within 100 years of settlement");
        }
    }
    let previous = coupon_date(maturity, k, months, end_of_month);
    let next = coupon_date(maturity, k - 1, months, end_of_month);
    let (a, e) = if basis == "30-360" {
        let yf = day_count_fraction(&previous, settlement, "30-360");
        (yf.numerator * 360 / yf.denominator, 360 / frequency)
    } else {
        let p = epoch_day_from_iso(&previous);
        (settle_day - p, epoch_day_from_iso(&next) - p)
    };
    let dsc = e - a;
    if dsc <= 0 {
        panic!("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.
    let micro = big(1_000_000);
    let coupon_term = big(c * e).mul(&micro); // C * M, C = coupon / (100 f) per 100
    let redemption_term = big(10000 * frequency * e).mul(&micro); // 100 * M
    let target = price_micro.mul(&big(100 * frequency * e)).add(&big(c * a).mul(&micro)); // (clean + accrued) * M
    let scale = fixed_scale();
    let e12 = big(1_000_000_000_000);

    let settled = if k == 1 {
        // ((100 + C) - dirty) / dirty * (f * E / DSC)
        let numerator = redemption_term.add(&coupon_term).sub(&target).mul(&big(frequency * e));
        round_half_away(&numerator.mul(&e12), &target.mul(&big(dsc)))
    } else {
        let n = k;
        let value = |z: &BigInt| -> BigInt {
            let step = pow_fixed(z, e as u64);
            let mut p = pow_fixed(z, dsc as u64);
            let mut total = BigInt::zero();
            for i in 1..=n {
                total = total.add(&coupon_term.mul(&p));
                if i < n {
                    p = mul_fixed(&p, &step);
                }
            }
            total.add(&redemption_term.mul(&p)).sub(&target.mul(&scale))
        };
        let one = big(1);
        let two = big(2);
        let mut lo = BigInt::zero();
        let mut hi = root_fixed(&scale.mul(&two), e as u64);
        if value(&hi).is_negative() {
            panic!("the price implies a yield below -50% a coupon period");
        }
        while hi.sub(&lo) > one {
            let mid = lo.add(&hi).div(&two);
            if !value(&mid).is_negative() {
                hi = mid;
            } else {
                lo = mid;
            }
        }
        let growth = pow_fixed(&hi, e as u64);
        if growth.is_zero() {
            panic!("the yield is too large to compute");
        }
        let per_period = scale.mul(&scale).div(&growth).sub(&scale);
        round_half_away(&per_period.mul(&big(frequency)), &micro)
    };
    let accrued_micro = round_half_away(&big(c * a).mul(&micro), &big(100 * frequency * e));
    BondYield {
        yield_basis_points: round_half_away(&settled.mul(&big(10000)), &e12).to_i64(),
        yield_to_maturity: decimal(&round_half_away(&settled, &big(1000)), 9),
        current_yield_basis_points: round_half_away(&big(c * 100).mul(&micro), &price_micro).to_i64(),
        accrued_interest: decimal(&accrued_micro, 6),
        dirty_price: decimal(&price_micro.add(&accrued_micro), 6),
        previous_coupon: previous,
        next_coupon: next,
        coupons_remaining: k,
    }
}

pub fn bond_yield_to_value(result: &BondYield) -> Value {
    Value::obj(vec![
        ("yieldBasisPoints", Value::Int(result.yield_basis_points)),
        ("yieldToMaturity", Value::str(&result.yield_to_maturity)),
        ("currentYieldBasisPoints", Value::Int(result.current_yield_basis_points)),
        ("accruedInterest", Value::str(&result.accrued_interest)),
        ("dirtyPrice", Value::str(&result.dirty_price)),
        ("previousCoupon", Value::str(&result.previous_coupon)),
        ("nextCoupon", Value::str(&result.next_coupon)),
        ("couponsRemaining", Value::Int(result.coupons_remaining)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what the typed signature cannot hold, with the wording TypeScript
    // and Python use.
    if let Value::Float(f) = args[2] {
        if f.fract() != 0.0 {
            panic!("couponBasisPoints must be a whole number from 0 to 100000, received {}", f);
        }
    }
    if let Value::Float(f) = args[4] {
        if f.fract() != 0.0 {
            panic!("frequency must be 1, 2 or 4, received {}", f);
        }
    }
    if !matches!(args[3], Value::Str(_)) {
        panic!("cleanPrice must be a positive decimal with at most 6 places, received \"{:?}\"", args[3]);
    }
    bond_yield_to_value(&bond_yield(
        args[0].as_str(),
        args[1].as_str(),
        args[2].as_i64(),
        args[3].as_str(),
        args[4].as_i64(),
        args[5].as_str(),
    ))
}