Skip to main content

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}