formualizer_eval/builtins/financial/tvm.rs
1//! Time Value of Money functions: PMT, PV, FV, NPV, NPER, RATE, IPMT, PPMT, XNPV, XIRR, DOLLARDE, DOLLARFR
2
3use crate::args::ArgSchema;
4use crate::function::Function;
5use crate::traits::{ArgumentHandle, CalcValue, FunctionContext};
6use formualizer_common::{ExcelError, ExcelErrorKind, LiteralValue};
7use formualizer_macros::func_caps;
8
9fn coerce_num(arg: &ArgumentHandle) -> Result<f64, ExcelError> {
10 let v = arg.value()?.into_literal();
11 coerce_literal_num(&v)
12}
13
14fn coerce_literal_num(v: &LiteralValue) -> Result<f64, ExcelError> {
15 match v {
16 LiteralValue::Number(f) => Ok(*f),
17 LiteralValue::Int(i) => Ok(*i as f64),
18 LiteralValue::Boolean(b) => Ok(if *b { 1.0 } else { 0.0 }),
19 LiteralValue::Empty => Ok(0.0),
20 LiteralValue::Error(e) => Err(e.clone()),
21 _ => Err(ExcelError::new_value()),
22 }
23}
24
25/// Calculates the constant payment amount for a fixed-rate annuity or loan.
26///
27/// Use this to solve for periodic payment size when rate, term, and present/future value
28/// targets are known.
29///
30/// # Remarks
31/// - `rate` is the interest rate per payment period (for example, annual rate / 12 for monthly payments).
32/// - Cash-flow sign convention: cash paid out is negative and cash received is positive.
33/// - `type = 0` means end-of-period payments; `type != 0` means beginning-of-period payments.
34/// - Returns `#NUM!` when `nper` is zero.
35/// - Propagates argument conversion and underlying value errors.
36///
37/// # Examples
38/// ```yaml,sandbox
39/// formula: =PMT(0.06/12, 360, 300000)
40/// result: -1798.6515754582708
41/// ```
42/// ```yaml,sandbox
43/// formula: =PMT(0.05/4, 20, -10000, 0, 1)
44/// result: 561.1890334005388
45/// ```
46/// ```yaml,docs
47/// related:
48/// - PV
49/// - FV
50/// - NPER
51/// - RATE
52/// faq:
53/// - q: "Why is `PMT` usually negative for a loan?"
54/// a: "TVM sign convention treats cash you pay as negative; with positive `pv`, payment outputs are typically negative."
55/// ```
56#[derive(Debug)]
57pub struct PmtFn;
58/// [formualizer-docgen:schema:start]
59/// Name: PMT
60/// Type: PmtFn
61/// Min args: 3
62/// Max args: variadic
63/// Variadic: true
64/// Signature: PMT(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5...: number@scalar)
65/// 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}
66/// Caps: PURE
67/// [formualizer-docgen:schema:end]
68impl Function for PmtFn {
69 func_caps!(PURE);
70 fn name(&self) -> &'static str {
71 "PMT"
72 }
73 fn min_args(&self) -> usize {
74 3
75 }
76 fn variadic(&self) -> bool {
77 true
78 }
79 fn arg_schema(&self) -> &'static [ArgSchema] {
80 use std::sync::LazyLock;
81 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
82 vec![
83 ArgSchema::number_lenient_scalar(), // rate
84 ArgSchema::number_lenient_scalar(), // nper
85 ArgSchema::number_lenient_scalar(), // pv
86 ArgSchema::number_lenient_scalar(), // fv (optional)
87 ArgSchema::number_lenient_scalar(), // type (optional)
88 ]
89 });
90 &SCHEMA[..]
91 }
92 fn eval<'a, 'b, 'c>(
93 &self,
94 args: &'c [ArgumentHandle<'a, 'b>],
95 _ctx: &dyn FunctionContext<'b>,
96 ) -> Result<CalcValue<'b>, ExcelError> {
97 let rate = coerce_num(&args[0])?;
98 let nper = coerce_num(&args[1])?;
99 let pv = coerce_num(&args[2])?;
100 let fv = if args.len() > 3 {
101 coerce_num(&args[3])?
102 } else {
103 0.0
104 };
105 let pmt_type = if args.len() > 4 {
106 coerce_num(&args[4])? as i32
107 } else {
108 0
109 };
110
111 if nper == 0.0 {
112 return Ok(CalcValue::Scalar(
113 LiteralValue::Error(ExcelError::new_num()),
114 ));
115 }
116
117 let pmt = if rate.abs() < 1e-10 {
118 // When rate is 0, PMT = -(pv + fv) / nper
119 -(pv + fv) / nper
120 } else {
121 // PMT = (rate * (pv * (1+rate)^nper + fv)) / ((1+rate)^nper - 1)
122 // With type adjustment for beginning of period
123 let factor = (1.0 + rate).powf(nper);
124 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
125 -(rate * (pv * factor + fv)) / ((factor - 1.0) * type_adj)
126 };
127
128 Ok(CalcValue::Scalar(LiteralValue::Number(pmt)))
129 }
130}
131
132/// Calculates present value from periodic cash flows at a fixed rate.
133///
134/// Use this to discount a regular payment stream and optional terminal value back to time zero.
135///
136/// # Remarks
137/// - `rate` is the discount rate per period.
138/// - Cash-flow sign convention: inflows are positive and outflows are negative.
139/// - `type = 0` assumes payments at period end; `type != 0` assumes period start.
140/// - When `rate` is zero, present value is computed with simple arithmetic (no discounting).
141/// - Returns argument-related errors if coercion fails or an input is an error value.
142///
143/// # Examples
144/// ```yaml,sandbox
145/// formula: =PV(0.06/12, 360, -1798.65157545827)
146/// result: 299999.9999999998
147/// ```
148/// ```yaml,sandbox
149/// formula: =PV(0, 10, -500)
150/// result: 5000
151/// ```
152/// ```yaml,docs
153/// related:
154/// - PMT
155/// - FV
156/// - NPER
157/// - RATE
158/// faq:
159/// - q: "How does `type` change `PV`?"
160/// a: "`type=0` discounts end-of-period payments, while non-zero `type` treats payments as beginning-of-period (annuity due)."
161/// ```
162#[derive(Debug)]
163pub struct PvFn;
164/// [formualizer-docgen:schema:start]
165/// Name: PV
166/// Type: PvFn
167/// Min args: 3
168/// Max args: variadic
169/// Variadic: true
170/// Signature: PV(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5...: number@scalar)
171/// 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}
172/// Caps: PURE
173/// [formualizer-docgen:schema:end]
174impl Function for PvFn {
175 func_caps!(PURE);
176 fn name(&self) -> &'static str {
177 "PV"
178 }
179 fn min_args(&self) -> usize {
180 3
181 }
182 fn variadic(&self) -> bool {
183 true
184 }
185 fn arg_schema(&self) -> &'static [ArgSchema] {
186 use std::sync::LazyLock;
187 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
188 vec![
189 ArgSchema::number_lenient_scalar(),
190 ArgSchema::number_lenient_scalar(),
191 ArgSchema::number_lenient_scalar(),
192 ArgSchema::number_lenient_scalar(),
193 ArgSchema::number_lenient_scalar(),
194 ]
195 });
196 &SCHEMA[..]
197 }
198 fn eval<'a, 'b, 'c>(
199 &self,
200 args: &'c [ArgumentHandle<'a, 'b>],
201 _ctx: &dyn FunctionContext<'b>,
202 ) -> Result<CalcValue<'b>, ExcelError> {
203 let rate = coerce_num(&args[0])?;
204 let nper = coerce_num(&args[1])?;
205 let pmt = coerce_num(&args[2])?;
206 let fv = if args.len() > 3 {
207 coerce_num(&args[3])?
208 } else {
209 0.0
210 };
211 let pmt_type = if args.len() > 4 {
212 coerce_num(&args[4])? as i32
213 } else {
214 0
215 };
216
217 let pv = if rate.abs() < 1e-10 {
218 -fv - pmt * nper
219 } else {
220 let factor = (1.0 + rate).powf(nper);
221 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
222 (-fv - pmt * type_adj * (factor - 1.0) / rate) / factor
223 };
224
225 Ok(CalcValue::Scalar(LiteralValue::Number(pv)))
226 }
227}
228
229/// Calculates future value from a fixed periodic rate and payment stream.
230///
231/// Use this to project an ending balance after compounding a present value and periodic payments.
232///
233/// # Remarks
234/// - `rate` is the interest rate per period.
235/// - Cash-flow sign convention: payments you make are negative; receipts are positive.
236/// - `type = 0` models end-of-period payments; `type != 0` models beginning-of-period payments.
237/// - When `rate` is zero, result is linear (`-pv - pmt * nper`).
238/// - Returns argument-related errors if coercion fails or an input is an error value.
239///
240/// # Examples
241/// ```yaml,sandbox
242/// formula: =FV(0.04/12, 120, -200)
243/// result: 29449.96094509572
244/// ```
245/// ```yaml,sandbox
246/// formula: =FV(0, 24, -150, 1000)
247/// result: 2600
248/// ```
249/// ```yaml,docs
250/// related:
251/// - PV
252/// - PMT
253/// - NPER
254/// - RATE
255/// faq:
256/// - q: "What happens when `rate` is zero in `FV`?"
257/// a: "It falls back to linear accumulation: `-pv - pmt * nper` with no compounding."
258/// ```
259#[derive(Debug)]
260pub struct FvFn;
261/// [formualizer-docgen:schema:start]
262/// Name: FV
263/// Type: FvFn
264/// Min args: 3
265/// Max args: variadic
266/// Variadic: true
267/// Signature: FV(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5...: number@scalar)
268/// 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}
269/// Caps: PURE
270/// [formualizer-docgen:schema:end]
271impl Function for FvFn {
272 func_caps!(PURE);
273 fn name(&self) -> &'static str {
274 "FV"
275 }
276 fn min_args(&self) -> usize {
277 3
278 }
279 fn variadic(&self) -> bool {
280 true
281 }
282 fn arg_schema(&self) -> &'static [ArgSchema] {
283 use std::sync::LazyLock;
284 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
285 vec![
286 ArgSchema::number_lenient_scalar(),
287 ArgSchema::number_lenient_scalar(),
288 ArgSchema::number_lenient_scalar(),
289 ArgSchema::number_lenient_scalar(),
290 ArgSchema::number_lenient_scalar(),
291 ]
292 });
293 &SCHEMA[..]
294 }
295 fn eval<'a, 'b, 'c>(
296 &self,
297 args: &'c [ArgumentHandle<'a, 'b>],
298 _ctx: &dyn FunctionContext<'b>,
299 ) -> Result<CalcValue<'b>, ExcelError> {
300 let rate = coerce_num(&args[0])?;
301 let nper = coerce_num(&args[1])?;
302 let pmt = coerce_num(&args[2])?;
303 let pv = if args.len() > 3 {
304 coerce_num(&args[3])?
305 } else {
306 0.0
307 };
308 let pmt_type = if args.len() > 4 {
309 coerce_num(&args[4])? as i32
310 } else {
311 0
312 };
313
314 let fv = if rate.abs() < 1e-10 {
315 -pv - pmt * nper
316 } else {
317 let factor = (1.0 + rate).powf(nper);
318 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
319 -pv * factor - pmt * type_adj * (factor - 1.0) / rate
320 };
321
322 Ok(CalcValue::Scalar(LiteralValue::Number(fv)))
323 }
324}
325
326/// Calculates net present value for equally spaced cash flows.
327///
328/// The first cash-flow argument is discounted one period from the present, matching spreadsheet
329/// `NPV` behavior for periodic series.
330///
331/// # Remarks
332/// - `rate` is the discount rate per period.
333/// - Cash-flow sign convention: investments/outflows are negative, returns/inflows are positive.
334/// - Non-numeric values are ignored; numeric values in arrays/ranges are consumed left-to-right.
335/// - Embedded error values inside provided cash-flow values are propagated as errors.
336/// - Returns argument coercion errors for invalid `rate` or direct scalar failures.
337///
338/// # Examples
339/// ```yaml,sandbox
340/// formula: =NPV(0.08, 4000, 5000, 6000)
341/// result: 12753.391251333636
342/// ```
343/// ```yaml,sandbox
344/// formula: =NPV(0.10, -5000, 2000, 2500, 3000)
345/// result: 1034.7653848780812
346/// ```
347/// ```yaml,docs
348/// related:
349/// - XNPV
350/// - IRR
351/// - MIRR
352/// faq:
353/// - q: "Is the first cash flow discounted at period 0 or period 1?"
354/// a: "`NPV` discounts the first supplied cash flow one full period, matching spreadsheet `NPV` behavior."
355/// ```
356#[derive(Debug)]
357pub struct NpvFn;
358/// [formualizer-docgen:schema:start]
359/// Name: NPV
360/// Type: NpvFn
361/// Min args: 2
362/// Max args: variadic
363/// Variadic: true
364/// Signature: NPV(arg1: number@scalar, arg2...: any@scalar)
365/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
366/// Caps: PURE
367/// [formualizer-docgen:schema:end]
368impl Function for NpvFn {
369 func_caps!(PURE);
370 fn name(&self) -> &'static str {
371 "NPV"
372 }
373 fn min_args(&self) -> usize {
374 2
375 }
376 fn variadic(&self) -> bool {
377 true
378 }
379 fn arg_schema(&self) -> &'static [ArgSchema] {
380 use std::sync::LazyLock;
381 static SCHEMA: LazyLock<Vec<ArgSchema>> =
382 LazyLock::new(|| vec![ArgSchema::number_lenient_scalar(), ArgSchema::any()]);
383 &SCHEMA[..]
384 }
385 fn eval<'a, 'b, 'c>(
386 &self,
387 args: &'c [ArgumentHandle<'a, 'b>],
388 _ctx: &dyn FunctionContext<'b>,
389 ) -> Result<CalcValue<'b>, ExcelError> {
390 let rate = coerce_num(&args[0])?;
391
392 let mut npv = 0.0;
393 let mut period = 1;
394
395 for arg in &args[1..] {
396 let v = arg.value()?.into_literal();
397 match v {
398 LiteralValue::Number(n) => {
399 npv += n / (1.0 + rate).powi(period);
400 period += 1;
401 }
402 LiteralValue::Int(i) => {
403 npv += (i as f64) / (1.0 + rate).powi(period);
404 period += 1;
405 }
406 LiteralValue::Error(e) => {
407 return Ok(CalcValue::Scalar(LiteralValue::Error(e)));
408 }
409 LiteralValue::Array(arr) => {
410 for row in arr {
411 for cell in row {
412 match cell {
413 LiteralValue::Number(n) => {
414 npv += n / (1.0 + rate).powi(period);
415 period += 1;
416 }
417 LiteralValue::Int(i) => {
418 npv += (i as f64) / (1.0 + rate).powi(period);
419 period += 1;
420 }
421 LiteralValue::Error(e) => {
422 return Ok(CalcValue::Scalar(LiteralValue::Error(e)));
423 }
424 _ => {} // Skip non-numeric values
425 }
426 }
427 }
428 }
429 _ => {} // Skip non-numeric values
430 }
431 }
432
433 Ok(CalcValue::Scalar(LiteralValue::Number(npv)))
434 }
435}
436
437/// Calculates the number of periods needed to satisfy a cash-flow target.
438///
439/// Use this to solve term length when periodic rate, payment, and value constraints are known.
440///
441/// # Remarks
442/// - `rate` is the interest rate per period.
443/// - Cash-flow sign convention: at least one of `pmt`, `pv`, or `fv` should usually have opposite sign.
444/// - `type = 0` means payments at period end; `type != 0` means period start.
445/// - Returns `#NUM!` when inputs imply no finite solution (for example, invalid logarithm domain).
446/// - Returns `#NUM!` when both `rate = 0` and `pmt = 0`.
447///
448/// # Examples
449/// ```yaml,sandbox
450/// formula: =NPER(0.06/12, -1798.65157545827, 300000)
451/// result: 360.00000000000045
452/// ```
453/// ```yaml,sandbox
454/// formula: =NPER(0, -250, 5000)
455/// result: 20
456/// ```
457/// ```yaml,docs
458/// related:
459/// - PMT
460/// - PV
461/// - RATE
462/// faq:
463/// - q: "Why does `NPER` return `#NUM!` for some sign combinations?"
464/// a: "If the logarithm domain is non-positive (or `rate=0` with `pmt=0`), there is no finite solution and `#NUM!` is returned."
465/// ```
466#[derive(Debug)]
467pub struct NperFn;
468/// [formualizer-docgen:schema:start]
469/// Name: NPER
470/// Type: NperFn
471/// Min args: 3
472/// Max args: variadic
473/// Variadic: true
474/// Signature: NPER(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5...: number@scalar)
475/// 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}
476/// Caps: PURE
477/// [formualizer-docgen:schema:end]
478impl Function for NperFn {
479 func_caps!(PURE);
480 fn name(&self) -> &'static str {
481 "NPER"
482 }
483 fn min_args(&self) -> usize {
484 3
485 }
486 fn variadic(&self) -> bool {
487 true
488 }
489 fn arg_schema(&self) -> &'static [ArgSchema] {
490 use std::sync::LazyLock;
491 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
492 vec![
493 ArgSchema::number_lenient_scalar(),
494 ArgSchema::number_lenient_scalar(),
495 ArgSchema::number_lenient_scalar(),
496 ArgSchema::number_lenient_scalar(),
497 ArgSchema::number_lenient_scalar(),
498 ]
499 });
500 &SCHEMA[..]
501 }
502 fn eval<'a, 'b, 'c>(
503 &self,
504 args: &'c [ArgumentHandle<'a, 'b>],
505 _ctx: &dyn FunctionContext<'b>,
506 ) -> Result<CalcValue<'b>, ExcelError> {
507 let rate = coerce_num(&args[0])?;
508 let pmt = coerce_num(&args[1])?;
509 let pv = coerce_num(&args[2])?;
510 let fv = if args.len() > 3 {
511 coerce_num(&args[3])?
512 } else {
513 0.0
514 };
515 let pmt_type = if args.len() > 4 {
516 coerce_num(&args[4])? as i32
517 } else {
518 0
519 };
520
521 let nper = if rate.abs() < 1e-10 {
522 if pmt.abs() < 1e-10 {
523 return Ok(CalcValue::Scalar(
524 LiteralValue::Error(ExcelError::new_num()),
525 ));
526 }
527 -(pv + fv) / pmt
528 } else {
529 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
530 let pmt_adj = pmt * type_adj;
531 let numerator = pmt_adj - fv * rate;
532 let denominator = pv * rate + pmt_adj;
533 if numerator / denominator <= 0.0 {
534 return Ok(CalcValue::Scalar(
535 LiteralValue::Error(ExcelError::new_num()),
536 ));
537 }
538 (numerator / denominator).ln() / (1.0 + rate).ln()
539 };
540
541 Ok(CalcValue::Scalar(LiteralValue::Number(nper)))
542 }
543}
544
545/// Solves for the periodic interest rate implied by annuity cash flows.
546///
547/// This function uses Newton-Raphson iteration and returns the per-period rate that satisfies
548/// the TVM equation.
549///
550/// # Remarks
551/// - Output is a rate per period; convert to annual terms externally if needed.
552/// - Cash-flow sign convention matters for convergence: use opposite signs for borrow/repay sides.
553/// - `guess` defaults to `0.1` and influences convergence speed and branch selection.
554/// - `type = 0` means end-of-period payments; `type != 0` means beginning-of-period payments.
555/// - Returns `#NUM!` on non-convergence, near-zero derivative, or unsatisfied numeric conditions.
556///
557/// # Examples
558/// ```yaml,sandbox
559/// formula: =RATE(360, -1798.65157545827, 300000)
560/// result: 0.005000000000000038
561/// ```
562/// ```yaml,sandbox
563/// formula: =RATE(12, -88.84878867834166, 1000)
564/// result: 0.010000000000005125
565/// ```
566/// ```yaml,docs
567/// related:
568/// - PMT
569/// - NPER
570/// - IRR
571/// faq:
572/// - q: "How important is `guess` for `RATE`?"
573/// a: "`RATE` uses Newton-Raphson from `guess` (default `0.1`); a poor starting point can lead to non-convergence and `#NUM!`."
574/// ```
575#[derive(Debug)]
576pub struct RateFn;
577/// [formualizer-docgen:schema:start]
578/// Name: RATE
579/// Type: RateFn
580/// Min args: 3
581/// Max args: variadic
582/// Variadic: true
583/// Signature: RATE(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6...: number@scalar)
584/// 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}
585/// Caps: PURE
586/// [formualizer-docgen:schema:end]
587impl Function for RateFn {
588 func_caps!(PURE);
589 fn name(&self) -> &'static str {
590 "RATE"
591 }
592 fn min_args(&self) -> usize {
593 3
594 }
595 fn variadic(&self) -> bool {
596 true
597 }
598 fn arg_schema(&self) -> &'static [ArgSchema] {
599 use std::sync::LazyLock;
600 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
601 vec![
602 ArgSchema::number_lenient_scalar(),
603 ArgSchema::number_lenient_scalar(),
604 ArgSchema::number_lenient_scalar(),
605 ArgSchema::number_lenient_scalar(),
606 ArgSchema::number_lenient_scalar(),
607 ArgSchema::number_lenient_scalar(),
608 ]
609 });
610 &SCHEMA[..]
611 }
612 fn eval<'a, 'b, 'c>(
613 &self,
614 args: &'c [ArgumentHandle<'a, 'b>],
615 _ctx: &dyn FunctionContext<'b>,
616 ) -> Result<CalcValue<'b>, ExcelError> {
617 let nper = coerce_num(&args[0])?;
618 let pmt = coerce_num(&args[1])?;
619 let pv = coerce_num(&args[2])?;
620 let fv = if args.len() > 3 {
621 coerce_num(&args[3])?
622 } else {
623 0.0
624 };
625 let pmt_type = if args.len() > 4 {
626 coerce_num(&args[4])? as i32
627 } else {
628 0
629 };
630 let guess = if args.len() > 5 {
631 coerce_num(&args[5])?
632 } else {
633 0.1
634 };
635
636 // Newton-Raphson iteration to find rate
637 let mut rate = guess;
638 let max_iter = 100;
639 let tolerance = 1e-10;
640
641 for _ in 0..max_iter {
642 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
643
644 if rate.abs() < 1e-10 {
645 // Special case for very small rate
646 let f = pv + pmt * nper + fv;
647 if f.abs() < tolerance {
648 return Ok(CalcValue::Scalar(LiteralValue::Number(rate)));
649 }
650 rate = 0.01; // Nudge away from zero
651 continue;
652 }
653
654 let factor = (1.0 + rate).powf(nper);
655 let f = pv * factor + pmt * type_adj * (factor - 1.0) / rate + fv;
656
657 // Derivative
658 let factor_prime = nper * (1.0 + rate).powf(nper - 1.0);
659 let df = pv * factor_prime
660 + pmt * type_adj * (factor_prime / rate - (factor - 1.0) / (rate * rate));
661
662 if df.abs() < 1e-20 {
663 break;
664 }
665
666 let new_rate = rate - f / df;
667
668 if (new_rate - rate).abs() < tolerance {
669 return Ok(CalcValue::Scalar(LiteralValue::Number(new_rate)));
670 }
671
672 rate = new_rate;
673
674 // Prevent rate from going too negative
675 if rate < -0.99 {
676 rate = -0.99;
677 }
678 }
679
680 // If we didn't converge, return error
681 Ok(CalcValue::Scalar(
682 LiteralValue::Error(ExcelError::new_num()),
683 ))
684 }
685}
686
687/// Returns the interest-only component of a payment for a specific period.
688///
689/// Use this with `PMT` or `PPMT` to break a fixed payment into interest and principal pieces.
690///
691/// # Remarks
692/// - `rate` is the interest rate per payment period.
693/// - `per` is 1-based and must satisfy `1 <= per <= nper`.
694/// - Cash-flow sign convention: for a positive loan principal (`pv`), interest components are typically negative.
695/// - `type = 1` yields zero interest in period 1 (annuity-due first payment).
696/// - Returns `#NUM!` when `per` is outside valid bounds.
697///
698/// # Examples
699/// ```yaml,sandbox
700/// formula: =IPMT(0.06/12, 1, 360, 300000)
701/// result: -1500
702/// ```
703/// ```yaml,sandbox
704/// formula: =IPMT(0.06/12, 12, 360, 300000)
705/// result: -1483.1572957145672
706/// ```
707/// ```yaml,docs
708/// related:
709/// - PMT
710/// - PPMT
711/// - CUMIPMT
712/// faq:
713/// - q: "Why is `IPMT` period 1 equal to zero for `type=1`?"
714/// a: "With beginning-of-period payments, the first payment occurs before interest accrues, so period-1 interest is zero."
715/// ```
716#[derive(Debug)]
717pub struct IpmtFn;
718/// [formualizer-docgen:schema:start]
719/// Name: IPMT
720/// Type: IpmtFn
721/// Min args: 4
722/// Max args: variadic
723/// Variadic: true
724/// Signature: IPMT(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6...: 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}
726/// Caps: PURE
727/// [formualizer-docgen:schema:end]
728impl Function for IpmtFn {
729 func_caps!(PURE);
730 fn name(&self) -> &'static str {
731 "IPMT"
732 }
733 fn min_args(&self) -> usize {
734 4
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(),
744 ArgSchema::number_lenient_scalar(),
745 ArgSchema::number_lenient_scalar(),
746 ArgSchema::number_lenient_scalar(),
747 ArgSchema::number_lenient_scalar(),
748 ArgSchema::number_lenient_scalar(),
749 ]
750 });
751 &SCHEMA[..]
752 }
753 fn eval<'a, 'b, 'c>(
754 &self,
755 args: &'c [ArgumentHandle<'a, 'b>],
756 _ctx: &dyn FunctionContext<'b>,
757 ) -> Result<CalcValue<'b>, ExcelError> {
758 let rate = coerce_num(&args[0])?;
759 let per = coerce_num(&args[1])?;
760 let nper = coerce_num(&args[2])?;
761 let pv = coerce_num(&args[3])?;
762 let fv = if args.len() > 4 {
763 coerce_num(&args[4])?
764 } else {
765 0.0
766 };
767 let pmt_type = if args.len() > 5 {
768 coerce_num(&args[5])? as i32
769 } else {
770 0
771 };
772
773 if per < 1.0 || per > nper {
774 return Ok(CalcValue::Scalar(
775 LiteralValue::Error(ExcelError::new_num()),
776 ));
777 }
778
779 // Calculate PMT first
780 let pmt = if rate.abs() < 1e-10 {
781 -(pv + fv) / nper
782 } else {
783 let factor = (1.0 + rate).powf(nper);
784 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
785 -(rate * (pv * factor + fv)) / ((factor - 1.0) * type_adj)
786 };
787
788 // Calculate FV at start of period
789 let fv_at_start = if rate.abs() < 1e-10 {
790 -pv - pmt * (per - 1.0)
791 } else {
792 let factor = (1.0 + rate).powf(per - 1.0);
793 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
794 -pv * factor - pmt * type_adj * (factor - 1.0) / rate
795 };
796
797 // Interest is rate * balance at start of period
798 // fv_at_start is negative of balance, so ipmt = fv_at_start * rate
799 let ipmt = if pmt_type != 0 && per == 1.0 {
800 0.0 // No interest in first period for annuity due
801 } else {
802 fv_at_start * rate
803 };
804
805 Ok(CalcValue::Scalar(LiteralValue::Number(ipmt)))
806 }
807}
808
809/// Returns the principal component of a payment for a specific period.
810///
811/// `PPMT` is computed as `PMT - IPMT` using the same rate, timing, and sign convention.
812///
813/// # Remarks
814/// - `rate` is the interest rate per payment period.
815/// - `per` is 1-based and must satisfy `1 <= per <= nper`.
816/// - Cash-flow sign convention: with a positive borrowed `pv`, principal components are usually negative.
817/// - `type = 1` means beginning-of-period payments.
818/// - Returns `#NUM!` when `per` is outside valid bounds.
819///
820/// # Examples
821/// ```yaml,sandbox
822/// formula: =PPMT(0.06/12, 1, 360, 300000)
823/// result: -298.6515754582708
824/// ```
825/// ```yaml,sandbox
826/// formula: =PPMT(0.06/12, 12, 360, 300000)
827/// result: -315.4942797437036
828/// ```
829/// ```yaml,docs
830/// related:
831/// - PMT
832/// - IPMT
833/// - CUMPRINC
834/// faq:
835/// - q: "How is `PPMT` computed?"
836/// a: "`PPMT` is computed as `PMT - IPMT` for the same `rate`, `per`, `nper`, `pv`, `fv`, and `type`."
837/// ```
838#[derive(Debug)]
839pub struct PpmtFn;
840/// [formualizer-docgen:schema:start]
841/// Name: PPMT
842/// Type: PpmtFn
843/// Min args: 4
844/// Max args: variadic
845/// Variadic: true
846/// Signature: PPMT(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6...: number@scalar)
847/// 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}
848/// Caps: PURE
849/// [formualizer-docgen:schema:end]
850impl Function for PpmtFn {
851 func_caps!(PURE);
852 fn name(&self) -> &'static str {
853 "PPMT"
854 }
855 fn min_args(&self) -> usize {
856 4
857 }
858 fn variadic(&self) -> bool {
859 true
860 }
861 fn arg_schema(&self) -> &'static [ArgSchema] {
862 use std::sync::LazyLock;
863 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
864 vec![
865 ArgSchema::number_lenient_scalar(),
866 ArgSchema::number_lenient_scalar(),
867 ArgSchema::number_lenient_scalar(),
868 ArgSchema::number_lenient_scalar(),
869 ArgSchema::number_lenient_scalar(),
870 ArgSchema::number_lenient_scalar(),
871 ]
872 });
873 &SCHEMA[..]
874 }
875 fn eval<'a, 'b, 'c>(
876 &self,
877 args: &'c [ArgumentHandle<'a, 'b>],
878 _ctx: &dyn FunctionContext<'b>,
879 ) -> Result<CalcValue<'b>, ExcelError> {
880 let rate = coerce_num(&args[0])?;
881 let per = coerce_num(&args[1])?;
882 let nper = coerce_num(&args[2])?;
883 let pv = coerce_num(&args[3])?;
884 let fv = if args.len() > 4 {
885 coerce_num(&args[4])?
886 } else {
887 0.0
888 };
889 let pmt_type = if args.len() > 5 {
890 coerce_num(&args[5])? as i32
891 } else {
892 0
893 };
894
895 if per < 1.0 || per > nper {
896 return Ok(CalcValue::Scalar(
897 LiteralValue::Error(ExcelError::new_num()),
898 ));
899 }
900
901 // Calculate PMT
902 let pmt = if rate.abs() < 1e-10 {
903 -(pv + fv) / nper
904 } else {
905 let factor = (1.0 + rate).powf(nper);
906 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
907 -(rate * (pv * factor + fv)) / ((factor - 1.0) * type_adj)
908 };
909
910 // Calculate IPMT
911 let fv_at_start = if rate.abs() < 1e-10 {
912 -pv - pmt * (per - 1.0)
913 } else {
914 let factor = (1.0 + rate).powf(per - 1.0);
915 let type_adj = if pmt_type != 0 { 1.0 + rate } else { 1.0 };
916 -pv * factor - pmt * type_adj * (factor - 1.0) / rate
917 };
918
919 let ipmt = if pmt_type != 0 && per == 1.0 {
920 0.0
921 } else {
922 fv_at_start * rate
923 };
924
925 // PPMT = PMT - IPMT
926 let ppmt = pmt - ipmt;
927
928 Ok(CalcValue::Scalar(LiteralValue::Number(ppmt)))
929 }
930}
931
932/// Converts a nominal annual rate into an effective annual rate.
933///
934/// This is useful when nominal APR is quoted with periodic compounding and you need annualized
935/// yield including compounding effects.
936///
937/// # Remarks
938/// - `nominal_rate` is annual; `npery` is compounding periods per year.
939/// - `npery` is truncated to an integer before computation.
940/// - Sign convention is not cash-flow based; this function transforms rate conventions only.
941/// - Returns `#NUM!` when `nominal_rate <= 0` or `npery < 1`.
942/// - Result formula: `(1 + nominal_rate / npery)^npery - 1`.
943///
944/// # Examples
945/// ```yaml,sandbox
946/// formula: =EFFECT(0.12, 12)
947/// result: 0.12682503013196977
948/// ```
949/// ```yaml,sandbox
950/// formula: =EFFECT(0.08, 4)
951/// result: 0.08243215999999998
952/// ```
953/// ```yaml,docs
954/// related:
955/// - NOMINAL
956/// - RATE
957/// faq:
958/// - q: "Does `EFFECT` accept fractional compounding periods?"
959/// a: "`npery` is truncated to an integer first; values less than 1 (or non-positive `nominal_rate`) return `#NUM!`."
960/// ```
961#[derive(Debug)]
962pub struct EffectFn;
963/// [formualizer-docgen:schema:start]
964/// Name: EFFECT
965/// Type: EffectFn
966/// Min args: 2
967/// Max args: 2
968/// Variadic: false
969/// Signature: EFFECT(arg1: number@scalar, arg2: number@scalar)
970/// 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}
971/// Caps: PURE
972/// [formualizer-docgen:schema:end]
973impl Function for EffectFn {
974 func_caps!(PURE);
975 fn name(&self) -> &'static str {
976 "EFFECT"
977 }
978 fn min_args(&self) -> usize {
979 2
980 }
981 fn arg_schema(&self) -> &'static [ArgSchema] {
982 use std::sync::LazyLock;
983 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
984 vec![
985 ArgSchema::number_lenient_scalar(),
986 ArgSchema::number_lenient_scalar(),
987 ]
988 });
989 &SCHEMA[..]
990 }
991 fn eval<'a, 'b, 'c>(
992 &self,
993 args: &'c [ArgumentHandle<'a, 'b>],
994 _ctx: &dyn FunctionContext<'b>,
995 ) -> Result<CalcValue<'b>, ExcelError> {
996 let nominal_rate = coerce_num(&args[0])?;
997 let npery = coerce_num(&args[1])?.trunc() as i32;
998
999 // Validation
1000 if nominal_rate <= 0.0 || npery < 1 {
1001 return Ok(CalcValue::Scalar(
1002 LiteralValue::Error(ExcelError::new_num()),
1003 ));
1004 }
1005
1006 // EFFECT = (1 + nominal_rate/npery)^npery - 1
1007 let effect = (1.0 + nominal_rate / npery as f64).powi(npery) - 1.0;
1008 Ok(CalcValue::Scalar(LiteralValue::Number(effect)))
1009 }
1010}
1011
1012/// Converts an effective annual rate into a nominal annual rate.
1013///
1014/// This is the inverse-style transformation of `EFFECT` for a chosen compounding frequency.
1015///
1016/// # Remarks
1017/// - `effect_rate` is annual effective yield; `npery` is periods per year.
1018/// - `npery` is truncated to an integer before computation.
1019/// - Sign convention is not cash-flow based; this function converts annual rate representation.
1020/// - Returns `#NUM!` when `effect_rate <= 0` or `npery < 1`.
1021/// - Result formula: `npery * ((1 + effect_rate)^(1/npery) - 1)`.
1022///
1023/// # Examples
1024/// ```yaml,sandbox
1025/// formula: =NOMINAL(0.12682503013196977, 12)
1026/// result: 0.1200000000000001
1027/// ```
1028/// ```yaml,sandbox
1029/// formula: =NOMINAL(0.08243216, 4)
1030/// result: 0.08000000000000007
1031/// ```
1032/// ```yaml,docs
1033/// related:
1034/// - EFFECT
1035/// - RATE
1036/// faq:
1037/// - q: "Is `NOMINAL` an exact inverse of `EFFECT`?"
1038/// a: "It is the corresponding transformation for the same integer `npery`; both functions require positive rates and `npery >= 1`."
1039/// ```
1040#[derive(Debug)]
1041pub struct NominalFn;
1042/// [formualizer-docgen:schema:start]
1043/// Name: NOMINAL
1044/// Type: NominalFn
1045/// Min args: 2
1046/// Max args: 2
1047/// Variadic: false
1048/// Signature: NOMINAL(arg1: number@scalar, arg2: number@scalar)
1049/// 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}
1050/// Caps: PURE
1051/// [formualizer-docgen:schema:end]
1052impl Function for NominalFn {
1053 func_caps!(PURE);
1054 fn name(&self) -> &'static str {
1055 "NOMINAL"
1056 }
1057 fn min_args(&self) -> usize {
1058 2
1059 }
1060 fn arg_schema(&self) -> &'static [ArgSchema] {
1061 use std::sync::LazyLock;
1062 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
1063 vec![
1064 ArgSchema::number_lenient_scalar(),
1065 ArgSchema::number_lenient_scalar(),
1066 ]
1067 });
1068 &SCHEMA[..]
1069 }
1070 fn eval<'a, 'b, 'c>(
1071 &self,
1072 args: &'c [ArgumentHandle<'a, 'b>],
1073 _ctx: &dyn FunctionContext<'b>,
1074 ) -> Result<CalcValue<'b>, ExcelError> {
1075 let effect_rate = coerce_num(&args[0])?;
1076 let npery = coerce_num(&args[1])?.trunc() as i32;
1077
1078 // Validation
1079 if effect_rate <= 0.0 || npery < 1 {
1080 return Ok(CalcValue::Scalar(
1081 LiteralValue::Error(ExcelError::new_num()),
1082 ));
1083 }
1084
1085 // NOMINAL = npery * ((1 + effect_rate)^(1/npery) - 1)
1086 let nominal = npery as f64 * ((1.0 + effect_rate).powf(1.0 / npery as f64) - 1.0);
1087 Ok(CalcValue::Scalar(LiteralValue::Number(nominal)))
1088 }
1089}
1090
1091/// Compute NPV at a given rate.
1092fn irr_npv(cashflows: &[f64], rate: f64) -> f64 {
1093 let mut npv = 0.0;
1094 for (i, &cf) in cashflows.iter().enumerate() {
1095 npv += cf / (1.0 + rate).powi(i as i32);
1096 }
1097 npv
1098}
1099
1100/// Compute NPV and its derivative w.r.t. rate.
1101fn irr_npv_deriv(cashflows: &[f64], rate: f64) -> (f64, f64) {
1102 let mut npv = 0.0;
1103 let mut d_npv = 0.0;
1104 for (i, &cf) in cashflows.iter().enumerate() {
1105 let factor = (1.0 + rate).powi(i as i32);
1106 npv += cf / factor;
1107 if i > 0 {
1108 d_npv -= (i as f64) * cf / (factor * (1.0 + rate));
1109 }
1110 }
1111 (npv, d_npv)
1112}
1113
1114/// Solve for IRR using Newton-Raphson with Brent's method fallback.
1115///
1116/// Strategy:
1117/// 1. Try Newton-Raphson from the user's guess (fast when it works).
1118/// 2. If Newton diverges, bracket the root by scanning probe points,
1119/// then use Brent's method (superlinear convergence with guaranteed
1120/// bracketing) to find the root precisely.
1121fn irr_solve(cashflows: &[f64], guess: f64) -> Option<f64> {
1122 const MAX_NR: usize = 100;
1123 const MAX_BRENT: usize = 200;
1124 const TOL: f64 = 1e-12;
1125 const MACH_EPS: f64 = f64::EPSILON;
1126
1127 // --- Phase 1: Newton-Raphson from the given guess ---
1128 let mut rate = guess;
1129 for _ in 0..MAX_NR {
1130 let (npv, d_npv) = irr_npv_deriv(cashflows, rate);
1131 if d_npv.abs() < TOL {
1132 break; // flat derivative, fall through to Brent
1133 }
1134 let new_rate = rate - npv / d_npv;
1135 // Accept if converged and rate > -1 (pole at -1)
1136 if (new_rate - rate).abs() < TOL && new_rate > -1.0 {
1137 return Some(new_rate);
1138 }
1139 // If Newton shoots below -1 or to NaN/Inf, bail out
1140 if new_rate <= -1.0 || !new_rate.is_finite() {
1141 break;
1142 }
1143 rate = new_rate;
1144 }
1145
1146 // --- Phase 2: Bracket the root, then apply Brent's method ---
1147 // Search for a sign change in NPV across a wide range of rates.
1148 let probes: &[f64] = &[
1149 -0.99, -0.9, -0.5, -0.1, -0.01, 0.0, 0.001, 0.005, 0.01, 0.02, 0.05, 0.1, 0.15, 0.2, 0.3,
1150 0.5, 1.0, 2.0, 5.0, 10.0,
1151 ];
1152 let mut lo = f64::NAN;
1153 let mut hi = f64::NAN;
1154 let mut npv_lo = f64::NAN;
1155
1156 for &r in probes {
1157 let npv = irr_npv(cashflows, r);
1158 if !npv.is_finite() {
1159 continue;
1160 }
1161 if lo.is_nan() {
1162 lo = r;
1163 npv_lo = npv;
1164 continue;
1165 }
1166 if npv_lo * npv < 0.0 {
1167 hi = r;
1168 break;
1169 }
1170 lo = r;
1171 npv_lo = npv;
1172 }
1173
1174 if hi.is_nan() {
1175 return None; // no sign change found — no real IRR
1176 }
1177
1178 // Brent's method (following scipy's brentq / Brent's zeroin algorithm).
1179 // Combines inverse quadratic interpolation, secant, and bisection.
1180 // xpre/xcur maintain the bracket; xblk is the contrapoint.
1181 let mut xpre = lo;
1182 let mut xcur = hi;
1183 let mut fpre = irr_npv(cashflows, xpre);
1184 let mut fcur = irr_npv(cashflows, xcur);
1185
1186 if fpre == 0.0 {
1187 return Some(xpre);
1188 }
1189 if fcur == 0.0 {
1190 return Some(xcur);
1191 }
1192
1193 let mut xblk = 0.0;
1194 let mut fblk = 0.0;
1195 let mut spre = 0.0;
1196 let mut scur = 0.0;
1197
1198 for _ in 0..MAX_BRENT {
1199 // If xpre and xcur bracket the root, reset the contrapoint
1200 if fpre * fcur < 0.0 {
1201 xblk = xpre;
1202 fblk = fpre;
1203 spre = xcur - xpre;
1204 scur = spre;
1205 }
1206
1207 // Ensure xcur is the best approximation (|fcur| <= |fblk|)
1208 if fblk.abs() < fcur.abs() {
1209 xpre = xcur;
1210 xcur = xblk;
1211 xblk = xpre;
1212 fpre = fcur;
1213 fcur = fblk;
1214 fblk = fpre;
1215 }
1216
1217 let delta = (MACH_EPS * xcur.abs() + 0.5 * TOL).max(MACH_EPS);
1218 let sbis = (xblk - xcur) * 0.5;
1219
1220 if fcur == 0.0 || sbis.abs() < delta {
1221 return Some(xcur);
1222 }
1223
1224 if spre.abs() >= delta && fcur.abs() < fpre.abs() {
1225 // Try interpolation
1226 let stry = if (xpre - xblk).abs() < MACH_EPS {
1227 // Secant step
1228 -fcur * (xcur - xpre) / (fcur - fpre)
1229 } else {
1230 // Inverse quadratic interpolation
1231 let dpre = (fpre - fcur) / (xpre - xcur);
1232 let dblk = (fblk - fcur) / (xblk - xcur);
1233 -fcur * (fblk * dblk - fpre * dpre) / (dblk * dpre * (fblk - fpre))
1234 };
1235
1236 // Accept if step is small enough
1237 if 2.0 * stry.abs() < spre.abs().min(3.0 * sbis.abs() - delta) {
1238 spre = scur;
1239 scur = stry;
1240 } else {
1241 spre = sbis;
1242 scur = sbis;
1243 }
1244 } else {
1245 // Bisection
1246 spre = sbis;
1247 scur = sbis;
1248 }
1249
1250 xpre = xcur;
1251 fpre = fcur;
1252
1253 if scur.abs() > delta {
1254 xcur += scur;
1255 } else {
1256 xcur += if sbis > 0.0 { delta } else { -delta };
1257 }
1258 fcur = irr_npv(cashflows, xcur);
1259 }
1260 Some(xcur)
1261}
1262
1263/// Calculates periodic internal rate of return for regularly spaced cash flows.
1264///
1265/// The function iteratively finds the per-period rate where discounted cash flows sum to zero.
1266///
1267/// # Remarks
1268/// - Output is a rate per cash-flow period (not automatically annualized).
1269/// - Cash-flow sign convention: outflows are negative and inflows are positive.
1270/// - Non-numeric cells in arrays/ranges are ignored; direct scalar errors are propagated.
1271/// - A callable value input returns `#CALC!`.
1272/// - Returns `#NUM!` if fewer than two numeric cash flows are available, if derivative is near zero, or if iteration does not converge.
1273///
1274/// # Examples
1275/// ```yaml,sandbox
1276/// formula: =IRR({-10000,3000,4200,6800})
1277/// result: 0.16340560068898924
1278/// ```
1279/// ```yaml,sandbox
1280/// formula: =IRR({-5000,1200,1410,1875,1050}, 0.1)
1281/// result: 0.041848876015677466
1282/// ```
1283/// ```yaml,docs
1284/// related:
1285/// - MIRR
1286/// - NPV
1287/// - XIRR
1288/// faq:
1289/// - q: "Why can `IRR` return `#NUM!` even with numeric cash flows?"
1290/// a: "The Newton solve can fail if derivative terms become unstable or no convergent root is reached from the chosen guess."
1291/// ```
1292#[derive(Debug)]
1293pub struct IrrFn;
1294/// [formualizer-docgen:schema:start]
1295/// Name: IRR
1296/// Type: IrrFn
1297/// Min args: 1
1298/// Max args: variadic
1299/// Variadic: true
1300/// Signature: IRR(arg1: any@scalar, arg2...: number@scalar)
1301/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg2{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
1302/// Caps: PURE
1303/// [formualizer-docgen:schema:end]
1304impl Function for IrrFn {
1305 func_caps!(PURE);
1306 fn name(&self) -> &'static str {
1307 "IRR"
1308 }
1309 fn min_args(&self) -> usize {
1310 1
1311 }
1312 fn variadic(&self) -> bool {
1313 true
1314 }
1315 fn arg_schema(&self) -> &'static [ArgSchema] {
1316 use std::sync::LazyLock;
1317 static SCHEMA: LazyLock<Vec<ArgSchema>> =
1318 LazyLock::new(|| vec![ArgSchema::any(), ArgSchema::number_lenient_scalar()]);
1319 &SCHEMA[..]
1320 }
1321 fn eval<'a, 'b, 'c>(
1322 &self,
1323 args: &'c [ArgumentHandle<'a, 'b>],
1324 _ctx: &dyn FunctionContext<'b>,
1325 ) -> Result<CalcValue<'b>, ExcelError> {
1326 // Collect cash flows
1327 let mut cashflows = Vec::new();
1328 let val = args[0].value()?;
1329 match val {
1330 CalcValue::Scalar(lit) => match lit {
1331 LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
1332 LiteralValue::Array(arr) => {
1333 for row in arr {
1334 for cell in row {
1335 if let Ok(n) = coerce_literal_num(&cell) {
1336 cashflows.push(n);
1337 }
1338 }
1339 }
1340 }
1341 other => cashflows.push(coerce_literal_num(&other)?),
1342 },
1343 CalcValue::Range(range) => {
1344 let (rows, cols) = range.dims();
1345 for r in 0..rows {
1346 for c in 0..cols {
1347 let cell = range.get_cell(r, c);
1348 if let Ok(n) = coerce_literal_num(&cell) {
1349 cashflows.push(n);
1350 }
1351 }
1352 }
1353 }
1354 CalcValue::Callable(_) => {
1355 return Ok(CalcValue::Scalar(LiteralValue::Error(
1356 ExcelError::new(ExcelErrorKind::Calc)
1357 .with_message("LAMBDA value must be invoked"),
1358 )));
1359 }
1360 }
1361
1362 if cashflows.len() < 2 {
1363 return Ok(CalcValue::Scalar(
1364 LiteralValue::Error(ExcelError::new_num()),
1365 ));
1366 }
1367
1368 // Initial guess
1369 let guess = if args.len() > 1 {
1370 coerce_num(&args[1])?
1371 } else {
1372 0.1
1373 };
1374
1375 match irr_solve(&cashflows, guess) {
1376 Some(rate) => Ok(CalcValue::Scalar(LiteralValue::Number(rate))),
1377 None => Ok(CalcValue::Scalar(
1378 LiteralValue::Error(ExcelError::new_num()),
1379 )),
1380 }
1381 }
1382}
1383
1384/// Calculates modified internal rate of return with separate finance and reinvest rates.
1385///
1386/// Negative cash flows are discounted at `finance_rate` and positive cash flows are compounded at
1387/// `reinvest_rate`, then combined into a single periodic return.
1388///
1389/// # Remarks
1390/// - `finance_rate` and `reinvest_rate` are both rates per cash-flow period.
1391/// - Cash-flow sign convention: at least one negative and one positive cash flow are required.
1392/// - Non-numeric cells in arrays/ranges are ignored; direct scalar errors are propagated.
1393/// - A callable value input returns `#CALC!`.
1394/// - Returns `#NUM!` for insufficient cash flows, and `#DIV/0!` when computed positive/negative legs are invalid.
1395///
1396/// # Examples
1397/// ```yaml,sandbox
1398/// formula: =MIRR({-10000,3000,4200,6800}, 0.1, 0.12)
1399/// result: 0.15147133664676304
1400/// ```
1401/// ```yaml,sandbox
1402/// formula: =MIRR({-120000,39000,30000,21000,37000,46000}, 0.1, 0.12)
1403/// result: 0.1260941303659051
1404/// ```
1405/// ```yaml,docs
1406/// related:
1407/// - IRR
1408/// - NPV
1409/// - XNPV
1410/// faq:
1411/// - q: "Why does `MIRR` return `#DIV/0!` for some cash-flow sets?"
1412/// a: "`MIRR` needs both a negative leg and a positive leg; if discounted negatives or compounded positives are invalid, it returns `#DIV/0!`."
1413/// ```
1414#[derive(Debug)]
1415pub struct MirrFn;
1416/// [formualizer-docgen:schema:start]
1417/// Name: MIRR
1418/// Type: MirrFn
1419/// Min args: 3
1420/// Max args: 3
1421/// Variadic: false
1422/// Signature: MIRR(arg1: any@scalar, arg2: number@scalar, arg3: number@scalar)
1423/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,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}
1424/// Caps: PURE
1425/// [formualizer-docgen:schema:end]
1426impl Function for MirrFn {
1427 func_caps!(PURE);
1428 fn name(&self) -> &'static str {
1429 "MIRR"
1430 }
1431 fn min_args(&self) -> usize {
1432 3
1433 }
1434 fn arg_schema(&self) -> &'static [ArgSchema] {
1435 use std::sync::LazyLock;
1436 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
1437 vec![
1438 ArgSchema::any(),
1439 ArgSchema::number_lenient_scalar(),
1440 ArgSchema::number_lenient_scalar(),
1441 ]
1442 });
1443 &SCHEMA[..]
1444 }
1445 fn eval<'a, 'b, 'c>(
1446 &self,
1447 args: &'c [ArgumentHandle<'a, 'b>],
1448 _ctx: &dyn FunctionContext<'b>,
1449 ) -> Result<CalcValue<'b>, ExcelError> {
1450 // Collect cash flows
1451 let mut cashflows = Vec::new();
1452 let val = args[0].value()?;
1453 match val {
1454 CalcValue::Scalar(lit) => match lit {
1455 LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
1456 LiteralValue::Array(arr) => {
1457 for row in arr {
1458 for cell in row {
1459 if let Ok(n) = coerce_literal_num(&cell) {
1460 cashflows.push(n);
1461 }
1462 }
1463 }
1464 }
1465 other => cashflows.push(coerce_literal_num(&other)?),
1466 },
1467 CalcValue::Range(range) => {
1468 let (rows, cols) = range.dims();
1469 for r in 0..rows {
1470 for c in 0..cols {
1471 let cell = range.get_cell(r, c);
1472 if let Ok(n) = coerce_literal_num(&cell) {
1473 cashflows.push(n);
1474 }
1475 }
1476 }
1477 }
1478 CalcValue::Callable(_) => {
1479 return Ok(CalcValue::Scalar(LiteralValue::Error(
1480 ExcelError::new(ExcelErrorKind::Calc)
1481 .with_message("LAMBDA value must be invoked"),
1482 )));
1483 }
1484 }
1485
1486 let finance_rate = coerce_num(&args[1])?;
1487 let reinvest_rate = coerce_num(&args[2])?;
1488
1489 if cashflows.len() < 2 {
1490 return Ok(CalcValue::Scalar(
1491 LiteralValue::Error(ExcelError::new_num()),
1492 ));
1493 }
1494
1495 let n = cashflows.len() as i32;
1496
1497 // Present value of negative cash flows (discounted at finance_rate)
1498 let mut pv_neg = 0.0;
1499 // Future value of positive cash flows (compounded at reinvest_rate)
1500 let mut fv_pos = 0.0;
1501
1502 for (i, &cf) in cashflows.iter().enumerate() {
1503 if cf < 0.0 {
1504 pv_neg += cf / (1.0 + finance_rate).powi(i as i32);
1505 } else {
1506 fv_pos += cf * (1.0 + reinvest_rate).powi(n - 1 - i as i32);
1507 }
1508 }
1509
1510 if pv_neg >= 0.0 || fv_pos <= 0.0 {
1511 return Ok(CalcValue::Scalar(
1512 LiteralValue::Error(ExcelError::new_div()),
1513 ));
1514 }
1515
1516 // MIRR = (FV_pos / -PV_neg)^(1/(n-1)) - 1
1517 let mirr = (-fv_pos / pv_neg).powf(1.0 / (n - 1) as f64) - 1.0;
1518 Ok(CalcValue::Scalar(LiteralValue::Number(mirr)))
1519 }
1520}
1521
1522/// Returns cumulative interest paid between two inclusive payment periods.
1523///
1524/// Use this to total the interest component over a slice of an amortization schedule.
1525///
1526/// # Remarks
1527/// - `rate` is the interest rate per payment period.
1528/// - `start_period` and `end_period` are 1-based, inclusive integer periods.
1529/// - `type` must be `0` (end-of-period) or `1` (beginning-of-period).
1530/// - Sign convention follows this implementation's balance model; with positive `pv`, cumulative interest is typically positive.
1531/// - Returns `#NUM!` for invalid domain values (non-positive rate, invalid ranges, invalid type, or non-positive `pv`).
1532///
1533/// # Examples
1534/// ```yaml,sandbox
1535/// formula: =CUMIPMT(0.06/12, 360, 300000, 1, 12, 0)
1536/// result: 16929.385083045923
1537/// ```
1538/// ```yaml,sandbox
1539/// formula: =CUMIPMT(0.06/12, 360, 300000, 13, 24, 0)
1540/// result: 14681.09233746059
1541/// ```
1542/// ```yaml,docs
1543/// related:
1544/// - IPMT
1545/// - PMT
1546/// - CUMPRINC
1547/// faq:
1548/// - q: "Are `start_period` and `end_period` inclusive in `CUMIPMT`?"
1549/// a: "Yes. Both bounds are inclusive and interpreted as 1-based periods after truncation to integers."
1550/// ```
1551#[derive(Debug)]
1552pub struct CumipmtFn;
1553/// [formualizer-docgen:schema:start]
1554/// Name: CUMIPMT
1555/// Type: CumipmtFn
1556/// Min args: 6
1557/// Max args: 6
1558/// Variadic: false
1559/// Signature: CUMIPMT(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6: number@scalar)
1560/// 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}
1561/// Caps: PURE
1562/// [formualizer-docgen:schema:end]
1563impl Function for CumipmtFn {
1564 func_caps!(PURE);
1565 fn name(&self) -> &'static str {
1566 "CUMIPMT"
1567 }
1568 fn min_args(&self) -> usize {
1569 6
1570 }
1571 fn arg_schema(&self) -> &'static [ArgSchema] {
1572 use std::sync::LazyLock;
1573 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
1574 vec![
1575 ArgSchema::number_lenient_scalar(),
1576 ArgSchema::number_lenient_scalar(),
1577 ArgSchema::number_lenient_scalar(),
1578 ArgSchema::number_lenient_scalar(),
1579 ArgSchema::number_lenient_scalar(),
1580 ArgSchema::number_lenient_scalar(),
1581 ]
1582 });
1583 &SCHEMA[..]
1584 }
1585 fn eval<'a, 'b, 'c>(
1586 &self,
1587 args: &'c [ArgumentHandle<'a, 'b>],
1588 _ctx: &dyn FunctionContext<'b>,
1589 ) -> Result<CalcValue<'b>, ExcelError> {
1590 let rate = coerce_num(&args[0])?;
1591 let nper = coerce_num(&args[1])?.trunc() as i32;
1592 let pv = coerce_num(&args[2])?;
1593 let start = coerce_num(&args[3])?.trunc() as i32;
1594 let end = coerce_num(&args[4])?.trunc() as i32;
1595 let pay_type = coerce_num(&args[5])?.trunc() as i32;
1596
1597 // Validation
1598 if rate <= 0.0
1599 || nper <= 0
1600 || pv <= 0.0
1601 || start < 1
1602 || end < start
1603 || end > nper
1604 || (pay_type != 0 && pay_type != 1)
1605 {
1606 return Ok(CalcValue::Scalar(
1607 LiteralValue::Error(ExcelError::new_num()),
1608 ));
1609 }
1610
1611 // Calculate PMT
1612 let pmt = if rate == 0.0 {
1613 -pv / nper as f64
1614 } else {
1615 -pv * rate * (1.0 + rate).powi(nper) / ((1.0 + rate).powi(nper) - 1.0)
1616 };
1617
1618 // Sum interest payments from start to end
1619 let mut cum_int = 0.0;
1620 let mut balance = pv;
1621
1622 for period in 1..=end {
1623 let interest = if pay_type == 1 && period == 1 {
1624 0.0
1625 } else {
1626 balance * rate
1627 };
1628
1629 if period >= start {
1630 cum_int += interest;
1631 }
1632
1633 let principal = pmt - interest;
1634 balance += principal;
1635 }
1636
1637 Ok(CalcValue::Scalar(LiteralValue::Number(cum_int)))
1638 }
1639}
1640
1641/// Returns cumulative principal paid between two inclusive payment periods.
1642///
1643/// Use this to measure principal reduction over a selected amortization window.
1644///
1645/// # Remarks
1646/// - `rate` is the interest rate per payment period.
1647/// - `start_period` and `end_period` are 1-based, inclusive integer periods.
1648/// - `type` must be `0` (end-of-period) or `1` (beginning-of-period).
1649/// - Sign convention follows payment direction; with positive `pv`, cumulative principal is typically negative.
1650/// - Returns `#NUM!` for invalid domain values (non-positive rate, invalid ranges, invalid type, or non-positive `pv`).
1651///
1652/// # Examples
1653/// ```yaml,sandbox
1654/// formula: =CUMPRINC(0.06/12, 360, 300000, 1, 12, 0)
1655/// result: -38513.20398854517
1656/// ```
1657/// ```yaml,sandbox
1658/// formula: =CUMPRINC(0.06/12, 360, 300000, 13, 24, 0)
1659/// result: -36264.91124295984
1660/// ```
1661/// ```yaml,docs
1662/// related:
1663/// - PPMT
1664/// - PMT
1665/// - CUMIPMT
1666/// faq:
1667/// - q: "Why is `CUMPRINC` often negative for loans?"
1668/// a: "With positive `pv`, payment cash outflows are negative in this convention, so cumulative principal is typically negative."
1669/// ```
1670#[derive(Debug)]
1671pub struct CumprincFn;
1672/// [formualizer-docgen:schema:start]
1673/// Name: CUMPRINC
1674/// Type: CumprincFn
1675/// Min args: 6
1676/// Max args: 6
1677/// Variadic: false
1678/// Signature: CUMPRINC(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar, arg5: number@scalar, arg6: number@scalar)
1679/// 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}
1680/// Caps: PURE
1681/// [formualizer-docgen:schema:end]
1682impl Function for CumprincFn {
1683 func_caps!(PURE);
1684 fn name(&self) -> &'static str {
1685 "CUMPRINC"
1686 }
1687 fn min_args(&self) -> usize {
1688 6
1689 }
1690 fn arg_schema(&self) -> &'static [ArgSchema] {
1691 use std::sync::LazyLock;
1692 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
1693 vec![
1694 ArgSchema::number_lenient_scalar(),
1695 ArgSchema::number_lenient_scalar(),
1696 ArgSchema::number_lenient_scalar(),
1697 ArgSchema::number_lenient_scalar(),
1698 ArgSchema::number_lenient_scalar(),
1699 ArgSchema::number_lenient_scalar(),
1700 ]
1701 });
1702 &SCHEMA[..]
1703 }
1704 fn eval<'a, 'b, 'c>(
1705 &self,
1706 args: &'c [ArgumentHandle<'a, 'b>],
1707 _ctx: &dyn FunctionContext<'b>,
1708 ) -> Result<CalcValue<'b>, ExcelError> {
1709 let rate = coerce_num(&args[0])?;
1710 let nper = coerce_num(&args[1])?.trunc() as i32;
1711 let pv = coerce_num(&args[2])?;
1712 let start = coerce_num(&args[3])?.trunc() as i32;
1713 let end = coerce_num(&args[4])?.trunc() as i32;
1714 let pay_type = coerce_num(&args[5])?.trunc() as i32;
1715
1716 // Validation
1717 if rate <= 0.0
1718 || nper <= 0
1719 || pv <= 0.0
1720 || start < 1
1721 || end < start
1722 || end > nper
1723 || (pay_type != 0 && pay_type != 1)
1724 {
1725 return Ok(CalcValue::Scalar(
1726 LiteralValue::Error(ExcelError::new_num()),
1727 ));
1728 }
1729
1730 // Calculate PMT
1731 let pmt = if rate == 0.0 {
1732 -pv / nper as f64
1733 } else {
1734 -pv * rate * (1.0 + rate).powi(nper) / ((1.0 + rate).powi(nper) - 1.0)
1735 };
1736
1737 // Sum principal payments from start to end
1738 let mut cum_princ = 0.0;
1739 let mut balance = pv;
1740
1741 for period in 1..=end {
1742 let interest = if pay_type == 1 && period == 1 {
1743 0.0
1744 } else {
1745 balance * rate
1746 };
1747
1748 let principal = pmt - interest;
1749
1750 if period >= start {
1751 cum_princ += principal;
1752 }
1753
1754 balance += principal;
1755 }
1756
1757 Ok(CalcValue::Scalar(LiteralValue::Number(cum_princ)))
1758 }
1759}
1760
1761/// Calculates annualized net present value for irregularly dated cash flows.
1762///
1763/// Discounting uses an actual-day offset divided by 365 from the first provided date.
1764///
1765/// # Remarks
1766/// - `rate` is an annual discount rate.
1767/// - Cash-flow sign convention: outflows are negative and inflows are positive.
1768/// - `values` and `dates` are flattened to numeric entries; non-numeric entries are ignored.
1769/// - Scalar error inputs are propagated; callable inputs return `#CALC!`.
1770/// - Returns `#NUM!` when `values` and `dates` lengths differ or no numeric pair exists.
1771///
1772/// # Examples
1773/// ```yaml,sandbox
1774/// formula: =XNPV(0.10, {-10000,2750,4250,3250,2750}, {0,365,730,1095,1460})
1775/// result: 332.4567993989465
1776/// ```
1777/// ```yaml,sandbox
1778/// formula: =XNPV(0.08, {-5000,1200,1800,2400}, {0,180,365,730})
1779/// result: -120.41078799700836
1780/// ```
1781/// ```yaml,docs
1782/// related:
1783/// - NPV
1784/// - XIRR
1785/// - MIRR
1786/// faq:
1787/// - q: "How are dates interpreted in `XNPV`?"
1788/// a: "Each cash flow is discounted by `(date_i - first_date) / 365`, so dates must align one-to-one with values."
1789/// ```
1790#[derive(Debug)]
1791pub struct XnpvFn;
1792/// [formualizer-docgen:schema:start]
1793/// Name: XNPV
1794/// Type: XnpvFn
1795/// Min args: 3
1796/// Max args: 3
1797/// Variadic: false
1798/// Signature: XNPV(arg1: number@scalar, arg2: any@scalar, arg3: any@scalar)
1799/// Arg schema: arg1{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}; arg2{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg3{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
1800/// Caps: PURE
1801/// [formualizer-docgen:schema:end]
1802impl Function for XnpvFn {
1803 func_caps!(PURE);
1804 fn name(&self) -> &'static str {
1805 "XNPV"
1806 }
1807 fn min_args(&self) -> usize {
1808 3
1809 }
1810 fn arg_schema(&self) -> &'static [ArgSchema] {
1811 use std::sync::LazyLock;
1812 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
1813 vec![
1814 ArgSchema::number_lenient_scalar(), // rate
1815 ArgSchema::any(), // values
1816 ArgSchema::any(), // dates
1817 ]
1818 });
1819 &SCHEMA[..]
1820 }
1821 fn eval<'a, 'b, 'c>(
1822 &self,
1823 args: &'c [ArgumentHandle<'a, 'b>],
1824 _ctx: &dyn FunctionContext<'b>,
1825 ) -> Result<CalcValue<'b>, ExcelError> {
1826 let rate = coerce_num(&args[0])?;
1827
1828 // Collect values
1829 let mut values = Vec::new();
1830 let val = args[1].value()?;
1831 match val {
1832 CalcValue::Scalar(lit) => match lit {
1833 LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
1834 LiteralValue::Array(arr) => {
1835 for row in arr {
1836 for cell in row {
1837 if let Ok(n) = coerce_literal_num(&cell) {
1838 values.push(n);
1839 }
1840 }
1841 }
1842 }
1843 other => values.push(coerce_literal_num(&other)?),
1844 },
1845 CalcValue::Range(range) => {
1846 let (rows, cols) = range.dims();
1847 for r in 0..rows {
1848 for c in 0..cols {
1849 let cell = range.get_cell(r, c);
1850 if let Ok(n) = coerce_literal_num(&cell) {
1851 values.push(n);
1852 }
1853 }
1854 }
1855 }
1856 CalcValue::Callable(_) => {
1857 return Ok(CalcValue::Scalar(LiteralValue::Error(
1858 ExcelError::new(ExcelErrorKind::Calc)
1859 .with_message("LAMBDA value must be invoked"),
1860 )));
1861 }
1862 }
1863
1864 // Collect dates
1865 let mut dates = Vec::new();
1866 let date_val = args[2].value()?;
1867 match date_val {
1868 CalcValue::Scalar(lit) => match lit {
1869 LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
1870 LiteralValue::Array(arr) => {
1871 for row in arr {
1872 for cell in row {
1873 if let Ok(n) = coerce_literal_num(&cell) {
1874 dates.push(n);
1875 }
1876 }
1877 }
1878 }
1879 other => dates.push(coerce_literal_num(&other)?),
1880 },
1881 CalcValue::Range(range) => {
1882 let (rows, cols) = range.dims();
1883 for r in 0..rows {
1884 for c in 0..cols {
1885 let cell = range.get_cell(r, c);
1886 if let Ok(n) = coerce_literal_num(&cell) {
1887 dates.push(n);
1888 }
1889 }
1890 }
1891 }
1892 CalcValue::Callable(_) => {
1893 return Ok(CalcValue::Scalar(LiteralValue::Error(
1894 ExcelError::new(ExcelErrorKind::Calc)
1895 .with_message("LAMBDA value must be invoked"),
1896 )));
1897 }
1898 }
1899
1900 // Validate that values and dates have the same length
1901 if values.len() != dates.len() || values.is_empty() {
1902 return Ok(CalcValue::Scalar(
1903 LiteralValue::Error(ExcelError::new_num()),
1904 ));
1905 }
1906
1907 // Calculate XNPV: Sum of values[i] / (1 + rate)^((dates[i] - dates[0]) / 365)
1908 let first_date = dates[0];
1909 let mut xnpv = 0.0;
1910
1911 for (i, &value) in values.iter().enumerate() {
1912 let days_from_start = dates[i] - first_date;
1913 let years = days_from_start / 365.0;
1914 xnpv += value / (1.0 + rate).powf(years);
1915 }
1916
1917 Ok(CalcValue::Scalar(LiteralValue::Number(xnpv)))
1918 }
1919}
1920
1921/// Helper function to calculate XNPV given rate, values, and dates
1922fn calculate_xnpv(rate: f64, values: &[f64], dates: &[f64]) -> f64 {
1923 if values.is_empty() || dates.is_empty() {
1924 return 0.0;
1925 }
1926 let first_date = dates[0];
1927 let mut xnpv = 0.0;
1928 for (i, &value) in values.iter().enumerate() {
1929 let days_from_start = dates[i] - first_date;
1930 let years = days_from_start / 365.0;
1931 xnpv += value / (1.0 + rate).powf(years);
1932 }
1933 xnpv
1934}
1935
1936/// Helper function to calculate the derivative of XNPV with respect to rate
1937fn calculate_xnpv_derivative(rate: f64, values: &[f64], dates: &[f64]) -> f64 {
1938 if values.is_empty() || dates.is_empty() {
1939 return 0.0;
1940 }
1941 let first_date = dates[0];
1942 let mut d_xnpv = 0.0;
1943 for (i, &value) in values.iter().enumerate() {
1944 let days_from_start = dates[i] - first_date;
1945 let years = days_from_start / 365.0;
1946 // d/dr [value / (1+r)^years] = -years * value / (1+r)^(years+1)
1947 d_xnpv -= years * value / (1.0 + rate).powf(years + 1.0);
1948 }
1949 d_xnpv
1950}
1951
1952/// Calculates annualized internal rate of return for irregularly dated cash flows.
1953///
1954/// The solver uses Newton-Raphson on `XNPV(rate, values, dates) = 0` with day-count basis 365.
1955///
1956/// # Remarks
1957/// - Output is an annualized rate.
1958/// - Cash-flow sign convention requires at least one negative and one positive value.
1959/// - `guess` defaults to `0.1` and can materially affect convergence.
1960/// - Non-numeric entries in value/date arrays are ignored; callable inputs return `#CALC!`.
1961/// - Returns `#NUM!` for mismatched lengths, insufficient valid points, missing sign change, derivative failure, or non-convergence.
1962///
1963/// # Examples
1964/// ```yaml,sandbox
1965/// formula: =XIRR({-10000,2750,4250,3250,2750}, {0,365,730,1095,1460})
1966/// result: 0.11541278310055854
1967/// ```
1968/// ```yaml,sandbox
1969/// formula: =XIRR({-5000,1200,1800,2400}, {0,180,365,730}, 0.1)
1970/// result: 0.06001829492127762
1971/// ```
1972/// ```yaml,docs
1973/// related:
1974/// - XNPV
1975/// - IRR
1976/// - NPV
1977/// faq:
1978/// - q: "What data shape does `XIRR` require?"
1979/// a: "`values` and `dates` must have equal numeric length with at least one positive and one negative cash flow, or `#NUM!` is returned."
1980/// ```
1981#[derive(Debug)]
1982pub struct XirrFn;
1983/// [formualizer-docgen:schema:start]
1984/// Name: XIRR
1985/// Type: XirrFn
1986/// Min args: 2
1987/// Max args: variadic
1988/// Variadic: true
1989/// Signature: XIRR(arg1: any@scalar, arg2: any@scalar, arg3...: number@scalar)
1990/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg2{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg3{kinds=number,required=true,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
1991/// Caps: PURE
1992/// [formualizer-docgen:schema:end]
1993impl Function for XirrFn {
1994 func_caps!(PURE);
1995 fn name(&self) -> &'static str {
1996 "XIRR"
1997 }
1998 fn min_args(&self) -> usize {
1999 2
2000 }
2001 fn variadic(&self) -> bool {
2002 true
2003 }
2004 fn arg_schema(&self) -> &'static [ArgSchema] {
2005 use std::sync::LazyLock;
2006 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
2007 vec![
2008 ArgSchema::any(), // values
2009 ArgSchema::any(), // dates
2010 ArgSchema::number_lenient_scalar(), // guess (optional)
2011 ]
2012 });
2013 &SCHEMA[..]
2014 }
2015 fn eval<'a, 'b, 'c>(
2016 &self,
2017 args: &'c [ArgumentHandle<'a, 'b>],
2018 _ctx: &dyn FunctionContext<'b>,
2019 ) -> Result<CalcValue<'b>, ExcelError> {
2020 // Collect values
2021 let mut values = Vec::new();
2022 let val = args[0].value()?;
2023 match val {
2024 CalcValue::Scalar(lit) => match lit {
2025 LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
2026 LiteralValue::Array(arr) => {
2027 for row in arr {
2028 for cell in row {
2029 if let Ok(n) = coerce_literal_num(&cell) {
2030 values.push(n);
2031 }
2032 }
2033 }
2034 }
2035 other => values.push(coerce_literal_num(&other)?),
2036 },
2037 CalcValue::Range(range) => {
2038 let (rows, cols) = range.dims();
2039 for r in 0..rows {
2040 for c in 0..cols {
2041 let cell = range.get_cell(r, c);
2042 if let Ok(n) = coerce_literal_num(&cell) {
2043 values.push(n);
2044 }
2045 }
2046 }
2047 }
2048 CalcValue::Callable(_) => {
2049 return Ok(CalcValue::Scalar(LiteralValue::Error(
2050 ExcelError::new(ExcelErrorKind::Calc)
2051 .with_message("LAMBDA value must be invoked"),
2052 )));
2053 }
2054 }
2055
2056 // Collect dates
2057 let mut dates = Vec::new();
2058 let date_val = args[1].value()?;
2059 match date_val {
2060 CalcValue::Scalar(lit) => match lit {
2061 LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
2062 LiteralValue::Array(arr) => {
2063 for row in arr {
2064 for cell in row {
2065 if let Ok(n) = coerce_literal_num(&cell) {
2066 dates.push(n);
2067 }
2068 }
2069 }
2070 }
2071 other => dates.push(coerce_literal_num(&other)?),
2072 },
2073 CalcValue::Range(range) => {
2074 let (rows, cols) = range.dims();
2075 for r in 0..rows {
2076 for c in 0..cols {
2077 let cell = range.get_cell(r, c);
2078 if let Ok(n) = coerce_literal_num(&cell) {
2079 dates.push(n);
2080 }
2081 }
2082 }
2083 }
2084 CalcValue::Callable(_) => {
2085 return Ok(CalcValue::Scalar(LiteralValue::Error(
2086 ExcelError::new(ExcelErrorKind::Calc)
2087 .with_message("LAMBDA value must be invoked"),
2088 )));
2089 }
2090 }
2091
2092 // Validate
2093 if values.len() != dates.len() || values.len() < 2 {
2094 return Ok(CalcValue::Scalar(
2095 LiteralValue::Error(ExcelError::new_num()),
2096 ));
2097 }
2098
2099 // Check that we have at least one positive and one negative cash flow
2100 let has_positive = values.iter().any(|&v| v > 0.0);
2101 let has_negative = values.iter().any(|&v| v < 0.0);
2102 if !has_positive || !has_negative {
2103 return Ok(CalcValue::Scalar(
2104 LiteralValue::Error(ExcelError::new_num()),
2105 ));
2106 }
2107
2108 // Initial guess
2109 let guess = if args.len() > 2 {
2110 coerce_num(&args[2])?
2111 } else {
2112 0.1
2113 };
2114
2115 // Newton-Raphson iteration to find XIRR
2116 let mut rate = guess;
2117 const MAX_ITER: i32 = 100;
2118 const EPSILON: f64 = 1e-10;
2119
2120 for _ in 0..MAX_ITER {
2121 let xnpv = calculate_xnpv(rate, &values, &dates);
2122 let d_xnpv = calculate_xnpv_derivative(rate, &values, &dates);
2123
2124 if d_xnpv.abs() < EPSILON {
2125 return Ok(CalcValue::Scalar(
2126 LiteralValue::Error(ExcelError::new_num()),
2127 ));
2128 }
2129
2130 let new_rate = rate - xnpv / d_xnpv;
2131
2132 if (new_rate - rate).abs() < EPSILON {
2133 return Ok(CalcValue::Scalar(LiteralValue::Number(new_rate)));
2134 }
2135
2136 rate = new_rate;
2137
2138 // Prevent rate from going too negative (would make (1+rate) negative)
2139 if rate <= -1.0 {
2140 rate = -0.99;
2141 }
2142 }
2143
2144 Ok(CalcValue::Scalar(
2145 LiteralValue::Error(ExcelError::new_num()),
2146 ))
2147 }
2148}
2149
2150/// Converts fractional-dollar notation into a decimal dollar value.
2151///
2152/// This is commonly used for security price formats such as thirty-seconds (`fraction = 32`).
2153///
2154/// # Remarks
2155/// - `fraction` is truncated to an integer denominator and must be `>= 1`.
2156/// - Sign convention: sign is preserved (`-x` maps to `-result`).
2157/// - No periodic rate is involved in this conversion.
2158/// - Returns `#NUM!` when `fraction < 1` after truncation.
2159/// - Fractional parsing uses denominator digit width (`ceil(log10(fraction))`).
2160///
2161/// # Examples
2162/// ```yaml,sandbox
2163/// formula: =DOLLARDE(1.02, 16)
2164/// result: 1.125
2165/// ```
2166/// ```yaml,sandbox
2167/// formula: =DOLLARDE(-3.15, 32)
2168/// result: -3.46875
2169/// ```
2170/// ```yaml,docs
2171/// related:
2172/// - DOLLARFR
2173/// faq:
2174/// - q: "Why does `DOLLARDE` truncate `fraction`?"
2175/// a: "The denominator is treated as an integer quote base; values below `1` after truncation return `#NUM!`."
2176/// ```
2177#[derive(Debug)]
2178pub struct DollardeFn;
2179/// [formualizer-docgen:schema:start]
2180/// Name: DOLLARDE
2181/// Type: DollardeFn
2182/// Min args: 2
2183/// Max args: 2
2184/// Variadic: false
2185/// Signature: DOLLARDE(arg1: number@scalar, arg2: number@scalar)
2186/// 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}
2187/// Caps: PURE
2188/// [formualizer-docgen:schema:end]
2189impl Function for DollardeFn {
2190 func_caps!(PURE);
2191 fn name(&self) -> &'static str {
2192 "DOLLARDE"
2193 }
2194 fn min_args(&self) -> usize {
2195 2
2196 }
2197 fn arg_schema(&self) -> &'static [ArgSchema] {
2198 use std::sync::LazyLock;
2199 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
2200 vec![
2201 ArgSchema::number_lenient_scalar(), // fractional_dollar
2202 ArgSchema::number_lenient_scalar(), // fraction
2203 ]
2204 });
2205 &SCHEMA[..]
2206 }
2207 fn eval<'a, 'b, 'c>(
2208 &self,
2209 args: &'c [ArgumentHandle<'a, 'b>],
2210 _ctx: &dyn FunctionContext<'b>,
2211 ) -> Result<CalcValue<'b>, ExcelError> {
2212 let fractional_dollar = coerce_num(&args[0])?;
2213 let fraction = coerce_num(&args[1])?.trunc() as i32;
2214
2215 // Validate fraction
2216 if fraction < 1 {
2217 return Ok(CalcValue::Scalar(
2218 LiteralValue::Error(ExcelError::new_num()),
2219 ));
2220 }
2221
2222 // Determine how many decimal places are in the fractional part
2223 // The fractional part represents numerator / fraction
2224 let sign = if fractional_dollar < 0.0 { -1.0 } else { 1.0 };
2225 let abs_value = fractional_dollar.abs();
2226 let integer_part = abs_value.trunc();
2227 let fractional_part = abs_value - integer_part;
2228
2229 // Calculate the number of digits needed to represent the fraction denominator
2230 let digits = (fraction as f64).log10().ceil() as i32;
2231 let multiplier = 10_f64.powi(digits);
2232
2233 // The fractional part is scaled by the multiplier, then divided by the fraction
2234 let numerator = (fractional_part * multiplier).round();
2235 let decimal_fraction = numerator / fraction as f64;
2236
2237 let result = sign * (integer_part + decimal_fraction);
2238 Ok(CalcValue::Scalar(LiteralValue::Number(result)))
2239 }
2240}
2241
2242/// Converts a decimal dollar value into fractional-dollar notation.
2243///
2244/// This is the inverse-style formatting helper used for quoted fractional price conventions.
2245///
2246/// # Remarks
2247/// - `fraction` is truncated to an integer denominator and must be `>= 1`.
2248/// - Sign convention: sign is preserved (`-x` maps to `-result`).
2249/// - No periodic rate is involved in this conversion.
2250/// - Returns `#NUM!` when `fraction < 1` after truncation.
2251/// - Fraction output is encoded by denominator digit width (`ceil(log10(fraction))`).
2252///
2253/// # Examples
2254/// ```yaml,sandbox
2255/// formula: =DOLLARFR(1.125, 16)
2256/// result: 1.02
2257/// ```
2258/// ```yaml,sandbox
2259/// formula: =DOLLARFR(-3.46875, 32)
2260/// result: -3.15
2261/// ```
2262/// ```yaml,docs
2263/// related:
2264/// - DOLLARDE
2265/// faq:
2266/// - q: "How does `DOLLARFR` encode the fractional part?"
2267/// a: "It scales the numerator into decimal digits based on `ceil(log10(fraction))`, preserving the input sign."
2268/// ```
2269#[derive(Debug)]
2270pub struct DollarfrFn;
2271/// [formualizer-docgen:schema:start]
2272/// Name: DOLLARFR
2273/// Type: DollarfrFn
2274/// Min args: 2
2275/// Max args: 2
2276/// Variadic: false
2277/// Signature: DOLLARFR(arg1: number@scalar, arg2: number@scalar)
2278/// 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}
2279/// Caps: PURE
2280/// [formualizer-docgen:schema:end]
2281impl Function for DollarfrFn {
2282 func_caps!(PURE);
2283 fn name(&self) -> &'static str {
2284 "DOLLARFR"
2285 }
2286 fn min_args(&self) -> usize {
2287 2
2288 }
2289 fn arg_schema(&self) -> &'static [ArgSchema] {
2290 use std::sync::LazyLock;
2291 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
2292 vec![
2293 ArgSchema::number_lenient_scalar(), // decimal_dollar
2294 ArgSchema::number_lenient_scalar(), // fraction
2295 ]
2296 });
2297 &SCHEMA[..]
2298 }
2299 fn eval<'a, 'b, 'c>(
2300 &self,
2301 args: &'c [ArgumentHandle<'a, 'b>],
2302 _ctx: &dyn FunctionContext<'b>,
2303 ) -> Result<CalcValue<'b>, ExcelError> {
2304 let decimal_dollar = coerce_num(&args[0])?;
2305 let fraction = coerce_num(&args[1])?.trunc() as i32;
2306
2307 // Validate fraction
2308 if fraction < 1 {
2309 return Ok(CalcValue::Scalar(
2310 LiteralValue::Error(ExcelError::new_num()),
2311 ));
2312 }
2313
2314 let sign = if decimal_dollar < 0.0 { -1.0 } else { 1.0 };
2315 let abs_value = decimal_dollar.abs();
2316 let integer_part = abs_value.trunc();
2317 let decimal_part = abs_value - integer_part;
2318
2319 // Convert decimal fraction to fractional representation
2320 // numerator = decimal_part * fraction
2321 let numerator = decimal_part * fraction as f64;
2322
2323 // Calculate the number of digits needed to represent the fraction denominator
2324 let digits = (fraction as f64).log10().ceil() as i32;
2325 let divisor = 10_f64.powi(digits);
2326
2327 // The fractional dollar format puts the numerator after the decimal point
2328 let result = sign * (integer_part + numerator / divisor);
2329 Ok(CalcValue::Scalar(LiteralValue::Number(result)))
2330 }
2331}
2332
2333/// RRI(nper, pv, fv) — equivalent interest rate for growth of an investment.
2334/// Returns (fv/pv)^(1/nper) - 1 (i.e. CAGR).
2335#[derive(Debug)]
2336pub struct RriFn;
2337/// [formualizer-docgen:schema:start]
2338/// Name: RRI
2339/// Type: RriFn
2340/// Min args: 3
2341/// Max args: 3
2342/// Variadic: false
2343/// Signature: RRI(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar)
2344/// 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}
2345/// Caps: PURE
2346/// [formualizer-docgen:schema:end]
2347impl Function for RriFn {
2348 func_caps!(PURE);
2349 fn name(&self) -> &'static str {
2350 "RRI"
2351 }
2352 fn min_args(&self) -> usize {
2353 3
2354 }
2355 fn arg_schema(&self) -> &'static [ArgSchema] {
2356 use std::sync::LazyLock;
2357 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
2358 vec![
2359 ArgSchema::number_lenient_scalar(), // nper
2360 ArgSchema::number_lenient_scalar(), // pv
2361 ArgSchema::number_lenient_scalar(), // fv
2362 ]
2363 });
2364 &SCHEMA[..]
2365 }
2366 fn eval<'a, 'b, 'c>(
2367 &self,
2368 args: &'c [ArgumentHandle<'a, 'b>],
2369 _ctx: &dyn FunctionContext<'b>,
2370 ) -> Result<CalcValue<'b>, ExcelError> {
2371 let nper = coerce_num(&args[0])?;
2372 let pv = coerce_num(&args[1])?;
2373 let fv = coerce_num(&args[2])?;
2374
2375 // nper must be > 0, pv must be non-zero
2376 if nper <= 0.0 || pv == 0.0 {
2377 return Ok(CalcValue::Scalar(
2378 LiteralValue::Error(ExcelError::new_num()),
2379 ));
2380 }
2381
2382 // If pv and fv have different signs, the ratio is negative and
2383 // fractional exponent would produce NaN → Excel returns #NUM!
2384 let ratio = fv / pv;
2385 if ratio < 0.0 {
2386 return Ok(CalcValue::Scalar(
2387 LiteralValue::Error(ExcelError::new_num()),
2388 ));
2389 }
2390
2391 let result = ratio.powf(1.0 / nper) - 1.0;
2392 Ok(CalcValue::Scalar(LiteralValue::Number(result)))
2393 }
2394}
2395
2396/// Returns the interest paid on the outstanding principal for a specific period
2397/// of an investment with even principal payments.
2398/// Calculates interest paid in a period for a loan repaid with equal-principal payments.
2399///
2400/// Unlike `IPMT`, `ISPMT` assumes the principal is repaid in equal installments
2401/// so the interest portion decreases linearly over the life of the loan.
2402///
2403/// # Remarks
2404/// - `rate` is the interest rate per period.
2405/// - `per` is 0-based period number (0 to `nper - 1`).
2406/// - `nper` is the total number of payment periods; must be non-zero.
2407/// - `pv` is the present value (principal).
2408/// - Formula: `pv * rate * (per / nper - 1)`.
2409/// - The result is typically negative for a positive loan principal, representing interest paid.
2410/// - Returns `#NUM!` when `nper` is zero.
2411///
2412/// # Examples
2413/// ```excel
2414/// =ISPMT(0.005, 1, 24, 100000)
2415/// ```
2416///
2417/// ```yaml,sandbox
2418/// title: "Interest in the first period"
2419/// formula: '=ISPMT(0.005, 1, 24, 100000)'
2420/// expected: -479.1666666666667
2421/// ```
2422///
2423/// ```yaml,docs
2424/// related:
2425/// - IPMT
2426/// - PPMT
2427/// - PMT
2428/// faq:
2429/// - q: "How is ISPMT different from IPMT?"
2430/// a: "ISPMT assumes equal principal repayment, so interest declines linearly instead of following an annuity schedule."
2431/// ```
2432#[derive(Debug)]
2433pub struct IspmtFn;
2434
2435/// [formualizer-docgen:schema:start]
2436/// Name: ISPMT
2437/// Type: IspmtFn
2438/// Min args: 4
2439/// Max args: 4
2440/// Variadic: false
2441/// Signature: ISPMT(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar, arg4: number@scalar)
2442/// 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}
2443/// Caps: PURE
2444/// [formualizer-docgen:schema:end]
2445impl Function for IspmtFn {
2446 func_caps!(PURE);
2447 fn name(&self) -> &'static str {
2448 "ISPMT"
2449 }
2450 fn min_args(&self) -> usize {
2451 4
2452 }
2453 fn arg_schema(&self) -> &'static [ArgSchema] {
2454 use std::sync::LazyLock;
2455 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
2456 vec![
2457 ArgSchema::number_lenient_scalar(), // rate
2458 ArgSchema::number_lenient_scalar(), // per
2459 ArgSchema::number_lenient_scalar(), // nper
2460 ArgSchema::number_lenient_scalar(), // pv
2461 ]
2462 });
2463 &SCHEMA[..]
2464 }
2465 fn eval<'a, 'b, 'c>(
2466 &self,
2467 args: &'c [ArgumentHandle<'a, 'b>],
2468 _ctx: &dyn FunctionContext<'b>,
2469 ) -> Result<CalcValue<'b>, ExcelError> {
2470 let rate = coerce_num(&args[0])?;
2471 let per = coerce_num(&args[1])?;
2472 let nper = coerce_num(&args[2])?;
2473 let pv = coerce_num(&args[3])?;
2474
2475 if nper == 0.0 {
2476 return Ok(CalcValue::Scalar(
2477 LiteralValue::Error(ExcelError::new_num()),
2478 ));
2479 }
2480
2481 // ISPMT = pv * rate * (per / nper - 1)
2482 let result = pv * rate * (per / nper - 1.0);
2483 Ok(CalcValue::Scalar(LiteralValue::Number(result)))
2484 }
2485}
2486
2487/// Returns the number of periods required for an investment to reach a
2488/// specified future value at a constant interest rate.
2489///
2490/// # Remarks
2491/// - `rate` is the interest rate per compounding period; must be positive.
2492/// - `pv` and `fv` must be positive and `fv > pv` (growth scenario) or
2493/// `fv < pv` is valid as long as both are positive.
2494/// - Formula: `(ln(fv) - ln(pv)) / ln(1 + rate)`.
2495/// - Returns `#NUM!` when `rate <= 0`, or `pv` or `fv` are non-positive.
2496///
2497/// # Examples
2498/// ```excel
2499/// =PDURATION(0.10, 1000, 2000)
2500/// ```
2501///
2502/// ```yaml,sandbox
2503/// title: "Time to double at ten percent"
2504/// formula: '=PDURATION(0.10, 1000, 2000)'
2505/// expected: 7.272540897341713
2506/// ```
2507///
2508/// ```yaml,docs
2509/// related:
2510/// - NPER
2511/// - RRI
2512/// - FV
2513/// faq:
2514/// - q: "Does PDURATION require growth?"
2515/// a: "It requires positive present and future values plus a positive rate; the logarithmic formula then returns the compounding periods needed to move between them."
2516/// ```
2517#[derive(Debug)]
2518pub struct PdurationFn;
2519
2520/// [formualizer-docgen:schema:start]
2521/// Name: PDURATION
2522/// Type: PdurationFn
2523/// Min args: 3
2524/// Max args: 3
2525/// Variadic: false
2526/// Signature: PDURATION(arg1: number@scalar, arg2: number@scalar, arg3: number@scalar)
2527/// 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}
2528/// Caps: PURE
2529/// [formualizer-docgen:schema:end]
2530impl Function for PdurationFn {
2531 func_caps!(PURE);
2532 fn name(&self) -> &'static str {
2533 "PDURATION"
2534 }
2535 fn min_args(&self) -> usize {
2536 3
2537 }
2538 fn arg_schema(&self) -> &'static [ArgSchema] {
2539 use std::sync::LazyLock;
2540 static SCHEMA: LazyLock<Vec<ArgSchema>> = LazyLock::new(|| {
2541 vec![
2542 ArgSchema::number_lenient_scalar(), // rate
2543 ArgSchema::number_lenient_scalar(), // pv
2544 ArgSchema::number_lenient_scalar(), // fv
2545 ]
2546 });
2547 &SCHEMA[..]
2548 }
2549 fn eval<'a, 'b, 'c>(
2550 &self,
2551 args: &'c [ArgumentHandle<'a, 'b>],
2552 _ctx: &dyn FunctionContext<'b>,
2553 ) -> Result<CalcValue<'b>, ExcelError> {
2554 let rate = coerce_num(&args[0])?;
2555 let pv = coerce_num(&args[1])?;
2556 let fv = coerce_num(&args[2])?;
2557
2558 if rate <= 0.0 || pv <= 0.0 || fv <= 0.0 {
2559 return Ok(CalcValue::Scalar(
2560 LiteralValue::Error(ExcelError::new_num()),
2561 ));
2562 }
2563
2564 let result = (fv.ln() - pv.ln()) / (1.0 + rate).ln();
2565 Ok(CalcValue::Scalar(LiteralValue::Number(result)))
2566 }
2567}
2568
2569pub fn register_builtins() {
2570 use std::sync::Arc;
2571 crate::function_registry::register_function(Arc::new(PmtFn));
2572 crate::function_registry::register_function(Arc::new(PvFn));
2573 crate::function_registry::register_function(Arc::new(FvFn));
2574 crate::function_registry::register_function(Arc::new(NpvFn));
2575 crate::function_registry::register_function(Arc::new(NperFn));
2576 crate::function_registry::register_function(Arc::new(RateFn));
2577 crate::function_registry::register_function(Arc::new(IpmtFn));
2578 crate::function_registry::register_function(Arc::new(PpmtFn));
2579 crate::function_registry::register_function(Arc::new(EffectFn));
2580 crate::function_registry::register_function(Arc::new(NominalFn));
2581 crate::function_registry::register_function(Arc::new(IrrFn));
2582 crate::function_registry::register_function(Arc::new(MirrFn));
2583 crate::function_registry::register_function(Arc::new(CumipmtFn));
2584 crate::function_registry::register_function(Arc::new(CumprincFn));
2585 crate::function_registry::register_function(Arc::new(XnpvFn));
2586 crate::function_registry::register_function(Arc::new(XirrFn));
2587 crate::function_registry::register_function(Arc::new(DollardeFn));
2588 crate::function_registry::register_function(Arc::new(DollarfrFn));
2589 crate::function_registry::register_function(Arc::new(RriFn));
2590 crate::function_registry::register_function(Arc::new(IspmtFn));
2591 crate::function_registry::register_function(Arc::new(PdurationFn));
2592}