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}