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 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 {
88            timestamp,
89            amount,
90            provider_id: None,
91        }
92    }
93
94    #[test]
95    fn test_empty_dividends() {
96        let a = DividendAnalytics::from_dividends(&[]);
97        assert_eq!(a.payment_count, 0);
98        assert_eq!(a.total_paid, 0.0);
99        assert!(a.cagr.is_none());
100    }
101
102    #[test]
103    fn test_single_dividend() {
104        let a = DividendAnalytics::from_dividends(&[div(1_000_000_000, 0.50)]);
105        assert_eq!(a.payment_count, 1);
106        assert!((a.total_paid - 0.50).abs() < 1e-9);
107        assert!(a.cagr.is_none()); // only one payment, can't compute CAGR
108    }
109
110    #[test]
111    fn test_cagr_two_years() {
112        // Two payments ~2 years apart: 0.50 → 0.605 ≈ 10% CAGR
113        let secs_per_year = 31_557_600_i64;
114        let t0 = 1_600_000_000_i64;
115        let t1 = t0 + 2 * secs_per_year;
116        let a = DividendAnalytics::from_dividends(&[div(t0, 0.50), div(t1, 0.605)]);
117        assert!(a.cagr.is_some());
118        let cagr = a.cagr.unwrap();
119        assert!(
120            (cagr - 0.10).abs() < 0.01,
121            "expected ~10% CAGR, got {cagr:.4}"
122        );
123    }
124
125    #[test]
126    fn test_totals() {
127        let divs = [
128            div(1_000_000, 0.25),
129            div(2_000_000, 0.25),
130            div(3_000_000, 0.25),
131        ];
132        let a = DividendAnalytics::from_dividends(&divs);
133        assert_eq!(a.payment_count, 3);
134        assert!((a.total_paid - 0.75).abs() < 1e-9);
135        assert!((a.average_payment - 0.25).abs() < 1e-9);
136    }
137}