formualizer_eval/builtins/financial/bonds.rs
1//! Bond pricing functions: ACCRINT, ACCRINTM, PRICE, YIELD
2
3use crate::args::ArgSchema;
4use crate::builtins::datetime::serial_to_date;
5use crate::function::Function;
6use crate::traits::{ArgumentHandle, CalcValue, FunctionContext};
7use chrono::{Datelike, NaiveDate};
8use formualizer_common::{ExcelError, LiteralValue};
9use formualizer_macros::func_caps;
10
11fn coerce_num(arg: &ArgumentHandle) -> Result<f64, ExcelError> {
12 let v = arg.value()?.into_literal();
13 coerce_literal_num(&v)
14}
15
16fn coerce_literal_num(v: &LiteralValue) -> Result<f64, ExcelError> {
17 match v {
18 LiteralValue::Number(f) => Ok(*f),
19 LiteralValue::Int(i) => Ok(*i as f64),
20 LiteralValue::Boolean(b) => Ok(if *b { 1.0 } else { 0.0 }),
21 LiteralValue::Empty => Ok(0.0),
22 LiteralValue::Error(e) => Err(e.clone()),
23 _ => Err(ExcelError::new_value()),
24 }
25}
26
27/// Day count basis calculation
28/// Returns (num_days, year_basis) for the given basis type
29#[derive(Debug, Clone, Copy, PartialEq)]
30enum DayCountBasis {
31 UsNasd30360 = 0, // US (NASD) 30/360
32 ActualActual = 1, // Actual/actual
33 Actual360 = 2, // Actual/360
34 Actual365 = 3, // Actual/365
35 European30360 = 4, // European 30/360
36}
37
38impl DayCountBasis {
39 fn from_int(basis: i32) -> Result<Self, ExcelError> {
40 match basis {
41 0 => Ok(DayCountBasis::UsNasd30360),
42 1 => Ok(DayCountBasis::ActualActual),
43 2 => Ok(DayCountBasis::Actual360),
44 3 => Ok(DayCountBasis::Actual365),
45 4 => Ok(DayCountBasis::European30360),
46 _ => Err(ExcelError::new_num()),
47 }
48 }
49}
50
51/// Check if a year is a leap year
52fn is_leap_year(year: i32) -> bool {
53 (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
54}
55
56/// Check if a date is the last day of the month
57fn is_last_day_of_month(date: &NaiveDate) -> bool {
58 let next_day = *date + chrono::Duration::days(1);
59 next_day.month() != date.month()
60}
61
62/// Calculate days between two dates using the specified basis
63fn days_between(start: &NaiveDate, end: &NaiveDate, basis: DayCountBasis) -> i32 {
64 match basis {
65 DayCountBasis::UsNasd30360 => days_30_360_us(start, end),
66 DayCountBasis::ActualActual | DayCountBasis::Actual360 | DayCountBasis::Actual365 => {
67 (*end - *start).num_days() as i32
68 }
69 DayCountBasis::European30360 => days_30_360_eu(start, end),
70 }
71}
72
73/// Calculate days using US (NASD) 30/360 method
74fn days_30_360_us(start: &NaiveDate, end: &NaiveDate) -> i32 {
75 let mut sd = start.day() as i32;
76 let sm = start.month() as i32;
77 let sy = start.year();
78
79 let mut ed = end.day() as i32;
80 let em = end.month() as i32;
81 let ey = end.year();
82
83 // Adjust for last day of February
84 let start_is_last_feb = sm == 2 && is_last_day_of_month(start);
85 let end_is_last_feb = em == 2 && is_last_day_of_month(end);
86
87 if start_is_last_feb && end_is_last_feb {
88 ed = 30;
89 }
90 if start_is_last_feb {
91 sd = 30;
92 }
93 if ed == 31 && sd >= 30 {
94 ed = 30;
95 }
96 if sd == 31 {
97 sd = 30;
98 }
99
100 (ey - sy) * 360 + (em - sm) * 30 + (ed - sd)
101}
102
103/// Calculate days using European 30/360 method
104fn days_30_360_eu(start: &NaiveDate, end: &NaiveDate) -> i32 {
105 let mut sd = start.day() as i32;
106 let sm = start.month() as i32;
107 let sy = start.year();
108
109 let mut ed = end.day() as i32;
110 let em = end.month() as i32;
111 let ey = end.year();
112
113 if sd == 31 {
114 sd = 30;
115 }
116 if ed == 31 {
117 ed = 30;
118 }
119
120 (ey - sy) * 360 + (em - sm) * 30 + (ed - sd)
121}
122
123/// Calculate the year fraction between two dates.
124///
125/// For Actual/Actual we mirror the prorated year-boundary behavior used by YEARFRAC
126/// rather than averaging year lengths across the entire span. This keeps bond accrual
127/// math aligned with the engine's date-part functions for cross-year ranges.
128fn year_fraction(start: &NaiveDate, end: &NaiveDate, basis: DayCountBasis) -> f64 {
129 if start == end {
130 return 0.0;
131 }
132
133 let (s, e, sign) = if start <= end {
134 (start, end, 1.0)
135 } else {
136 (end, start, -1.0)
137 };
138
139 let frac = match basis {
140 DayCountBasis::UsNasd30360 | DayCountBasis::European30360 => {
141 days_between(s, e, basis) as f64 / 360.0
142 }
143 DayCountBasis::Actual360 => ((*e - *s).num_days() as f64) / 360.0,
144 DayCountBasis::Actual365 => ((*e - *s).num_days() as f64) / 365.0,
145 DayCountBasis::ActualActual => {
146 let actual_days = (*e - *s).num_days() as f64;
147 if s.year() == e.year() {
148 actual_days / if is_leap_year(s.year()) { 366.0 } else { 365.0 }
149 } else {
150 let start_year_end = NaiveDate::from_ymd_opt(s.year() + 1, 1, 1).unwrap();
151 let end_year_start = NaiveDate::from_ymd_opt(e.year(), 1, 1).unwrap();
152
153 let mut out = (start_year_end - *s).num_days() as f64
154 / if is_leap_year(s.year()) { 366.0 } else { 365.0 };
155 for _year in (s.year() + 1)..e.year() {
156 out += 1.0;
157 }
158 out + (*e - end_year_start).num_days() as f64
159 / if is_leap_year(e.year()) { 366.0 } else { 365.0 }
160 }
161 }
162 };
163
164 sign * frac
165}
166
167/// Find the coupon date before settlement date
168fn coupon_date_before(settlement: &NaiveDate, maturity: &NaiveDate, frequency: i32) -> NaiveDate {
169 let months_between_coupons = 12 / frequency;
170 let mut coupon_date = *maturity;
171
172 // Work backwards from maturity to find the coupon date just before settlement
173 while coupon_date >= *settlement {
174 coupon_date = add_months(&coupon_date, -months_between_coupons);
175 }
176 coupon_date
177}
178
179/// Find the coupon date after settlement date
180fn coupon_date_after(settlement: &NaiveDate, maturity: &NaiveDate, frequency: i32) -> NaiveDate {
181 let months_between_coupons = 12 / frequency;
182 let prev_coupon = coupon_date_before(settlement, maturity, frequency);
183 add_months(&prev_coupon, months_between_coupons)
184}
185
186/// Add months to a date, handling end-of-month adjustments
187fn add_months(date: &NaiveDate, months: i32) -> NaiveDate {
188 let total_months = date.year() * 12 + date.month() as i32 - 1 + months;
189 let new_year = total_months / 12;
190 let new_month = (total_months % 12 + 1) as u32;
191
192 // Try to keep the same day, but cap at month's end
193 let mut new_day = date.day();
194 loop {
195 if let Some(d) = NaiveDate::from_ymd_opt(new_year, new_month, new_day) {
196 return d;
197 }
198 new_day -= 1;
199 if new_day == 0 {
200 // Fallback - should never reach here
201 return NaiveDate::from_ymd_opt(new_year, new_month, 1).unwrap();
202 }
203 }
204}
205
206/// Count the number of coupons remaining
207fn coupons_remaining(settlement: &NaiveDate, maturity: &NaiveDate, frequency: i32) -> i32 {
208 let months_between_coupons = 12 / frequency;
209 let mut count = 0;
210 let mut coupon_date = coupon_date_after(settlement, maturity, frequency);
211
212 while coupon_date <= *maturity {
213 count += 1;
214 coupon_date = add_months(&coupon_date, months_between_coupons);
215 }
216 count
217}
218
219/// Returns accrued interest for a coupon-bearing security.
220///
221/// `ACCRINT` calculates interest from either `issue` or the previous coupon date up to
222/// `settlement`, depending on `calc_method`.
223///
224/// # Remarks
225/// - Date inputs are spreadsheet serial dates; `settlement` must be after `issue`.
226/// - `rate` is the annual coupon rate as a decimal (for example, `0.06` for 6%), and `par` is principal amount; both must be positive.
227/// - `frequency` must be `1` (annual), `2` (semiannual), or `4` (quarterly).
228/// - `basis` codes: `0=US(NASD)30/360`, `1=Actual/Actual`, `2=Actual/360`, `3=Actual/365`, `4=European30/360`.
229/// - `calc_method`: non-zero accrues from `issue`; `0` accrues from the previous coupon date.
230/// - Return value is in the same currency units as `par` and is positive for valid positive inputs.
231///
232/// # Examples
233///
234/// ```yaml,sandbox
235/// title: "Accrue from issue date (default calc_method)"
236/// formula: "=ACCRINT(DATE(2024,1,1), DATE(2024,7,1), DATE(2024,7,1), 0.06, 1000, 2, 0)"
237/// expected: 30
238/// ```
239///
240/// ```yaml,sandbox
241/// title: "Accrue from previous coupon (calc_method = 0)"
242/// formula: "=ACCRINT(DATE(2024,1,1), DATE(2024,7,1), DATE(2024,10,1), 0.08, 1000, 2, 0, 0)"
243/// expected: 20
244/// ```
245/// ```yaml,docs
246/// related:
247/// - ACCRINTM
248/// - PRICE
249/// - YIELD
250/// faq:
251/// - q: "When does `calc_method` change the result?"
252/// a: "`calc_method=0` accrues from the previous coupon date; any non-zero value accrues from `issue`."
253/// - q: "Which inputs return `#NUM!`?"
254/// a: "Invalid `basis`, non-positive `rate`/`par`, unsupported `frequency`, or `settlement <= issue` return `#NUM!`."
255/// ```
256#[derive(Debug)]
257pub struct AccrintFn;
258
259/// [formualizer-docgen:schema:start]
260/// Name: ACCRINT
261/// Type: AccrintFn
262/// Min args: 6
263/// Max args: variadic
264/// Variadic: true
265/// Signature: ACCRINT(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6: number@scalar, arg7: number@scalar, arg8...: number@scalar)
266/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg4{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg5{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg6{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg7{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg8{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
267/// Caps: PURE
268/// [formualizer-docgen:schema:end]
269impl Function for AccrintFn {
270 func_caps!(PURE);
271 fn name(&self) -> &'static str {
272 "ACCRINT"
273 }
274 fn min_args(&self) -> usize {
275 6
276 }
277 fn variadic(&self) -> bool {
278 true
279 }
280 fn arg_schema(&self) -> &'static [ArgSchema] {
281 use std::sync::LazyLock;
282 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
283 vec![
284 ArgSchema::number_lenient_scalar(), // issue
285 ArgSchema::number_lenient_scalar(), // first_interest
286 ArgSchema::number_lenient_scalar(), // settlement
287 ArgSchema::number_lenient_scalar(), // rate
288 ArgSchema::number_lenient_scalar(), // par
289 ArgSchema::number_lenient_scalar(), // frequency
290 ArgSchema::number_lenient_scalar(), // basis (optional)
291 ArgSchema::number_lenient_scalar(), // calc_method (optional)
292 ]
293 });
294 &SCHEMA[..]
295 }
296 fn eval<'a, 'b, 'c>(
297 &self,
298 args: &'c [ArgumentHandle<'a, 'b>],
299 _ctx: &dyn FunctionContext<'b>,
300 ) -> Result<CalcValue<'b>, ExcelError> {
301 // Check minimum required arguments
302 if args.len() < 6 {
303 return Ok(CalcValue::Scalar(LiteralValue::Error(
304 ExcelError::new_value(),
305 )));
306 }
307
308 let issue_serial = coerce_num(&args[0])?;
309 let first_interest_serial = coerce_num(&args[1])?;
310 let settlement_serial = coerce_num(&args[2])?;
311 let rate = coerce_num(&args[3])?;
312 let par = coerce_num(&args[4])?;
313 let frequency = coerce_num(&args[5])?.trunc() as i32;
314 let basis_int = if args.len() > 6 {
315 coerce_num(&args[6])?.trunc() as i32
316 } else {
317 0
318 };
319 let calc_method = if args.len() > 7 {
320 coerce_num(&args[7])?.trunc() as i32
321 } else {
322 1
323 };
324
325 // Validate inputs
326 if rate <= 0.0 || par <= 0.0 {
327 return Ok(CalcValue::Scalar(
328 LiteralValue::Error(ExcelError::new_num()),
329 ));
330 }
331 if frequency != 1 && frequency != 2 && frequency != 4 {
332 return Ok(CalcValue::Scalar(
333 LiteralValue::Error(ExcelError::new_num()),
334 ));
335 }
336
337 let basis = DayCountBasis::from_int(basis_int)?;
338
339 let issue = serial_to_date(issue_serial)?;
340 let first_interest = serial_to_date(first_interest_serial)?;
341 let settlement = serial_to_date(settlement_serial)?;
342
343 // settlement must be after issue
344 if settlement <= issue {
345 return Ok(CalcValue::Scalar(
346 LiteralValue::Error(ExcelError::new_num()),
347 ));
348 }
349
350 // Calculate accrued interest
351 // If calc_method is TRUE (or 1), calculate from issue to settlement
352 // If calc_method is FALSE (or 0), calculate from last coupon to settlement
353 let accrued_interest = if calc_method != 0 {
354 // Calculate from issue date to settlement date
355 // ACCRINT = par * rate * year_fraction(issue, settlement)
356 let yf = year_fraction(&issue, &settlement, basis);
357 par * rate * yf
358 } else {
359 // Calculate from last coupon date to settlement
360 let prev_coupon = coupon_date_before(&settlement, &first_interest, frequency);
361 let start_date = if prev_coupon < issue {
362 issue
363 } else {
364 prev_coupon
365 };
366 let yf = year_fraction(&start_date, &settlement, basis);
367 par * rate * yf
368 };
369
370 Ok(CalcValue::Scalar(LiteralValue::Number(accrued_interest)))
371 }
372}
373
374/// Returns accrued interest for a security that pays interest at maturity.
375///
376/// `ACCRINTM` accrues from `issue` to `settlement` with no periodic coupon schedule.
377///
378/// # Remarks
379/// - Date inputs are spreadsheet serial dates and must satisfy `settlement > issue`.
380/// - `rate` is an annual decimal rate (for example, `0.05` for 5%), and `par` must be positive.
381/// - `basis` codes: `0=US(NASD)30/360`, `1=Actual/Actual`, `2=Actual/360`, `3=Actual/365`, `4=European30/360`.
382/// - Return value is accrued interest amount in the same currency units as `par`.
383/// - With positive `rate` and `par`, the result is positive.
384///
385/// # Examples
386///
387/// ```yaml,sandbox
388/// title: "One full 30/360 year"
389/// formula: "=ACCRINTM(DATE(2024,1,1), DATE(2025,1,1), 0.05, 1000, 0)"
390/// expected: 50
391/// ```
392///
393/// ```yaml,sandbox
394/// title: "European 30/360 half-year accrual"
395/// formula: "=ACCRINTM(DATE(2024,2,28), DATE(2024,8,28), 0.04, 1000, 4)"
396/// expected: 20
397/// ```
398/// ```yaml,docs
399/// related:
400/// - ACCRINT
401/// - PRICE
402/// - YIELD
403/// faq:
404/// - q: "Does `ACCRINTM` use coupon frequency?"
405/// a: "No. It accrues directly from `issue` to `settlement` with no periodic coupon schedule."
406/// - q: "What causes `#NUM!`?"
407/// a: "Invalid `basis`, non-positive `rate`/`par`, or `settlement <= issue`."
408/// ```
409#[derive(Debug)]
410pub struct AccrintmFn;
411
412/// [formualizer-docgen:schema:start]
413/// Name: ACCRINTM
414/// Type: AccrintmFn
415/// Min args: 4
416/// Max args: variadic
417/// Variadic: true
418/// Signature: ACCRINTM(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5...: number@scalar)
419/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg4{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg5{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
420/// Caps: PURE
421/// [formualizer-docgen:schema:end]
422impl Function for AccrintmFn {
423 func_caps!(PURE);
424 fn name(&self) -> &'static str {
425 "ACCRINTM"
426 }
427 fn min_args(&self) -> usize {
428 4
429 }
430 fn variadic(&self) -> bool {
431 true
432 }
433 fn arg_schema(&self) -> &'static [ArgSchema] {
434 use std::sync::LazyLock;
435 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
436 vec![
437 ArgSchema::number_lenient_scalar(), // issue
438 ArgSchema::number_lenient_scalar(), // settlement
439 ArgSchema::number_lenient_scalar(), // rate
440 ArgSchema::number_lenient_scalar(), // par
441 ArgSchema::number_lenient_scalar(), // basis (optional)
442 ]
443 });
444 &SCHEMA[..]
445 }
446 fn eval<'a, 'b, 'c>(
447 &self,
448 args: &'c [ArgumentHandle<'a, 'b>],
449 _ctx: &dyn FunctionContext<'b>,
450 ) -> Result<CalcValue<'b>, ExcelError> {
451 // Check minimum required arguments
452 if args.len() < 4 {
453 return Ok(CalcValue::Scalar(LiteralValue::Error(
454 ExcelError::new_value(),
455 )));
456 }
457
458 let issue_serial = coerce_num(&args[0])?;
459 let settlement_serial = coerce_num(&args[1])?;
460 let rate = coerce_num(&args[2])?;
461 let par = coerce_num(&args[3])?;
462 let basis_int = if args.len() > 4 {
463 coerce_num(&args[4])?.trunc() as i32
464 } else {
465 0
466 };
467
468 // Validate inputs
469 if rate <= 0.0 || par <= 0.0 {
470 return Ok(CalcValue::Scalar(
471 LiteralValue::Error(ExcelError::new_num()),
472 ));
473 }
474
475 let basis = DayCountBasis::from_int(basis_int)?;
476
477 let issue = serial_to_date(issue_serial)?;
478 let settlement = serial_to_date(settlement_serial)?;
479
480 // settlement must be after issue
481 if settlement <= issue {
482 return Ok(CalcValue::Scalar(
483 LiteralValue::Error(ExcelError::new_num()),
484 ));
485 }
486
487 // ACCRINTM = par * rate * year_fraction(issue, settlement)
488 let yf = year_fraction(&issue, &settlement, basis);
489 let accrued_interest = par * rate * yf;
490
491 Ok(CalcValue::Scalar(LiteralValue::Number(accrued_interest)))
492 }
493}
494
495/// Returns clean price per 100 face value for a coupon-paying security.
496///
497/// `PRICE` discounts remaining coupons and redemption to settlement and subtracts accrued
498/// coupon interest according to the chosen day-count basis.
499///
500/// # Remarks
501/// - Date inputs are spreadsheet serial dates and must satisfy `maturity > settlement`.
502/// - `rate` (coupon) and `yld` (yield) are annual decimal rates; `redemption` is amount paid per 100 face value at maturity.
503/// - `frequency` must be `1` (annual), `2` (semiannual), or `4` (quarterly).
504/// - `basis` codes: `0=US(NASD)30/360`, `1=Actual/Actual`, `2=Actual/360`, `3=Actual/365`, `4=European30/360`.
505/// - Return value is quoted per 100 face value; positive inputs usually produce a positive price.
506/// - Coupon schedule is derived by stepping backward from `maturity`, with end-of-month adjustment behavior in month arithmetic.
507///
508/// # Examples
509///
510/// ```yaml,sandbox
511/// title: "Single remaining coupon period"
512/// formula: "=PRICE(DATE(2024,4,1), DATE(2024,7,1), 0.06, 0.05, 100, 2, 0)"
513/// expected: 100.2283950617284
514/// ```
515///
516/// ```yaml,sandbox
517/// title: "Par bond when coupon rate equals yield"
518/// formula: "=PRICE(DATE(2024,3,1), DATE(2026,3,1), 0.05, 0.05, 100, 2, 0)"
519/// expected: 100
520/// ```
521/// ```yaml,docs
522/// related:
523/// - YIELD
524/// - ACCRINT
525/// - ACCRINTM
526/// faq:
527/// - q: "Why is `PRICE` quoted per 100 even if my bond face value differs?"
528/// a: "This implementation follows Excel quoting conventions and returns clean price per 100 face value."
529/// - q: "Which domain checks return `#NUM!`?"
530/// a: "`maturity <= settlement`, invalid `basis`, unsupported `frequency`, negative `rate`/`yld`, or non-positive `redemption`."
531/// ```
532#[derive(Debug)]
533pub struct PriceFn;
534
535/// [formualizer-docgen:schema:start]
536/// Name: PRICE
537/// Type: PriceFn
538/// Min args: 6
539/// Max args: variadic
540/// Variadic: true
541/// Signature: PRICE(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6: number@scalar, arg7...: number@scalar)
542/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg4{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg5{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg6{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg7{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
543/// Caps: PURE
544/// [formualizer-docgen:schema:end]
545impl Function for PriceFn {
546 func_caps!(PURE);
547 fn name(&self) -> &'static str {
548 "PRICE"
549 }
550 fn min_args(&self) -> usize {
551 6
552 }
553 fn variadic(&self) -> bool {
554 true
555 }
556 fn arg_schema(&self) -> &'static [ArgSchema] {
557 use std::sync::LazyLock;
558 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
559 vec![
560 ArgSchema::number_lenient_scalar(), // settlement
561 ArgSchema::number_lenient_scalar(), // maturity
562 ArgSchema::number_lenient_scalar(), // rate (coupon rate)
563 ArgSchema::number_lenient_scalar(), // yld (yield)
564 ArgSchema::number_lenient_scalar(), // redemption
565 ArgSchema::number_lenient_scalar(), // frequency
566 ArgSchema::number_lenient_scalar(), // basis (optional)
567 ]
568 });
569 &SCHEMA[..]
570 }
571 fn eval<'a, 'b, 'c>(
572 &self,
573 args: &'c [ArgumentHandle<'a, 'b>],
574 _ctx: &dyn FunctionContext<'b>,
575 ) -> Result<CalcValue<'b>, ExcelError> {
576 // Check minimum required arguments
577 if args.len() < 6 {
578 return Ok(CalcValue::Scalar(LiteralValue::Error(
579 ExcelError::new_value(),
580 )));
581 }
582
583 let settlement_serial = coerce_num(&args[0])?;
584 let maturity_serial = coerce_num(&args[1])?;
585 let rate = coerce_num(&args[2])?;
586 let yld = coerce_num(&args[3])?;
587 let redemption = coerce_num(&args[4])?;
588 let frequency = coerce_num(&args[5])?.trunc() as i32;
589 let basis_int = if args.len() > 6 {
590 coerce_num(&args[6])?.trunc() as i32
591 } else {
592 0
593 };
594
595 // Validate inputs
596 if rate < 0.0 || yld < 0.0 || redemption <= 0.0 {
597 return Ok(CalcValue::Scalar(
598 LiteralValue::Error(ExcelError::new_num()),
599 ));
600 }
601 if frequency != 1 && frequency != 2 && frequency != 4 {
602 return Ok(CalcValue::Scalar(
603 LiteralValue::Error(ExcelError::new_num()),
604 ));
605 }
606
607 let basis = DayCountBasis::from_int(basis_int)?;
608
609 let settlement = serial_to_date(settlement_serial)?;
610 let maturity = serial_to_date(maturity_serial)?;
611
612 // maturity must be after settlement
613 if maturity <= settlement {
614 return Ok(CalcValue::Scalar(
615 LiteralValue::Error(ExcelError::new_num()),
616 ));
617 }
618
619 let price = calculate_price(
620 &settlement,
621 &maturity,
622 rate,
623 yld,
624 redemption,
625 frequency,
626 basis,
627 );
628 Ok(CalcValue::Scalar(LiteralValue::Number(price)))
629 }
630}
631
632/// Calculate bond price using standard bond pricing formula
633fn calculate_price(
634 settlement: &NaiveDate,
635 maturity: &NaiveDate,
636 rate: f64,
637 yld: f64,
638 redemption: f64,
639 frequency: i32,
640 basis: DayCountBasis,
641) -> f64 {
642 let n = coupons_remaining(settlement, maturity, frequency);
643 let coupon = 100.0 * rate / frequency as f64;
644
645 // Find previous and next coupon dates
646 let next_coupon = coupon_date_after(settlement, maturity, frequency);
647 let prev_coupon = coupon_date_before(settlement, maturity, frequency);
648
649 // Calculate fraction of period from settlement to next coupon
650 let days_to_next = days_between(settlement, &next_coupon, basis) as f64;
651 let days_in_period = days_between(&prev_coupon, &next_coupon, basis) as f64;
652
653 let dsn = if days_in_period > 0.0 {
654 days_to_next / days_in_period
655 } else {
656 0.0
657 };
658
659 let yld_per_period = yld / frequency as f64;
660
661 if n == 1 {
662 // Short first period (single coupon remaining)
663 // Price = (redemption + coupon) / (1 + dsn * yld_per_period) - (1 - dsn) * coupon
664 (redemption + coupon) / (1.0 + dsn * yld_per_period) - (1.0 - dsn) * coupon
665 } else {
666 // Multiple coupons remaining
667 // Price = sum of discounted coupons + discounted redemption - accrued interest
668 let discount_factor = 1.0 + yld_per_period;
669
670 // Discount factor for first coupon (fractional period)
671 let first_discount = discount_factor.powf(dsn);
672
673 // Present value of coupon payments
674 let mut pv_coupons = 0.0;
675 for k in 0..n {
676 let discount = first_discount * discount_factor.powi(k);
677 pv_coupons += coupon / discount;
678 }
679
680 // Present value of redemption
681 let pv_redemption = redemption / (first_discount * discount_factor.powi(n - 1));
682
683 // Accrued interest (negative because we subtract it)
684 let accrued = (1.0 - dsn) * coupon;
685
686 pv_coupons + pv_redemption - accrued
687 }
688}
689
690/// Returns annual yield for a coupon-paying security from its market price.
691///
692/// `YIELD` solves for the annual rate that makes `PRICE(...)` match the input `pr`.
693///
694/// # Remarks
695/// - Date inputs are spreadsheet serial dates and must satisfy `maturity > settlement`.
696/// - `rate` is coupon rate (annual decimal), `pr` is price per 100 face value, and `redemption` is redemption per 100; `pr` and `redemption` must be positive.
697/// - `frequency` must be `1` (annual), `2` (semiannual), or `4` (quarterly).
698/// - `basis` codes: `0=US(NASD)30/360`, `1=Actual/Actual`, `2=Actual/360`, `3=Actual/365`, `4=European30/360`.
699/// - Result is an annualized decimal yield (for example, `0.05` means 5%).
700/// - This implementation uses Newton-Raphson iteration; if it cannot converge, it returns `#NUM!`.
701///
702/// # Examples
703///
704/// ```yaml,sandbox
705/// title: "Par price implies coupon-rate yield"
706/// formula: "=YIELD(DATE(2024,3,1), DATE(2026,3,1), 0.05, 100, 100, 2, 0)"
707/// expected: 0.05
708/// ```
709///
710/// ```yaml,sandbox
711/// title: "Yield recovered from a discounted price"
712/// formula: "=YIELD(DATE(2024,2,15), DATE(2027,2,15), 0.05, 97.2914042780609, 100, 2, 0)"
713/// expected: 0.06
714/// ```
715/// ```yaml,docs
716/// related:
717/// - PRICE
718/// - ACCRINT
719/// - ACCRINTM
720/// faq:
721/// - q: "What does the returned `YIELD` represent?"
722/// a: "It is an annualized decimal yield (for example, `0.06` means 6% per year)."
723/// - q: "When does `YIELD` return `#NUM!` besides invalid inputs?"
724/// a: "The Newton-Raphson solve can fail to converge or hit an unstable derivative; in those cases it returns `#NUM!`."
725/// ```
726#[derive(Debug)]
727pub struct YieldFn;
728
729/// [formualizer-docgen:schema:start]
730/// Name: YIELD
731/// Type: YieldFn
732/// Min args: 6
733/// Max args: variadic
734/// Variadic: true
735/// Signature: YIELD(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6: number@scalar, arg7...: number@scalar)
736/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg4{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg5{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg6{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg7{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
737/// Caps: PURE
738/// [formualizer-docgen:schema:end]
739impl Function for YieldFn {
740 func_caps!(PURE);
741 fn name(&self) -> &'static str {
742 "YIELD"
743 }
744 fn min_args(&self) -> usize {
745 6
746 }
747 fn variadic(&self) -> bool {
748 true
749 }
750 fn arg_schema(&self) -> &'static [ArgSchema] {
751 use std::sync::LazyLock;
752 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
753 vec![
754 ArgSchema::number_lenient_scalar(), // settlement
755 ArgSchema::number_lenient_scalar(), // maturity
756 ArgSchema::number_lenient_scalar(), // rate (coupon rate)
757 ArgSchema::number_lenient_scalar(), // pr (price)
758 ArgSchema::number_lenient_scalar(), // redemption
759 ArgSchema::number_lenient_scalar(), // frequency
760 ArgSchema::number_lenient_scalar(), // basis (optional)
761 ]
762 });
763 &SCHEMA[..]
764 }
765 fn eval<'a, 'b, 'c>(
766 &self,
767 args: &'c [ArgumentHandle<'a, 'b>],
768 _ctx: &dyn FunctionContext<'b>,
769 ) -> Result<CalcValue<'b>, ExcelError> {
770 // Check minimum required arguments
771 if args.len() < 6 {
772 return Ok(CalcValue::Scalar(LiteralValue::Error(
773 ExcelError::new_value(),
774 )));
775 }
776
777 let settlement_serial = coerce_num(&args[0])?;
778 let maturity_serial = coerce_num(&args[1])?;
779 let rate = coerce_num(&args[2])?;
780 let pr = coerce_num(&args[3])?;
781 let redemption = coerce_num(&args[4])?;
782 let frequency = coerce_num(&args[5])?.trunc() as i32;
783 let basis_int = if args.len() > 6 {
784 coerce_num(&args[6])?.trunc() as i32
785 } else {
786 0
787 };
788
789 // Validate inputs
790 if rate < 0.0 || pr <= 0.0 || redemption <= 0.0 {
791 return Ok(CalcValue::Scalar(
792 LiteralValue::Error(ExcelError::new_num()),
793 ));
794 }
795 if frequency != 1 && frequency != 2 && frequency != 4 {
796 return Ok(CalcValue::Scalar(
797 LiteralValue::Error(ExcelError::new_num()),
798 ));
799 }
800
801 let basis = DayCountBasis::from_int(basis_int)?;
802
803 let settlement = serial_to_date(settlement_serial)?;
804 let maturity = serial_to_date(maturity_serial)?;
805
806 // maturity must be after settlement
807 if maturity <= settlement {
808 return Ok(CalcValue::Scalar(
809 LiteralValue::Error(ExcelError::new_num()),
810 ));
811 }
812
813 // Use Newton-Raphson to find yield where price = target price
814 let yld = calculate_yield(
815 &settlement,
816 &maturity,
817 rate,
818 pr,
819 redemption,
820 frequency,
821 basis,
822 );
823
824 match yld {
825 Some(y) => Ok(CalcValue::Scalar(LiteralValue::Number(y))),
826 None => Ok(CalcValue::Scalar(
827 LiteralValue::Error(ExcelError::new_num()),
828 )),
829 }
830 }
831}
832
833/// Calculate yield using Newton-Raphson iteration
834fn calculate_yield(
835 settlement: &NaiveDate,
836 maturity: &NaiveDate,
837 rate: f64,
838 target_price: f64,
839 redemption: f64,
840 frequency: i32,
841 basis: DayCountBasis,
842) -> Option<f64> {
843 const MAX_ITER: i32 = 100;
844 const EPSILON: f64 = 1e-10;
845
846 // Initial guess based on coupon rate
847 let mut yld = rate;
848 if yld == 0.0 {
849 yld = 0.05; // Default guess if rate is 0
850 }
851
852 for _ in 0..MAX_ITER {
853 let price = calculate_price(
854 settlement, maturity, rate, yld, redemption, frequency, basis,
855 );
856 let diff = price - target_price;
857
858 if diff.abs() < EPSILON {
859 return Some(yld);
860 }
861
862 // Calculate derivative numerically
863 let delta = 0.0001;
864 let price_up = calculate_price(
865 settlement,
866 maturity,
867 rate,
868 yld + delta,
869 redemption,
870 frequency,
871 basis,
872 );
873 let derivative = (price_up - price) / delta;
874
875 if derivative.abs() < EPSILON {
876 return None;
877 }
878
879 let new_yld = yld - diff / derivative;
880
881 // Prevent yield from going too negative
882 if new_yld < -0.99 {
883 yld = -0.99;
884 } else {
885 yld = new_yld;
886 }
887
888 // Prevent yield from going too high
889 if yld > 10.0 {
890 yld = 10.0;
891 }
892 }
893
894 // If close enough after max iterations, return the result
895 let final_price = calculate_price(
896 settlement, maturity, rate, yld, redemption, frequency, basis,
897 );
898 if (final_price - target_price).abs() < 0.01 {
899 Some(yld)
900 } else {
901 None
902 }
903}
904
905/// Returns bond-equivalent yield for a US Treasury bill.
906///
907/// `TBILLEQ` converts a T-bill discount rate into an annualized bond-equivalent yield
908/// so it can be compared with coupon-bearing securities.
909///
910/// # Remarks
911/// - Date inputs are spreadsheet serial dates; `maturity` must be after `settlement`.
912/// - The T-bill must mature within one year of settlement (DSM <= 365).
913/// - `discount` is the T-bill discount rate as a decimal (e.g. `0.09` for 9%).
914/// - For bills with DSM <= 182: `yield = 365 * discount / (360 - discount * DSM)`.
915/// - For bills with DSM > 182 a quadratic coupon-equivalent formula is used.
916/// - Returns `#NUM!` for invalid dates, non-positive discount, or DSM out of range.
917///
918/// # Examples
919/// ```excel
920/// =TBILLEQ(DATE(2024,1,1), DATE(2024,4,1), 0.038)
921/// ```
922///
923/// ```yaml,sandbox
924/// title: "Short bill bond-equivalent yield"
925/// formula: '=TBILLEQ(DATE(2024,1,1), DATE(2024,4,1), 0.038)'
926/// expected: 0.03890144779577161
927/// ```
928///
929/// ```yaml,docs
930/// related:
931/// - TBILLPRICE
932/// - TBILLYIELD
933/// - YIELD
934/// faq:
935/// - q: "Why does TBILLEQ differ from the discount rate?"
936/// a: "TBILLEQ annualizes the bill using a bond-equivalent convention so it can be compared with coupon-bearing yields."
937/// ```
938#[derive(Debug)]
939pub struct TbilleqFn;
940
941/// [formualizer-docgen:schema:start]
942/// Name: TBILLEQ
943/// Type: TbilleqFn
944/// Min args: 3
945/// Max args: 3
946/// Variadic: false
947/// Signature: TBILLEQ(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar)
948/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
949/// Caps: PURE
950/// [formualizer-docgen:schema:end]
951impl Function for TbilleqFn {
952 func_caps!(PURE);
953 fn name(&self) -> &'static str {
954 "TBILLEQ"
955 }
956 fn min_args(&self) -> usize {
957 3
958 }
959 fn arg_schema(&self) -> &'static [ArgSchema] {
960 use std::sync::LazyLock;
961 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
962 vec![
963 ArgSchema::number_lenient_scalar(), // settlement
964 ArgSchema::number_lenient_scalar(), // maturity
965 ArgSchema::number_lenient_scalar(), // discount
966 ]
967 });
968 &SCHEMA[..]
969 }
970 fn eval<'a, 'b, 'c>(
971 &self,
972 args: &'c [ArgumentHandle<'a, 'b>],
973 _ctx: &dyn FunctionContext<'b>,
974 ) -> Result<CalcValue<'b>, ExcelError> {
975 let settlement_serial = coerce_num(&args[0])?;
976 let maturity_serial = coerce_num(&args[1])?;
977 let discount = coerce_num(&args[2])?;
978
979 if discount <= 0.0 {
980 return Ok(CalcValue::Scalar(
981 LiteralValue::Error(ExcelError::new_num()),
982 ));
983 }
984
985 let settlement = serial_to_date(settlement_serial)?;
986 let maturity = serial_to_date(maturity_serial)?;
987
988 if maturity <= settlement {
989 return Ok(CalcValue::Scalar(
990 LiteralValue::Error(ExcelError::new_num()),
991 ));
992 }
993
994 let dsm = (maturity - settlement).num_days() as f64;
995 if dsm > 365.0 || dsm <= 0.0 {
996 return Ok(CalcValue::Scalar(
997 LiteralValue::Error(ExcelError::new_num()),
998 ));
999 }
1000
1001 let result = if dsm <= 182.0 {
1002 365.0 * discount / (360.0 - discount * dsm)
1003 } else {
1004 // Coupon-equivalent yield for long-dated T-bills (> 182 days)
1005 // Microsoft formula: (-2a + 2*sqrt(a^2 - (2a-1)*(1 - 1/p))) / (2a-1)
1006 // where a = DSM/365 and p = price as fraction of par
1007 let price_frac = 1.0 - discount * dsm / 360.0;
1008 let a = dsm / 365.0;
1009 let inner = a * a - (2.0 * a - 1.0) * (1.0 - 1.0 / price_frac);
1010 if inner < 0.0 {
1011 return Ok(CalcValue::Scalar(
1012 LiteralValue::Error(ExcelError::new_num()),
1013 ));
1014 }
1015 (-2.0 * a + 2.0 * inner.sqrt()) / (2.0 * a - 1.0)
1016 };
1017
1018 Ok(CalcValue::Scalar(LiteralValue::Number(result)))
1019 }
1020}
1021
1022/// Returns price per $100 face value for a US Treasury bill.
1023///
1024/// `TBILLPRICE` computes the dollar price from a discount rate using
1025/// `price = 100 * (1 - discount * DSM / 360)`.
1026///
1027/// # Remarks
1028/// - Date inputs are spreadsheet serial dates; `maturity` must be after `settlement`.
1029/// - The T-bill must mature within one year of settlement (DSM <= 365).
1030/// - `discount` is a decimal discount rate; must be positive.
1031/// - Returns `#NUM!` for invalid dates, non-positive discount, or DSM out of range.
1032///
1033/// # Examples
1034/// ```excel
1035/// =TBILLPRICE(DATE(2024,1,1), DATE(2024,4,1), 0.038)
1036/// ```
1037///
1038/// ```yaml,sandbox
1039/// title: "Price a 91-day T-bill"
1040/// formula: '=TBILLPRICE(DATE(2024,1,1), DATE(2024,4,1), 0.038)'
1041/// expected: 99.03944444444444
1042/// ```
1043///
1044/// ```yaml,docs
1045/// related:
1046/// - TBILLEQ
1047/// - TBILLYIELD
1048/// - PRICE
1049/// faq:
1050/// - q: "What does TBILLPRICE quote?"
1051/// a: "The result is the dollar price per 100 of face value, following Excel's Treasury-bill convention."
1052/// ```
1053#[derive(Debug)]
1054pub struct TbillpriceFn;
1055
1056/// [formualizer-docgen:schema:start]
1057/// Name: TBILLPRICE
1058/// Type: TbillpriceFn
1059/// Min args: 3
1060/// Max args: 3
1061/// Variadic: false
1062/// Signature: TBILLPRICE(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar)
1063/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
1064/// Caps: PURE
1065/// [formualizer-docgen:schema:end]
1066impl Function for TbillpriceFn {
1067 func_caps!(PURE);
1068 fn name(&self) -> &'static str {
1069 "TBILLPRICE"
1070 }
1071 fn min_args(&self) -> usize {
1072 3
1073 }
1074 fn arg_schema(&self) -> &'static [ArgSchema] {
1075 use std::sync::LazyLock;
1076 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
1077 vec![
1078 ArgSchema::number_lenient_scalar(), // settlement
1079 ArgSchema::number_lenient_scalar(), // maturity
1080 ArgSchema::number_lenient_scalar(), // discount
1081 ]
1082 });
1083 &SCHEMA[..]
1084 }
1085 fn eval<'a, 'b, 'c>(
1086 &self,
1087 args: &'c [ArgumentHandle<'a, 'b>],
1088 _ctx: &dyn FunctionContext<'b>,
1089 ) -> Result<CalcValue<'b>, ExcelError> {
1090 let settlement_serial = coerce_num(&args[0])?;
1091 let maturity_serial = coerce_num(&args[1])?;
1092 let discount = coerce_num(&args[2])?;
1093
1094 if discount <= 0.0 {
1095 return Ok(CalcValue::Scalar(
1096 LiteralValue::Error(ExcelError::new_num()),
1097 ));
1098 }
1099
1100 let settlement = serial_to_date(settlement_serial)?;
1101 let maturity = serial_to_date(maturity_serial)?;
1102
1103 if maturity <= settlement {
1104 return Ok(CalcValue::Scalar(
1105 LiteralValue::Error(ExcelError::new_num()),
1106 ));
1107 }
1108
1109 let dsm = (maturity - settlement).num_days() as f64;
1110 if dsm > 365.0 || dsm <= 0.0 {
1111 return Ok(CalcValue::Scalar(
1112 LiteralValue::Error(ExcelError::new_num()),
1113 ));
1114 }
1115
1116 let price = 100.0 * (1.0 - discount * dsm / 360.0);
1117 Ok(CalcValue::Scalar(LiteralValue::Number(price)))
1118 }
1119}
1120
1121/// Returns the yield for a US Treasury bill.
1122///
1123/// `TBILLYIELD` computes the discount-rate yield from a T-bill's price using
1124/// `yield = (100 - price) / price * (360 / DSM)`.
1125///
1126/// # Remarks
1127/// - Date inputs are spreadsheet serial dates; `maturity` must be after `settlement`.
1128/// - The T-bill must mature within one year of settlement (DSM <= 365).
1129/// - `price` is the dollar price per $100 face value; must be positive.
1130/// - Returns `#NUM!` for invalid dates, non-positive price, or DSM out of range.
1131///
1132/// # Examples
1133/// ```excel
1134/// =TBILLYIELD(DATE(2024,1,1), DATE(2024,4,1), 98.5)
1135/// ```
1136///
1137/// ```yaml,sandbox
1138/// title: "Yield from quoted T-bill price"
1139/// formula: '=TBILLYIELD(DATE(2024,1,1), DATE(2024,4,1), 98.5)'
1140/// expected: 0.060244324203715074
1141/// ```
1142///
1143/// ```yaml,docs
1144/// related:
1145/// - TBILLPRICE
1146/// - TBILLEQ
1147/// - YIELD
1148/// faq:
1149/// - q: "Is TBILLYIELD the same as bond-equivalent yield?"
1150/// a: "No. TBILLYIELD gives the bill's discount-rate yield, while TBILLEQ converts that pricing into a bond-equivalent yield."
1151/// ```
1152#[derive(Debug)]
1153pub struct TbillyieldFn;
1154
1155/// [formualizer-docgen:schema:start]
1156/// Name: TBILLYIELD
1157/// Type: TbillyieldFn
1158/// Min args: 3
1159/// Max args: 3
1160/// Variadic: false
1161/// Signature: TBILLYIELD(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar)
1162/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
1163/// Caps: PURE
1164/// [formualizer-docgen:schema:end]
1165impl Function for TbillyieldFn {
1166 func_caps!(PURE);
1167 fn name(&self) -> &'static str {
1168 "TBILLYIELD"
1169 }
1170 fn min_args(&self) -> usize {
1171 3
1172 }
1173 fn arg_schema(&self) -> &'static [ArgSchema] {
1174 use std::sync::LazyLock;
1175 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
1176 vec![
1177 ArgSchema::number_lenient_scalar(), // settlement
1178 ArgSchema::number_lenient_scalar(), // maturity
1179 ArgSchema::number_lenient_scalar(), // price
1180 ]
1181 });
1182 &SCHEMA[..]
1183 }
1184 fn eval<'a, 'b, 'c>(
1185 &self,
1186 args: &'c [ArgumentHandle<'a, 'b>],
1187 _ctx: &dyn FunctionContext<'b>,
1188 ) -> Result<CalcValue<'b>, ExcelError> {
1189 let settlement_serial = coerce_num(&args[0])?;
1190 let maturity_serial = coerce_num(&args[1])?;
1191 let price = coerce_num(&args[2])?;
1192
1193 if price <= 0.0 {
1194 return Ok(CalcValue::Scalar(
1195 LiteralValue::Error(ExcelError::new_num()),
1196 ));
1197 }
1198
1199 let settlement = serial_to_date(settlement_serial)?;
1200 let maturity = serial_to_date(maturity_serial)?;
1201
1202 if maturity <= settlement {
1203 return Ok(CalcValue::Scalar(
1204 LiteralValue::Error(ExcelError::new_num()),
1205 ));
1206 }
1207
1208 let dsm = (maturity - settlement).num_days() as f64;
1209 if dsm > 365.0 || dsm <= 0.0 {
1210 return Ok(CalcValue::Scalar(
1211 LiteralValue::Error(ExcelError::new_num()),
1212 ));
1213 }
1214
1215 let yld = (100.0 - price) / price * (360.0 / dsm);
1216 Ok(CalcValue::Scalar(LiteralValue::Number(yld)))
1217 }
1218}
1219
1220pub fn register_builtins() {
1221 use std::sync::Arc;
1222 crate::function_registry::register_function(Arc::new(AccrintFn));
1223 crate::function_registry::register_function(Arc::new(AccrintmFn));
1224 crate::function_registry::register_function(Arc::new(PriceFn));
1225 crate::function_registry::register_function(Arc::new(YieldFn));
1226 crate::function_registry::register_function(Arc::new(TbilleqFn));
1227 crate::function_registry::register_function(Arc::new(TbillpriceFn));
1228 crate::function_registry::register_function(Arc::new(TbillyieldFn));
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233 use super::*;
1234
1235 #[test]
1236 fn year_fraction_actual_actual_prorates_cross_year_ranges() {
1237 let start = NaiveDate::from_ymd_opt(2023, 12, 1).unwrap();
1238 let end = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
1239 let expected = 31.0 / 365.0 + 60.0 / 366.0;
1240 let actual = year_fraction(&start, &end, DayCountBasis::ActualActual);
1241 assert!(
1242 (actual - expected).abs() < 1e-12,
1243 "expected {expected}, got {actual}"
1244 );
1245 }
1246
1247 #[test]
1248 fn year_fraction_actual_actual_is_sign_symmetric() {
1249 let start = NaiveDate::from_ymd_opt(2023, 12, 1).unwrap();
1250 let end = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
1251 let forward = year_fraction(&start, &end, DayCountBasis::ActualActual);
1252 let backward = year_fraction(&end, &start, DayCountBasis::ActualActual);
1253 assert!((forward + backward).abs() < 1e-12);
1254 }
1255}