use super::funejson::Value; use super::dates_add_days::{days_in_month, epoch_day_from_iso, format_iso_date, parse_iso_date, CivilDate}; use super::dates_add_months::add_months; use super::dates_day_count_fraction::day_count_fraction; use super::math_big_integer::{parse_big_integer, BigInt}; use super::math_fractional_power::{fixed_scale, mul_fixed, pow_fixed, root_fixed}; /// 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(), )) }