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/// Get the annual basis (denominator for year fraction)
124fn annual_basis(basis: DayCountBasis, start: &NaiveDate, end: &NaiveDate) -> f64 {
125    match basis {
126        DayCountBasis::UsNasd30360 | DayCountBasis::European30360 => 360.0,
127        DayCountBasis::Actual360 => 360.0,
128        DayCountBasis::Actual365 => 365.0,
129        DayCountBasis::ActualActual => {
130            // Determine the average year length based on leap years in the period
131            let sy = start.year();
132            let ey = end.year();
133            if sy == ey {
134                if is_leap_year(sy) { 366.0 } else { 365.0 }
135            } else {
136                // Average across years
137                let mut total_days = 0.0;
138                let mut years = 0;
139                for y in sy..=ey {
140                    total_days += if is_leap_year(y) { 366.0 } else { 365.0 };
141                    years += 1;
142                }
143                total_days / years as f64
144            }
145        }
146    }
147}
148
149/// Calculate the year fraction between two dates
150fn year_fraction(start: &NaiveDate, end: &NaiveDate, basis: DayCountBasis) -> f64 {
151    let days = days_between(start, end, basis) as f64;
152    let annual = annual_basis(basis, start, end);
153    days / annual
154}
155
156/// Find the coupon date before settlement date
157fn coupon_date_before(settlement: &NaiveDate, maturity: &NaiveDate, frequency: i32) -> NaiveDate {
158    let months_between_coupons = 12 / frequency;
159    let mut coupon_date = *maturity;
160
161    // Work backwards from maturity to find the coupon date just before settlement
162    while coupon_date >= *settlement {
163        coupon_date = add_months(&coupon_date, -months_between_coupons);
164    }
165    coupon_date
166}
167
168/// Find the coupon date after settlement date
169fn coupon_date_after(settlement: &NaiveDate, maturity: &NaiveDate, frequency: i32) -> NaiveDate {
170    let months_between_coupons = 12 / frequency;
171    let prev_coupon = coupon_date_before(settlement, maturity, frequency);
172    add_months(&prev_coupon, months_between_coupons)
173}
174
175/// Add months to a date, handling end-of-month adjustments
176fn add_months(date: &NaiveDate, months: i32) -> NaiveDate {
177    let total_months = date.year() * 12 + date.month() as i32 - 1 + months;
178    let new_year = total_months / 12;
179    let new_month = (total_months % 12 + 1) as u32;
180
181    // Try to keep the same day, but cap at month's end
182    let mut new_day = date.day();
183    loop {
184        if let Some(d) = NaiveDate::from_ymd_opt(new_year, new_month, new_day) {
185            return d;
186        }
187        new_day -= 1;
188        if new_day == 0 {
189            // Fallback - should never reach here
190            return NaiveDate::from_ymd_opt(new_year, new_month, 1).unwrap();
191        }
192    }
193}
194
195/// Count the number of coupons remaining
196fn coupons_remaining(settlement: &NaiveDate, maturity: &NaiveDate, frequency: i32) -> i32 {
197    let months_between_coupons = 12 / frequency;
198    let mut count = 0;
199    let mut coupon_date = coupon_date_after(settlement, maturity, frequency);
200
201    while coupon_date <= *maturity {
202        count += 1;
203        coupon_date = add_months(&coupon_date, months_between_coupons);
204    }
205    count
206}
207
208/// Returns accrued interest for a coupon-bearing security.
209///
210/// `ACCRINT` calculates interest from either `issue` or the previous coupon date up to
211/// `settlement`, depending on `calc_method`.
212///
213/// # Remarks
214/// - Date inputs are spreadsheet serial dates; `settlement` must be after `issue`.
215/// - `rate` is the annual coupon rate as a decimal (for example, `0.06` for 6%), and `par` is principal amount; both must be positive.
216/// - `frequency` must be `1` (annual), `2` (semiannual), or `4` (quarterly).
217/// - `basis` codes: `0=US(NASD)30/360`, `1=Actual/Actual`, `2=Actual/360`, `3=Actual/365`, `4=European30/360`.
218/// - `calc_method`: non-zero accrues from `issue`; `0` accrues from the previous coupon date.
219/// - Return value is in the same currency units as `par` and is positive for valid positive inputs.
220///
221/// # Examples
222///
223/// ```yaml,sandbox
224/// title: "Accrue from issue date (default calc_method)"
225/// formula: "=ACCRINT(DATE(2024,1,1), DATE(2024,7,1), DATE(2024,7,1), 0.06, 1000, 2, 0)"
226/// expected: 30
227/// ```
228///
229/// ```yaml,sandbox
230/// title: "Accrue from previous coupon (calc_method = 0)"
231/// formula: "=ACCRINT(DATE(2024,1,1), DATE(2024,7,1), DATE(2024,10,1), 0.08, 1000, 2, 0, 0)"
232/// expected: 20
233/// ```
234/// ```yaml,docs
235/// related:
236///   - ACCRINTM
237///   - PRICE
238///   - YIELD
239/// faq:
240///   - q: "When does `calc_method` change the result?"
241///     a: "`calc_method=0` accrues from the previous coupon date; any non-zero value accrues from `issue`."
242///   - q: "Which inputs return `#NUM!`?"
243///     a: "Invalid `basis`, non-positive `rate`/`par`, unsupported `frequency`, or `settlement <= issue` return `#NUM!`."
244/// ```
245#[derive(Debug)]
246pub struct AccrintFn;
247
248/// [formualizer-docgen:schema:start]
249/// Name: ACCRINT
250/// Type: AccrintFn
251/// Min args: 6
252/// Max args: variadic
253/// Variadic: true
254/// 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)
255/// 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}
256/// Caps: PURE
257/// [formualizer-docgen:schema:end]
258impl Function for AccrintFn {
259    func_caps!(PURE);
260    fn name(&self) -> &'static str {
261        "ACCRINT"
262    }
263    fn min_args(&self) -> usize {
264        6
265    }
266    fn variadic(&self) -> bool {
267        true
268    }
269    fn arg_schema(&self) -> &'static [ArgSchema] {
270        use std::sync::LazyLock;
271        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
272            vec![
273                ArgSchema::number_lenient_scalar(), // issue
274                ArgSchema::number_lenient_scalar(), // first_interest
275                ArgSchema::number_lenient_scalar(), // settlement
276                ArgSchema::number_lenient_scalar(), // rate
277                ArgSchema::number_lenient_scalar(), // par
278                ArgSchema::number_lenient_scalar(), // frequency
279                ArgSchema::number_lenient_scalar(), // basis (optional)
280                ArgSchema::number_lenient_scalar(), // calc_method (optional)
281            ]
282        });
283        &SCHEMA[..]
284    }
285    fn eval<'a, 'b, 'c>(
286        &self,
287        args: &'c [ArgumentHandle<'a, 'b>],
288        _ctx: &dyn FunctionContext<'b>,
289    ) -> Result<CalcValue<'b>, ExcelError> {
290        // Check minimum required arguments
291        if args.len() < 6 {
292            return Ok(CalcValue::Scalar(LiteralValue::Error(
293                ExcelError::new_value(),
294            )));
295        }
296
297        let issue_serial = coerce_num(&args[0])?;
298        let first_interest_serial = coerce_num(&args[1])?;
299        let settlement_serial = coerce_num(&args[2])?;
300        let rate = coerce_num(&args[3])?;
301        let par = coerce_num(&args[4])?;
302        let frequency = coerce_num(&args[5])?.trunc() as i32;
303        let basis_int = if args.len() > 6 {
304            coerce_num(&args[6])?.trunc() as i32
305        } else {
306            0
307        };
308        let calc_method = if args.len() > 7 {
309            coerce_num(&args[7])?.trunc() as i32
310        } else {
311            1
312        };
313
314        // Validate inputs
315        if rate <= 0.0 || par <= 0.0 {
316            return Ok(CalcValue::Scalar(
317                LiteralValue::Error(ExcelError::new_num()),
318            ));
319        }
320        if frequency != 1 && frequency != 2 && frequency != 4 {
321            return Ok(CalcValue::Scalar(
322                LiteralValue::Error(ExcelError::new_num()),
323            ));
324        }
325
326        let basis = DayCountBasis::from_int(basis_int)?;
327
328        let issue = serial_to_date(issue_serial)?;
329        let first_interest = serial_to_date(first_interest_serial)?;
330        let settlement = serial_to_date(settlement_serial)?;
331
332        // settlement must be after issue
333        if settlement <= issue {
334            return Ok(CalcValue::Scalar(
335                LiteralValue::Error(ExcelError::new_num()),
336            ));
337        }
338
339        // Calculate accrued interest
340        // If calc_method is TRUE (or 1), calculate from issue to settlement
341        // If calc_method is FALSE (or 0), calculate from last coupon to settlement
342        let accrued_interest = if calc_method != 0 {
343            // Calculate from issue date to settlement date
344            // ACCRINT = par * rate * year_fraction(issue, settlement)
345            let yf = year_fraction(&issue, &settlement, basis);
346            par * rate * yf
347        } else {
348            // Calculate from last coupon date to settlement
349            let prev_coupon = coupon_date_before(&settlement, &first_interest, frequency);
350            let start_date = if prev_coupon < issue {
351                issue
352            } else {
353                prev_coupon
354            };
355            let yf = year_fraction(&start_date, &settlement, basis);
356            par * rate * yf
357        };
358
359        Ok(CalcValue::Scalar(LiteralValue::Number(accrued_interest)))
360    }
361}
362
363/// Returns accrued interest for a security that pays interest at maturity.
364///
365/// `ACCRINTM` accrues from `issue` to `settlement` with no periodic coupon schedule.
366///
367/// # Remarks
368/// - Date inputs are spreadsheet serial dates and must satisfy `settlement > issue`.
369/// - `rate` is an annual decimal rate (for example, `0.05` for 5%), and `par` must be positive.
370/// - `basis` codes: `0=US(NASD)30/360`, `1=Actual/Actual`, `2=Actual/360`, `3=Actual/365`, `4=European30/360`.
371/// - Return value is accrued interest amount in the same currency units as `par`.
372/// - With positive `rate` and `par`, the result is positive.
373///
374/// # Examples
375///
376/// ```yaml,sandbox
377/// title: "One full 30/360 year"
378/// formula: "=ACCRINTM(DATE(2024,1,1), DATE(2025,1,1), 0.05, 1000, 0)"
379/// expected: 50
380/// ```
381///
382/// ```yaml,sandbox
383/// title: "European 30/360 half-year accrual"
384/// formula: "=ACCRINTM(DATE(2024,2,28), DATE(2024,8,28), 0.04, 1000, 4)"
385/// expected: 20
386/// ```
387/// ```yaml,docs
388/// related:
389///   - ACCRINT
390///   - PRICE
391///   - YIELD
392/// faq:
393///   - q: "Does `ACCRINTM` use coupon frequency?"
394///     a: "No. It accrues directly from `issue` to `settlement` with no periodic coupon schedule."
395///   - q: "What causes `#NUM!`?"
396///     a: "Invalid `basis`, non-positive `rate`/`par`, or `settlement <= issue`."
397/// ```
398#[derive(Debug)]
399pub struct AccrintmFn;
400
401/// [formualizer-docgen:schema:start]
402/// Name: ACCRINTM
403/// Type: AccrintmFn
404/// Min args: 4
405/// Max args: variadic
406/// Variadic: true
407/// Signature: ACCRINTM(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5...: number@scalar)
408/// 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}
409/// Caps: PURE
410/// [formualizer-docgen:schema:end]
411impl Function for AccrintmFn {
412    func_caps!(PURE);
413    fn name(&self) -> &'static str {
414        "ACCRINTM"
415    }
416    fn min_args(&self) -> usize {
417        4
418    }
419    fn variadic(&self) -> bool {
420        true
421    }
422    fn arg_schema(&self) -> &'static [ArgSchema] {
423        use std::sync::LazyLock;
424        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
425            vec![
426                ArgSchema::number_lenient_scalar(), // issue
427                ArgSchema::number_lenient_scalar(), // settlement
428                ArgSchema::number_lenient_scalar(), // rate
429                ArgSchema::number_lenient_scalar(), // par
430                ArgSchema::number_lenient_scalar(), // basis (optional)
431            ]
432        });
433        &SCHEMA[..]
434    }
435    fn eval<'a, 'b, 'c>(
436        &self,
437        args: &'c [ArgumentHandle<'a, 'b>],
438        _ctx: &dyn FunctionContext<'b>,
439    ) -> Result<CalcValue<'b>, ExcelError> {
440        // Check minimum required arguments
441        if args.len() < 4 {
442            return Ok(CalcValue::Scalar(LiteralValue::Error(
443                ExcelError::new_value(),
444            )));
445        }
446
447        let issue_serial = coerce_num(&args[0])?;
448        let settlement_serial = coerce_num(&args[1])?;
449        let rate = coerce_num(&args[2])?;
450        let par = coerce_num(&args[3])?;
451        let basis_int = if args.len() > 4 {
452            coerce_num(&args[4])?.trunc() as i32
453        } else {
454            0
455        };
456
457        // Validate inputs
458        if rate <= 0.0 || par <= 0.0 {
459            return Ok(CalcValue::Scalar(
460                LiteralValue::Error(ExcelError::new_num()),
461            ));
462        }
463
464        let basis = DayCountBasis::from_int(basis_int)?;
465
466        let issue = serial_to_date(issue_serial)?;
467        let settlement = serial_to_date(settlement_serial)?;
468
469        // settlement must be after issue
470        if settlement <= issue {
471            return Ok(CalcValue::Scalar(
472                LiteralValue::Error(ExcelError::new_num()),
473            ));
474        }
475
476        // ACCRINTM = par * rate * year_fraction(issue, settlement)
477        let yf = year_fraction(&issue, &settlement, basis);
478        let accrued_interest = par * rate * yf;
479
480        Ok(CalcValue::Scalar(LiteralValue::Number(accrued_interest)))
481    }
482}
483
484/// Returns clean price per 100 face value for a coupon-paying security.
485///
486/// `PRICE` discounts remaining coupons and redemption to settlement and subtracts accrued
487/// coupon interest according to the chosen day-count basis.
488///
489/// # Remarks
490/// - Date inputs are spreadsheet serial dates and must satisfy `maturity > settlement`.
491/// - `rate` (coupon) and `yld` (yield) are annual decimal rates; `redemption` is amount paid per 100 face value at maturity.
492/// - `frequency` must be `1` (annual), `2` (semiannual), or `4` (quarterly).
493/// - `basis` codes: `0=US(NASD)30/360`, `1=Actual/Actual`, `2=Actual/360`, `3=Actual/365`, `4=European30/360`.
494/// - Return value is quoted per 100 face value; positive inputs usually produce a positive price.
495/// - Coupon schedule is derived by stepping backward from `maturity`, with end-of-month adjustment behavior in month arithmetic.
496///
497/// # Examples
498///
499/// ```yaml,sandbox
500/// title: "Single remaining coupon period"
501/// formula: "=PRICE(DATE(2024,4,1), DATE(2024,7,1), 0.06, 0.05, 100, 2, 0)"
502/// expected: 100.2283950617284
503/// ```
504///
505/// ```yaml,sandbox
506/// title: "Par bond when coupon rate equals yield"
507/// formula: "=PRICE(DATE(2024,3,1), DATE(2026,3,1), 0.05, 0.05, 100, 2, 0)"
508/// expected: 100
509/// ```
510/// ```yaml,docs
511/// related:
512///   - YIELD
513///   - ACCRINT
514///   - ACCRINTM
515/// faq:
516///   - q: "Why is `PRICE` quoted per 100 even if my bond face value differs?"
517///     a: "This implementation follows Excel quoting conventions and returns clean price per 100 face value."
518///   - q: "Which domain checks return `#NUM!`?"
519///     a: "`maturity <= settlement`, invalid `basis`, unsupported `frequency`, negative `rate`/`yld`, or non-positive `redemption`."
520/// ```
521#[derive(Debug)]
522pub struct PriceFn;
523
524/// [formualizer-docgen:schema:start]
525/// Name: PRICE
526/// Type: PriceFn
527/// Min args: 6
528/// Max args: variadic
529/// Variadic: true
530/// Signature: PRICE(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6: number@scalar, arg7...: number@scalar)
531/// 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}
532/// Caps: PURE
533/// [formualizer-docgen:schema:end]
534impl Function for PriceFn {
535    func_caps!(PURE);
536    fn name(&self) -> &'static str {
537        "PRICE"
538    }
539    fn min_args(&self) -> usize {
540        6
541    }
542    fn variadic(&self) -> bool {
543        true
544    }
545    fn arg_schema(&self) -> &'static [ArgSchema] {
546        use std::sync::LazyLock;
547        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
548            vec![
549                ArgSchema::number_lenient_scalar(), // settlement
550                ArgSchema::number_lenient_scalar(), // maturity
551                ArgSchema::number_lenient_scalar(), // rate (coupon rate)
552                ArgSchema::number_lenient_scalar(), // yld (yield)
553                ArgSchema::number_lenient_scalar(), // redemption
554                ArgSchema::number_lenient_scalar(), // frequency
555                ArgSchema::number_lenient_scalar(), // basis (optional)
556            ]
557        });
558        &SCHEMA[..]
559    }
560    fn eval<'a, 'b, 'c>(
561        &self,
562        args: &'c [ArgumentHandle<'a, 'b>],
563        _ctx: &dyn FunctionContext<'b>,
564    ) -> Result<CalcValue<'b>, ExcelError> {
565        // Check minimum required arguments
566        if args.len() < 6 {
567            return Ok(CalcValue::Scalar(LiteralValue::Error(
568                ExcelError::new_value(),
569            )));
570        }
571
572        let settlement_serial = coerce_num(&args[0])?;
573        let maturity_serial = coerce_num(&args[1])?;
574        let rate = coerce_num(&args[2])?;
575        let yld = coerce_num(&args[3])?;
576        let redemption = coerce_num(&args[4])?;
577        let frequency = coerce_num(&args[5])?.trunc() as i32;
578        let basis_int = if args.len() > 6 {
579            coerce_num(&args[6])?.trunc() as i32
580        } else {
581            0
582        };
583
584        // Validate inputs
585        if rate < 0.0 || yld < 0.0 || redemption <= 0.0 {
586            return Ok(CalcValue::Scalar(
587                LiteralValue::Error(ExcelError::new_num()),
588            ));
589        }
590        if frequency != 1 && frequency != 2 && frequency != 4 {
591            return Ok(CalcValue::Scalar(
592                LiteralValue::Error(ExcelError::new_num()),
593            ));
594        }
595
596        let basis = DayCountBasis::from_int(basis_int)?;
597
598        let settlement = serial_to_date(settlement_serial)?;
599        let maturity = serial_to_date(maturity_serial)?;
600
601        // maturity must be after settlement
602        if maturity <= settlement {
603            return Ok(CalcValue::Scalar(
604                LiteralValue::Error(ExcelError::new_num()),
605            ));
606        }
607
608        let price = calculate_price(
609            &settlement,
610            &maturity,
611            rate,
612            yld,
613            redemption,
614            frequency,
615            basis,
616        );
617        Ok(CalcValue::Scalar(LiteralValue::Number(price)))
618    }
619}
620
621/// Calculate bond price using standard bond pricing formula
622fn calculate_price(
623    settlement: &NaiveDate,
624    maturity: &NaiveDate,
625    rate: f64,
626    yld: f64,
627    redemption: f64,
628    frequency: i32,
629    basis: DayCountBasis,
630) -> f64 {
631    let n = coupons_remaining(settlement, maturity, frequency);
632    let coupon = 100.0 * rate / frequency as f64;
633
634    // Find previous and next coupon dates
635    let next_coupon = coupon_date_after(settlement, maturity, frequency);
636    let prev_coupon = coupon_date_before(settlement, maturity, frequency);
637
638    // Calculate fraction of period from settlement to next coupon
639    let days_to_next = days_between(settlement, &next_coupon, basis) as f64;
640    let days_in_period = days_between(&prev_coupon, &next_coupon, basis) as f64;
641
642    let dsn = if days_in_period > 0.0 {
643        days_to_next / days_in_period
644    } else {
645        0.0
646    };
647
648    let yld_per_period = yld / frequency as f64;
649
650    if n == 1 {
651        // Short first period (single coupon remaining)
652        // Price = (redemption + coupon) / (1 + dsn * yld_per_period) - (1 - dsn) * coupon
653        (redemption + coupon) / (1.0 + dsn * yld_per_period) - (1.0 - dsn) * coupon
654    } else {
655        // Multiple coupons remaining
656        // Price = sum of discounted coupons + discounted redemption - accrued interest
657        let discount_factor = 1.0 + yld_per_period;
658
659        // Discount factor for first coupon (fractional period)
660        let first_discount = discount_factor.powf(dsn);
661
662        // Present value of coupon payments
663        let mut pv_coupons = 0.0;
664        for k in 0..n {
665            let discount = first_discount * discount_factor.powi(k);
666            pv_coupons += coupon / discount;
667        }
668
669        // Present value of redemption
670        let pv_redemption = redemption / (first_discount * discount_factor.powi(n - 1));
671
672        // Accrued interest (negative because we subtract it)
673        let accrued = (1.0 - dsn) * coupon;
674
675        pv_coupons + pv_redemption - accrued
676    }
677}
678
679/// Returns annual yield for a coupon-paying security from its market price.
680///
681/// `YIELD` solves for the annual rate that makes `PRICE(...)` match the input `pr`.
682///
683/// # Remarks
684/// - Date inputs are spreadsheet serial dates and must satisfy `maturity > settlement`.
685/// - `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.
686/// - `frequency` must be `1` (annual), `2` (semiannual), or `4` (quarterly).
687/// - `basis` codes: `0=US(NASD)30/360`, `1=Actual/Actual`, `2=Actual/360`, `3=Actual/365`, `4=European30/360`.
688/// - Result is an annualized decimal yield (for example, `0.05` means 5%).
689/// - This implementation uses Newton-Raphson iteration; if it cannot converge, it returns `#NUM!`.
690///
691/// # Examples
692///
693/// ```yaml,sandbox
694/// title: "Par price implies coupon-rate yield"
695/// formula: "=YIELD(DATE(2024,3,1), DATE(2026,3,1), 0.05, 100, 100, 2, 0)"
696/// expected: 0.05
697/// ```
698///
699/// ```yaml,sandbox
700/// title: "Yield recovered from a discounted price"
701/// formula: "=YIELD(DATE(2024,2,15), DATE(2027,2,15), 0.05, 97.2914042780609, 100, 2, 0)"
702/// expected: 0.06
703/// ```
704/// ```yaml,docs
705/// related:
706///   - PRICE
707///   - ACCRINT
708///   - ACCRINTM
709/// faq:
710///   - q: "What does the returned `YIELD` represent?"
711///     a: "It is an annualized decimal yield (for example, `0.06` means 6% per year)."
712///   - q: "When does `YIELD` return `#NUM!` besides invalid inputs?"
713///     a: "The Newton-Raphson solve can fail to converge or hit an unstable derivative; in those cases it returns `#NUM!`."
714/// ```
715#[derive(Debug)]
716pub struct YieldFn;
717
718/// [formualizer-docgen:schema:start]
719/// Name: YIELD
720/// Type: YieldFn
721/// Min args: 6
722/// Max args: variadic
723/// Variadic: true
724/// Signature: YIELD(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6: number@scalar, arg7...: number@scalar)
725/// 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}
726/// Caps: PURE
727/// [formualizer-docgen:schema:end]
728impl Function for YieldFn {
729    func_caps!(PURE);
730    fn name(&self) -> &'static str {
731        "YIELD"
732    }
733    fn min_args(&self) -> usize {
734        6
735    }
736    fn variadic(&self) -> bool {
737        true
738    }
739    fn arg_schema(&self) -> &'static [ArgSchema] {
740        use std::sync::LazyLock;
741        static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
742            vec![
743                ArgSchema::number_lenient_scalar(), // settlement
744                ArgSchema::number_lenient_scalar(), // maturity
745                ArgSchema::number_lenient_scalar(), // rate (coupon rate)
746                ArgSchema::number_lenient_scalar(), // pr (price)
747                ArgSchema::number_lenient_scalar(), // redemption
748                ArgSchema::number_lenient_scalar(), // frequency
749                ArgSchema::number_lenient_scalar(), // basis (optional)
750            ]
751        });
752        &SCHEMA[..]
753    }
754    fn eval<'a, 'b, 'c>(
755        &self,
756        args: &'c [ArgumentHandle<'a, 'b>],
757        _ctx: &dyn FunctionContext<'b>,
758    ) -> Result<CalcValue<'b>, ExcelError> {
759        // Check minimum required arguments
760        if args.len() < 6 {
761            return Ok(CalcValue::Scalar(LiteralValue::Error(
762                ExcelError::new_value(),
763            )));
764        }
765
766        let settlement_serial = coerce_num(&args[0])?;
767        let maturity_serial = coerce_num(&args[1])?;
768        let rate = coerce_num(&args[2])?;
769        let pr = coerce_num(&args[3])?;
770        let redemption = coerce_num(&args[4])?;
771        let frequency = coerce_num(&args[5])?.trunc() as i32;
772        let basis_int = if args.len() > 6 {
773            coerce_num(&args[6])?.trunc() as i32
774        } else {
775            0
776        };
777
778        // Validate inputs
779        if rate < 0.0 || pr <= 0.0 || redemption <= 0.0 {
780            return Ok(CalcValue::Scalar(
781                LiteralValue::Error(ExcelError::new_num()),
782            ));
783        }
784        if frequency != 1 && frequency != 2 && frequency != 4 {
785            return Ok(CalcValue::Scalar(
786                LiteralValue::Error(ExcelError::new_num()),
787            ));
788        }
789
790        let basis = DayCountBasis::from_int(basis_int)?;
791
792        let settlement = serial_to_date(settlement_serial)?;
793        let maturity = serial_to_date(maturity_serial)?;
794
795        // maturity must be after settlement
796        if maturity <= settlement {
797            return Ok(CalcValue::Scalar(
798                LiteralValue::Error(ExcelError::new_num()),
799            ));
800        }
801
802        // Use Newton-Raphson to find yield where price = target price
803        let yld = calculate_yield(
804            &settlement,
805            &maturity,
806            rate,
807            pr,
808            redemption,
809            frequency,
810            basis,
811        );
812
813        match yld {
814            Some(y) => Ok(CalcValue::Scalar(LiteralValue::Number(y))),
815            None => Ok(CalcValue::Scalar(
816                LiteralValue::Error(ExcelError::new_num()),
817            )),
818        }
819    }
820}
821
822/// Calculate yield using Newton-Raphson iteration
823fn calculate_yield(
824    settlement: &NaiveDate,
825    maturity: &NaiveDate,
826    rate: f64,
827    target_price: f64,
828    redemption: f64,
829    frequency: i32,
830    basis: DayCountBasis,
831) -> Option<f64> {
832    const MAX_ITER: i32 = 100;
833    const EPSILON: f64 = 1e-10;
834
835    // Initial guess based on coupon rate
836    let mut yld = rate;
837    if yld == 0.0 {
838        yld = 0.05; // Default guess if rate is 0
839    }
840
841    for _ in 0..MAX_ITER {
842        let price = calculate_price(
843            settlement, maturity, rate, yld, redemption, frequency, basis,
844        );
845        let diff = price - target_price;
846
847        if diff.abs() < EPSILON {
848            return Some(yld);
849        }
850
851        // Calculate derivative numerically
852        let delta = 0.0001;
853        let price_up = calculate_price(
854            settlement,
855            maturity,
856            rate,
857            yld + delta,
858            redemption,
859            frequency,
860            basis,
861        );
862        let derivative = (price_up - price) / delta;
863
864        if derivative.abs() < EPSILON {
865            return None;
866        }
867
868        let new_yld = yld - diff / derivative;
869
870        // Prevent yield from going too negative
871        if new_yld < -0.99 {
872            yld = -0.99;
873        } else {
874            yld = new_yld;
875        }
876
877        // Prevent yield from going too high
878        if yld > 10.0 {
879            yld = 10.0;
880        }
881    }
882
883    // If close enough after max iterations, return the result
884    let final_price = calculate_price(
885        settlement, maturity, rate, yld, redemption, frequency, basis,
886    );
887    if (final_price - target_price).abs() < 0.01 {
888        Some(yld)
889    } else {
890        None
891    }
892}
893
894pub fn register_builtins() {
895    use std::sync::Arc;
896    crate::function_registry::register_function(Arc::new(AccrintFn));
897    crate::function_registry::register_function(Arc::new(AccrintmFn));
898    crate::function_registry::register_function(Arc::new(PriceFn));
899    crate::function_registry::register_function(Arc::new(YieldFn));
900}