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(µ); // C * M, C = coupon / (100 f) per 100
let redemption_term = big(10000 * frequency * e).mul(µ); // 100 * M
let target = price_micro.mul(&big(100 * frequency * e)).add(&big(c * a).mul(µ)); // (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)), µ)
};
let accrued_micro = round_half_away(&big(c * a).mul(µ), &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(µ), &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(),
))
}