code wiki / (root) / nx_bond_lib.nx

nx_bond_lib.nx source

↩ module page · 110 lines · 5707 B

1// nx_bond_lib.nx -- FIXED-INCOME VALUATION: bond price, yield-to-maturity, duration. Integer-exact. 2// 3// The fixed-income companion to nx_dcf_lib (equity intrinsic value). A bond is a stream of coupons plus a 4// face repayment; its PRICE is the present value of that stream at a discount yield, and its YIELD-TO-MATURITY 5// is the inverse -- the single rate that makes the PV equal the market price. Together, DCF (equities) + this 6// (bonds) value both sides of a real portfolio. 7// 8// NO FLOATS. The discount factor is carried scaled x1e6 and rolled forward one period at a time 9// (df_t = df_{t-1} * 10000 / (10000 + ytm_bp)); every present value is exact integer arithmetic with defined 10// downward truncation, identical to nx_dcf_lib. Coupons and yields are basis points (10% -> 1000). 11// 12// *YTM BY INTEGER BISECTION, NOT NEWTON-RAPHSON. Price is strictly monotone-decreasing in yield, so a 13// bisection on basis points is exact and cannot diverge -- unlike the float Newton step every mainstream 14// library uses, which can oscillate or shoot to a garbage root on a bad initial guess. And it is FAIL-CLOSED: 15// a target price above the zero-yield maximum (would require a negative yield) or below the max-search-yield 16// floor is REFUSED (BOND_NO_CONVERGE) rather than returning an invented number. A yield you cannot bracket 17// honestly is not a yield. 18// 19// SCALE ENVELOPE (declared): face*coupon_bp and cf*df fit i64 for face up to ~9e8 minor units at bp<=1e5. 20// bisection is O(log(YTM_MAX) * periods). Self-contained (no imports); the df identity is shared with 21// nx_dcf_lib by design, not by copy of a service. license_tier: ORIGINAL No hw writes (Rule 26). LIB. 22 23const BOND_DF_SCALE: i64 = 1000000 // discount factor carried x1e6 (1.0 = 1000000) 24const BOND_BP_FULL: i64 = 10000 // 100% in basis points 25const BOND_DUR_SCALE: i64 = 1000 // duration reported in periods x1000 26const BOND_YTM_MAX: i64 = 500000 // upper bisection bound: 5000% -- declared search ceiling 27const BOND_BAD: i64 = 0 - 2000000002 // invalid input (price <= 0) 28const BOND_NO_CONVERGE: i64 = 0 - 2000000003 // target price unreachable within [0, BOND_YTM_MAX] 29 30// PRICE: present value of coupons + face at `ytm_bp`. coupon per period = face * coupon_bp / 10000. 31// df is rolled forward so this is O(periods), not O(periods^2). At loop end df = discount factor of the 32// final period, reused for the face repayment. 33func bond_price(coupon_bp: i64, face: i64, periods: i64, ytm_bp: i64) -> i64 { 34 let coupon: i64 = face * coupon_bp / BOND_BP_FULL 35 var df: i64 = BOND_DF_SCALE 36 var price: i64 = 0 37 var t: i64 = 1 38 while t <= periods { 39 df = df * BOND_BP_FULL / (BOND_BP_FULL + ytm_bp) 40 price = price + coupon * df / BOND_DF_SCALE 41 t = t + 1 42 } 43 price = price + face * df / BOND_DF_SCALE 44 return price 45} 46 47// *YIELD-TO-MATURITY by integer bisection. Price decreases as yield rises, so [lo=0, hi=YTM_MAX] brackets 48// every reachable price. FAIL-CLOSED: price<=0 -> BOND_BAD; a price above the zero-yield max or below the 49// ceiling-yield min -> BOND_NO_CONVERGE (never an invented yield). Returns the basis-point yield whose price 50// is closest to the target within a 1 bp bracket. 51func bond_ytm(price: i64, coupon_bp: i64, face: i64, periods: i64) -> i64 { 52 if price <= 0 { return BOND_BAD } 53 var lo: i64 = 0 54 var hi: i64 = BOND_YTM_MAX 55 let p_lo: i64 = bond_price(coupon_bp, face, periods, lo) 56 let p_hi: i64 = bond_price(coupon_bp, face, periods, hi) 57 if price > p_lo { return BOND_NO_CONVERGE } 58 if price < p_hi { return BOND_NO_CONVERGE } 59 while (hi - lo) > 1 { 60 let mid: i64 = (lo + hi) / 2 61 let pm: i64 = bond_price(coupon_bp, face, periods, mid) 62 if pm > price { lo = mid } 63 if pm <= price { hi = mid } 64 } 65 let d_lo: i64 = bond_price(coupon_bp, face, periods, lo) - price 66 let d_hi: i64 = price - bond_price(coupon_bp, face, periods, hi) 67 if d_lo <= d_hi { return lo } 68 return hi 69} 70 71// MACAULAY DURATION: PV-weighted average time to cashflow = sum(t * PV_t) / sum(PV_t), in periods x1000. 72// The bond's interest-rate sensitivity -- the fixed-income analog of the equity margin of safety. 73func bond_macaulay_duration(coupon_bp: i64, face: i64, periods: i64, ytm_bp: i64) -> i64 { 74 let coupon: i64 = face * coupon_bp / BOND_BP_FULL 75 var df: i64 = BOND_DF_SCALE 76 var weighted: i64 = 0 77 var pvsum: i64 = 0 78 var t: i64 = 1 79 while t <= periods { 80 df = df * BOND_BP_FULL / (BOND_BP_FULL + ytm_bp) 81 var cf: i64 = coupon 82 if t == periods { cf = coupon + face } 83 let pv: i64 = cf * df / BOND_DF_SCALE 84 weighted = weighted + t * pv 85 pvsum = pvsum + pv 86 t = t + 1 87 } 88 if pvsum <= 0 { return BOND_BAD } 89 return weighted * BOND_DUR_SCALE / pvsum 90} 91 92// MODIFIED DURATION = Macaulay / (1 + y). Same x1000 scale. Always < Macaulay for a positive yield. 93func bond_modified_duration(macaulay_x1000: i64, ytm_bp: i64) -> i64 { 94 if macaulay_x1000 < 0 { return BOND_BAD } 95 return macaulay_x1000 * BOND_BP_FULL / (BOND_BP_FULL + ytm_bp) 96} 97 98// CURRENT YIELD in basis points: annual coupon / price. For a discount bond it exceeds the coupon rate. 99func bond_current_yield_bp(coupon_bp: i64, face: i64, price: i64) -> i64 { 100 if price <= 0 { return BOND_BAD } 101 let coupon: i64 = face * coupon_bp / BOND_BP_FULL 102 return coupon * BOND_BP_FULL / price 103} 104 105// price class vs face: PREMIUM (price>face), DISCOUNT (price<face), PAR (equal). 106func bond_price_class(price: i64, face: i64) -> *u8 { 107 if price > face { return "PREMIUM" as *u8 } 108 if price < face { return "DISCOUNT" as *u8 } 109 return "PAR" as *u8 110}