Skip to main content

finance_solution/returns/
rule_of_72.rs

1//! Approximate years to double (Rule of 72 / 69 / 70) and exact doubling time.
2//!
3//! Prefer [`doubling_solution`] for formulas, comparison tables, and multi-rate scenarios.
4//! Prefer [`doubling_time`] when you only need the exact discrete result.
5//!
6//! # Error handling (v0.1+)
7//!
8//! All public entry points return [`FinanceResult`]. Zero or negative rates and non-finite
9//! inputs yield [`FinanceError`] — they do not panic.
10use crate::util::error::{require_finite, FinanceError, FinanceResult};
11use crate::{columns_with_strings, print_table_locale_opt};
12
13// ---------------------------------------------------------------------------
14// Scalars
15// ---------------------------------------------------------------------------
16
17/// Approximate years to double using the Rule of 72: `72 / (100 * rate)`.
18///
19/// `rate` is a decimal rate (e.g. `0.08` for 8%).
20///
21/// # Errors
22/// [`FinanceError::ZeroValue`] if `rate == 0`, [`FinanceError::InvalidRate`] if `rate < 0`,
23/// or [`FinanceError::NonFinite`] if non-finite.
24///
25/// # Examples
26/// ```
27/// use finance_solution::{rule_of_72, FinanceError};
28///
29/// assert_eq!((rule_of_72(0.08).unwrap() * 100.0).round() / 100.0, 9.0); // 72/8
30///
31/// match rule_of_72(0.0) {
32///     Err(FinanceError::ZeroValue { field }) => assert_eq!(field, "rate"),
33///     other => panic!("expected ZeroValue, got {other:?}"),
34/// }
35/// ```
36pub fn rule_of_72(rate: f64) -> FinanceResult<f64> {
37    require_positive_rate(rate)?;
38    Ok(72.0 / (rate * 100.0))
39}
40
41/// Approximate years to double using the Rule of 70: `70 / (100 * rate)`.
42///
43/// # Errors
44/// Same domain as [`rule_of_72`].
45///
46/// # Examples
47/// ```
48/// use finance_solution::{rule_of_70, FinanceError};
49///
50/// assert!((rule_of_70(0.08).unwrap() - 8.75).abs() < 1e-12); // 70/8
51/// assert!(matches!(rule_of_70(0.0), Err(FinanceError::ZeroValue { .. })));
52/// ```
53pub fn rule_of_70(rate: f64) -> FinanceResult<f64> {
54    require_positive_rate(rate)?;
55    Ok(70.0 / (rate * 100.0))
56}
57
58/// Approximate years to double using the Rule of 69: `69 / (100 * rate)`.
59///
60/// Often closer to continuous compounding than the Rule of 72.
61///
62/// # Errors
63/// Same domain as [`rule_of_72`].
64///
65/// # Examples
66/// ```
67/// use finance_solution::{rule_of_69, FinanceError};
68///
69/// assert!((rule_of_69(0.08).unwrap() - 8.625).abs() < 1e-12); // 69/8
70/// match rule_of_69(-0.01) {
71///     Err(FinanceError::InvalidRate { rate }) => assert!(rate < 0.0),
72///     other => panic!("expected InvalidRate, got {other:?}"),
73/// }
74/// ```
75pub fn rule_of_69(rate: f64) -> FinanceResult<f64> {
76    require_positive_rate(rate)?;
77    Ok(69.0 / (rate * 100.0))
78}
79
80/// Exact periods to double under discrete compounding: `ln(2) / ln(1 + rate)`.
81///
82/// # Errors
83/// [`FinanceError::InvalidRate`] if `rate <= 0`, or non-finite / unsolvable rates.
84///
85/// # Examples
86/// ```
87/// use finance_solution::{doubling_time, rule_of_72, FinanceError};
88///
89/// let exact = doubling_time(0.08).unwrap();
90/// let approx = rule_of_72(0.08).unwrap();
91/// assert!((exact - approx).abs() < 0.5);
92/// assert!((exact - 9.0065).abs() < 1e-3);
93///
94/// match doubling_time(-0.05) {
95///     Err(FinanceError::InvalidRate { rate }) => assert!(rate < 0.0),
96///     other => panic!("expected InvalidRate, got {other:?}"),
97/// }
98/// ```
99pub fn doubling_time(rate: f64) -> FinanceResult<f64> {
100    require_finite("rate", rate)?;
101    if rate <= 0.0 {
102        return Err(FinanceError::InvalidRate { rate });
103    }
104    let denom = (1.0 + rate).ln();
105    if denom == 0.0 || !denom.is_finite() {
106        return Err(FinanceError::Unsolvable {
107            message: "cannot compute doubling time for this rate",
108        });
109    }
110    Ok(2.0_f64.ln() / denom)
111}
112
113/// Exact time to double under continuous compounding: `ln(2) / rate`.
114///
115/// Slightly shorter than discrete [`doubling_time`] for the same nominal rate.
116///
117/// # Errors
118/// Same domain as [`rule_of_72`] (strictly positive finite rate).
119///
120/// # Examples
121/// ```
122/// use finance_solution::{doubling_time, doubling_time_continuous, FinanceError};
123///
124/// let cont = doubling_time_continuous(0.08).unwrap();
125/// let disc = doubling_time(0.08).unwrap();
126/// assert!(cont < disc);
127/// assert!(matches!(
128///     doubling_time_continuous(0.0),
129///     Err(FinanceError::ZeroValue { .. })
130/// ));
131/// ```
132pub fn doubling_time_continuous(rate: f64) -> FinanceResult<f64> {
133    require_positive_rate(rate)?;
134    Ok(2.0_f64.ln() / rate)
135}
136
137// ---------------------------------------------------------------------------
138// Solution
139// ---------------------------------------------------------------------------
140
141/// Comparison of doubling-time methods at a single rate.
142///
143/// Create with [`doubling_solution`].
144///
145/// # Examples
146/// ```
147/// use finance_solution::*;
148///
149/// let s = doubling_solution(0.08).unwrap();
150/// assert_rounded_2!(s.rule_of_72(), 9.0);
151/// assert_rounded_4!(s.exact(), 9.0065);
152/// // Table of method vs years (and error vs exact):
153/// s.print_table();
154/// ```
155///
156/// Sample `print_table()` output at 8%:
157///
158/// ```text
159/// method               years      error_vs_exact
160/// ---------------  ---------  ----------------
161/// rule_of_72          9.0000           -0.0065
162/// rule_of_70          8.7500           -0.2565
163/// rule_of_69          8.6250           -0.3815
164/// exact_discrete      9.0065            0.0000
165/// exact_continuous    8.6643           -0.3421
166/// ```
167#[derive(Clone, Debug)]
168pub struct DoublingSolution {
169    rate: f64,
170    rule_of_72: f64,
171    rule_of_70: f64,
172    rule_of_69: f64,
173    exact: f64,
174    exact_continuous: f64,
175    formula: String,
176    symbolic_formula: String,
177}
178
179impl DoublingSolution {
180    pub fn rate(&self) -> f64 {
181        self.rate
182    }
183    pub fn rule_of_72(&self) -> f64 {
184        self.rule_of_72
185    }
186    pub fn rule_of_70(&self) -> f64 {
187        self.rule_of_70
188    }
189    pub fn rule_of_69(&self) -> f64 {
190        self.rule_of_69
191    }
192    /// Exact discrete compounding: `ln(2) / ln(1+r)`.
193    pub fn exact(&self) -> f64 {
194        self.exact
195    }
196    /// Exact continuous compounding: `ln(2) / r`.
197    pub fn exact_continuous(&self) -> f64 {
198        self.exact_continuous
199    }
200    pub fn formula(&self) -> &str {
201        &self.formula
202    }
203    pub fn symbolic_formula(&self) -> &str {
204        &self.symbolic_formula
205    }
206
207    /// Absolute error of Rule of 72 vs exact discrete doubling time.
208    pub fn error_rule_of_72(&self) -> f64 {
209        self.rule_of_72 - self.exact
210    }
211
212    /// Print method comparison at this rate.
213    pub fn print_table(&self) {
214        self.print_table_locale_opt(None, None);
215    }
216
217    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
218        self.print_table_locale_opt(Some(locale), Some(precision));
219    }
220
221    fn print_table_locale_opt(
222        &self,
223        locale: Option<&num_format::Locale>,
224        precision: Option<usize>,
225    ) {
226        let columns = columns_with_strings(&[
227            ("method", "s", true),
228            ("years", "f", true),
229            ("error_vs_exact", "f", true),
230        ]);
231        let rows = [
232            ("rule_of_72", self.rule_of_72),
233            ("rule_of_70", self.rule_of_70),
234            ("rule_of_69", self.rule_of_69),
235            ("exact_discrete", self.exact),
236            ("exact_continuous", self.exact_continuous),
237        ];
238        let data = rows
239            .iter()
240            .map(|(name, years)| {
241                vec![
242                    name.to_string(),
243                    years.to_string(),
244                    (years - self.exact).to_string(),
245                ]
246            })
247            .collect();
248        print_table_locale_opt(&columns, data, locale, precision);
249    }
250}
251
252/// Build a [`DoublingSolution`] comparing Rule of 72/70/69 with exact discrete and continuous times.
253///
254/// # Errors
255/// Propagates validation failures from the underlying doubling formulas.
256///
257/// # Examples
258/// ```
259/// use finance_solution::{doubling_solution, FinanceError};
260///
261/// let s = doubling_solution(0.10).unwrap();
262/// // Symmetry: $1 grown at r for exact periods ≈ $2
263/// let grown = (1.0_f64 + s.rate()).powf(s.exact());
264/// assert!((grown - 2.0).abs() < 1e-9);
265///
266/// match doubling_solution(-0.05) {
267///     Err(FinanceError::InvalidRate { .. }) => {}
268///     other => panic!("expected InvalidRate, got {other:?}"),
269/// }
270/// ```
271pub fn doubling_solution(rate: f64) -> FinanceResult<DoublingSolution> {
272    let r72 = rule_of_72(rate)?;
273    let r70 = rule_of_70(rate)?;
274    let r69 = rule_of_69(rate)?;
275    let exact = doubling_time(rate)?;
276    let exact_c = doubling_time_continuous(rate)?;
277    let formula = format!(
278        "exact {:.4} = ln(2) / ln(1 + {:.6}); rule_72 {:.4} = 72 / ({:.4})",
279        exact,
280        rate,
281        r72,
282        rate * 100.0
283    );
284    let symbolic =
285        "exact = ln(2)/ln(1+r); rule_72 = 72/(100*r); rule_70 = 70/(100*r); rule_69 = 69/(100*r); continuous = ln(2)/r";
286    Ok(DoublingSolution {
287        rate,
288        rule_of_72: r72,
289        rule_of_70: r70,
290        rule_of_69: r69,
291        exact,
292        exact_continuous: exact_c,
293        formula,
294        symbolic_formula: symbolic.to_string(),
295    })
296}
297
298// ---------------------------------------------------------------------------
299// Multi-rate scenario table
300// ---------------------------------------------------------------------------
301
302/// One row of a multi-rate doubling comparison.
303#[derive(Clone, Debug)]
304pub struct DoublingRateRow {
305    rate: f64,
306    rule_of_72: f64,
307    rule_of_70: f64,
308    rule_of_69: f64,
309    exact: f64,
310    exact_continuous: f64,
311}
312
313impl DoublingRateRow {
314    pub fn rate(&self) -> f64 {
315        self.rate
316    }
317    pub fn rule_of_72(&self) -> f64 {
318        self.rule_of_72
319    }
320    pub fn rule_of_70(&self) -> f64 {
321        self.rule_of_70
322    }
323    pub fn rule_of_69(&self) -> f64 {
324        self.rule_of_69
325    }
326    pub fn exact(&self) -> f64 {
327        self.exact
328    }
329    pub fn exact_continuous(&self) -> f64 {
330        self.exact_continuous
331    }
332    pub fn error_rule_of_72(&self) -> f64 {
333        self.rule_of_72 - self.exact
334    }
335}
336
337/// Table of doubling estimates across many rates (teaching: when is Rule of 72 “good enough?”).
338#[derive(Clone, Debug)]
339pub struct DoublingRateSeries(Vec<DoublingRateRow>);
340
341impl DoublingRateSeries {
342    pub fn rows(&self) -> &[DoublingRateRow] {
343        &self.0
344    }
345
346    pub fn print_table(&self) {
347        self.print_table_locale_opt(None, None);
348    }
349
350    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
351        self.print_table_locale_opt(Some(locale), Some(precision));
352    }
353
354    fn print_table_locale_opt(
355        &self,
356        locale: Option<&num_format::Locale>,
357        precision: Option<usize>,
358    ) {
359        let columns = columns_with_strings(&[
360            ("rate", "r", true),
361            ("rule_72", "f", true),
362            ("rule_70", "f", true),
363            ("rule_69", "f", true),
364            ("exact", "f", true),
365            ("continuous", "f", true),
366            ("err_72", "f", true),
367        ]);
368        let data = self
369            .0
370            .iter()
371            .map(|row| {
372                vec![
373                    row.rate.to_string(),
374                    row.rule_of_72.to_string(),
375                    row.rule_of_70.to_string(),
376                    row.rule_of_69.to_string(),
377                    row.exact.to_string(),
378                    row.exact_continuous.to_string(),
379                    row.error_rule_of_72().to_string(),
380                ]
381            })
382            .collect();
383        print_table_locale_opt(&columns, data, locale, precision);
384    }
385}
386
387/// Compare doubling rules across a list of rates (teaching: when is Rule of 72 “good enough?”).
388///
389/// # Examples
390/// ```
391/// use finance_solution::{doubling_compare_rates, FinanceError};
392///
393/// let table = doubling_compare_rates(&[0.05, 0.08]).unwrap();
394/// assert_eq!(table.rows().len(), 2);
395///
396/// match doubling_compare_rates(&[]) {
397///     Err(FinanceError::Unsolvable { message }) => assert!(message.contains("one rate")),
398///     other => panic!("expected Unsolvable, got {other:?}"),
399/// }
400/// match doubling_compare_rates(&[0.05, -0.01]) {
401///     Err(FinanceError::InvalidRate { .. }) => {}
402///     other => panic!("expected InvalidRate, got {other:?}"),
403/// }
404/// ```
405pub fn doubling_compare_rates(rates: &[f64]) -> FinanceResult<DoublingRateSeries> {
406    if rates.is_empty() {
407        return Err(FinanceError::Unsolvable {
408            message: "doubling_compare_rates requires at least one rate",
409        });
410    }
411    let mut rows = Vec::with_capacity(rates.len());
412    for &rate in rates {
413        let s = doubling_solution(rate)?;
414        rows.push(DoublingRateRow {
415            rate: s.rate,
416            rule_of_72: s.rule_of_72,
417            rule_of_70: s.rule_of_70,
418            rule_of_69: s.rule_of_69,
419            exact: s.exact,
420            exact_continuous: s.exact_continuous,
421        });
422    }
423    Ok(DoublingRateSeries(rows))
424}
425
426fn require_positive_rate(rate: f64) -> FinanceResult<()> {
427    require_finite("rate", rate)?;
428    if rate == 0.0 {
429        return Err(FinanceError::ZeroValue { field: "rate" });
430    }
431    if rate < 0.0 {
432        return Err(FinanceError::InvalidRate { rate });
433    }
434    Ok(())
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::*;
441
442    #[test]
443    fn test_rule_of_72_eight_percent() {
444        assert_rounded_2!(rule_of_72(0.08).unwrap(), 9.0);
445        assert_rounded_2!(rule_of_70(0.08).unwrap(), 8.75);
446        assert_rounded_2!(rule_of_69(0.08).unwrap(), 8.625);
447    }
448
449    #[test]
450    fn test_doubling_time_positive() {
451        let t = doubling_time(0.10).unwrap();
452        assert!(t > 7.0 && t < 8.0);
453        assert!(doubling_time_continuous(0.10).unwrap() < t);
454    }
455
456    #[test]
457    fn test_rule_rejects_zero_and_negative() {
458        assert!(matches!(
459            rule_of_72(0.0),
460            Err(FinanceError::ZeroValue { .. })
461        ));
462        assert!(matches!(
463            doubling_time(-0.05),
464            Err(FinanceError::InvalidRate { .. })
465        ));
466        assert!(doubling_compare_rates(&[]).is_err());
467    }
468
469    #[test]
470    fn test_solution_and_symmetry() {
471        let s = doubling_solution(0.08).unwrap();
472        assert_rounded_2!(s.rule_of_72(), 9.0);
473        assert_rounded_4!(s.exact(), 9.0065);
474        let grown = (1.0 + s.rate()).powf(s.exact());
475        assert!((grown - 2.0).abs() < 1e-9);
476        assert!(!s.formula().is_empty());
477    }
478
479    #[test]
480    fn test_compare_rates() {
481        let t = doubling_compare_rates(&[0.01, 0.08, 0.12]).unwrap();
482        assert_eq!(t.rows().len(), 3);
483        // At low rates rule_72 overstates years vs exact.
484        assert!(t.rows()[0].error_rule_of_72() > 0.0);
485    }
486}