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}