Skip to main content

finance_query/models/chart/
dividend_analytics.rs

1//! Dividend analytics computed from historical dividend data.
2
3use serde::{Deserialize, Serialize};
4
5use super::events::Dividend;
6
7/// Computed analytics derived from a symbol's dividend history.
8///
9/// Obtain via [`Ticker::dividend_analytics`](crate::Ticker::dividend_analytics).
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[non_exhaustive]
12pub struct DividendAnalytics {
13    /// Total dividends paid in the requested range
14    pub total_paid: f64,
15    /// Number of dividend payments in the requested range
16    pub payment_count: usize,
17    /// Average dividend per payment
18    pub average_payment: f64,
19    /// Compound Annual Growth Rate of the dividend amount.
20    ///
21    /// `None` when fewer than two payments spanning at least one year are available.
22    pub cagr: Option<f64>,
23    /// Most recent dividend payment
24    pub last_payment: Option<Dividend>,
25    /// Earliest dividend payment in the requested range
26    pub first_payment: Option<Dividend>,
27}
28
29impl DividendAnalytics {
30    /// Compute analytics from a pre-filtered, chronologically sorted slice of dividends.
31    pub(crate) fn from_dividends(dividends: &[Dividend]) -> Self {
32        if dividends.is_empty() {
33            return Self {
34                total_paid: 0.0,
35                payment_count: 0,
36                average_payment: 0.0,
37                cagr: None,
38                last_payment: None,
39                first_payment: None,
40            };
41        }
42
43        let total_paid: f64 = dividends.iter().map(|d| d.amount).sum();
44        let payment_count = dividends.len();
45        let average_payment = total_paid / payment_count as f64;
46
47        let first = dividends.first().cloned();
48        let last = dividends.last().cloned();
49
50        let cagr = compute_cagr(&first, &last);
51
52        Self {
53            total_paid,
54            payment_count,
55            average_payment,
56            cagr,
57            last_payment: last,
58            first_payment: first,
59        }
60    }
61}
62
63/// Compute dividend CAGR between two payments.
64///
65/// Requires at least one full year between payments and both amounts > 0.
66fn compute_cagr(first: &Option<Dividend>, last: &Option<Dividend>) -> Option<f64> {
67    let first = first.as_ref()?;
68    let last = last.as_ref()?;
69
70    if first.amount <= 0.0 || last.amount <= 0.0 {
71        return None;
72    }
73
74    let years = (last.timestamp - first.timestamp) as f64 / 31_557_600.0; // seconds per Julian year
75    if years < 1.0 {
76        return None;
77    }
78
79    Some((last.amount / first.amount).powf(1.0 / years) - 1.0)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    fn div(timestamp: i64, amount: f64) -> Dividend {
87        Dividend { timestamp, amount }
88    }
89
90    #[test]
91    fn test_empty_dividends() {
92        let a = DividendAnalytics::from_dividends(&[]);
93        assert_eq!(a.payment_count, 0);
94        assert_eq!(a.total_paid, 0.0);
95        assert!(a.cagr.is_none());
96    }
97
98    #[test]
99    fn test_single_dividend() {
100        let a = DividendAnalytics::from_dividends(&[div(1_000_000_000, 0.50)]);
101        assert_eq!(a.payment_count, 1);
102        assert!((a.total_paid - 0.50).abs() < 1e-9);
103        assert!(a.cagr.is_none()); // only one payment, can't compute CAGR
104    }
105
106    #[test]
107    fn test_cagr_two_years() {
108        // Two payments ~2 years apart: 0.50 → 0.605 ≈ 10% CAGR
109        let secs_per_year = 31_557_600_i64;
110        let t0 = 1_600_000_000_i64;
111        let t1 = t0 + 2 * secs_per_year;
112        let a = DividendAnalytics::from_dividends(&[div(t0, 0.50), div(t1, 0.605)]);
113        assert!(a.cagr.is_some());
114        let cagr = a.cagr.unwrap();
115        assert!(
116            (cagr - 0.10).abs() < 0.01,
117            "expected ~10% CAGR, got {cagr:.4}"
118        );
119    }
120
121    #[test]
122    fn test_totals() {
123        let divs = [
124            div(1_000_000, 0.25),
125            div(2_000_000, 0.25),
126            div(3_000_000, 0.25),
127        ];
128        let a = DividendAnalytics::from_dividends(&divs);
129        assert_eq!(a.payment_count, 3);
130        assert!((a.total_paid - 0.75).abs() < 1e-9);
131        assert!((a.average_payment - 0.25).abs() < 1e-9);
132    }
133}