use std::collections::HashMap;
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct AccountTypeSnapshot {
pub account_type: String,
pub month: String,
pub balance: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct TrendResult {
pub monthly_snapshots: Vec<MonthlyNetWorth>,
pub latest_net_worth: f64,
pub net_worth_change: f64,
pub by_account_type: HashMap<String, TypeSummary>,
pub biggest_mover: Option<BiggestMover>,
pub total_assets: f64,
pub total_liabilities: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct MonthlyNetWorth {
pub month: String,
pub net_worth: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct TypeSummary {
pub change: f64,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct BiggestMover {
pub account_type: String,
pub change: f64,
}
pub fn compute_trend(snapshots: &[AccountTypeSnapshot]) -> TrendResult {
if snapshots.is_empty() {
return TrendResult {
monthly_snapshots: vec![],
latest_net_worth: 0.0,
net_worth_change: 0.0,
by_account_type: HashMap::new(),
biggest_mover: None,
total_assets: 0.0,
total_liabilities: 0.0,
};
}
let mut months: Vec<String> = snapshots.iter().map(|s| s.month.clone()).collect();
months.sort();
months.dedup();
let monthly_snapshots: Vec<MonthlyNetWorth> = months
.iter()
.map(|m| {
let net_worth: f64 = snapshots
.iter()
.filter(|s| &s.month == m)
.map(|s| s.balance)
.sum();
MonthlyNetWorth {
month: m.clone(),
net_worth,
}
})
.collect();
let latest_net_worth = monthly_snapshots.last().map(|m| m.net_worth).unwrap_or(0.0);
let net_worth_change = if monthly_snapshots.len() >= 2 {
latest_net_worth - monthly_snapshots[0].net_worth
} else {
0.0
};
let latest_month = months.last().unwrap();
let all_types: Vec<String> = {
let mut ts: Vec<String> = snapshots.iter().map(|s| s.account_type.clone()).collect();
ts.sort();
ts.dedup();
ts
};
let first_seen_month_for = |acct_type: &str| -> &str {
months
.iter()
.find(|m| {
snapshots
.iter()
.any(|s| &s.month == *m && s.account_type == acct_type)
})
.map(|m| m.as_str())
.unwrap_or(latest_month.as_str())
};
let balance_for = |month: &str, acct_type: &str| -> f64 {
snapshots
.iter()
.filter(|s| s.month == month && s.account_type == acct_type)
.map(|s| s.balance)
.sum()
};
let by_account_type: HashMap<String, TypeSummary> = all_types
.iter()
.map(|t| {
let baseline_month = first_seen_month_for(t);
let baseline_bal = balance_for(baseline_month, t);
let latest_bal = balance_for(latest_month, t);
let change = latest_bal - baseline_bal;
(t.clone(), TypeSummary { change })
})
.collect();
let biggest_mover = if months.len() >= 2 {
by_account_type
.iter()
.max_by(|a, b| {
a.1.change
.abs()
.partial_cmp(&b.1.change.abs())
.unwrap()
.then_with(|| b.0.cmp(a.0))
})
.map(|(t, s)| BiggestMover {
account_type: t.clone(),
change: s.change,
})
} else {
None
};
let latest_rows: Vec<&AccountTypeSnapshot> = snapshots
.iter()
.filter(|s| &s.month == latest_month)
.collect();
let total_assets: f64 = latest_rows
.iter()
.filter(|s| s.balance > 0.0)
.map(|s| s.balance)
.sum();
let total_liabilities: f64 = latest_rows
.iter()
.filter(|s| s.balance < 0.0)
.map(|s| s.balance.abs())
.sum();
TrendResult {
monthly_snapshots,
latest_net_worth,
net_worth_change,
by_account_type,
biggest_mover,
total_assets,
total_liabilities,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(month: &str, account_type: &str, balance: f64) -> AccountTypeSnapshot {
AccountTypeSnapshot {
month: month.to_string(),
account_type: account_type.to_string(),
balance,
}
}
#[test]
fn three_months_produce_three_data_points() {
let snapshots = vec![
snap("2026-03", "depository", 10_000.0),
snap("2026-03", "brokerage", 50_000.0),
snap("2026-03", "credit", -5_000.0),
snap("2026-04", "depository", 11_000.0),
snap("2026-04", "brokerage", 52_000.0),
snap("2026-04", "credit", -4_800.0),
snap("2026-05", "depository", 12_000.0),
snap("2026-05", "brokerage", 54_000.0),
snap("2026-05", "credit", -4_600.0),
];
let result = compute_trend(&snapshots);
assert_eq!(result.monthly_snapshots.len(), 3);
}
#[test]
fn latest_net_worth_is_sum_of_latest_month() {
let snapshots = vec![
snap("2026-03", "depository", 10_000.0),
snap("2026-03", "brokerage", 50_000.0),
snap("2026-03", "credit", -5_000.0),
snap("2026-04", "depository", 11_000.0),
snap("2026-04", "brokerage", 52_000.0),
snap("2026-04", "credit", -4_800.0),
snap("2026-05", "depository", 12_000.0),
snap("2026-05", "brokerage", 54_000.0),
snap("2026-05", "credit", -4_600.0),
];
let result = compute_trend(&snapshots);
assert!((result.latest_net_worth - 61_400.0).abs() < 0.01);
}
#[test]
fn net_worth_change_is_latest_minus_earliest() {
let snapshots = vec![
snap("2026-03", "depository", 10_000.0),
snap("2026-03", "brokerage", 50_000.0),
snap("2026-03", "credit", -5_000.0),
snap("2026-04", "depository", 11_000.0),
snap("2026-04", "brokerage", 52_000.0),
snap("2026-04", "credit", -4_800.0),
snap("2026-05", "depository", 12_000.0),
snap("2026-05", "brokerage", 54_000.0),
snap("2026-05", "credit", -4_600.0),
];
let result = compute_trend(&snapshots);
assert!((result.net_worth_change - 6_400.0).abs() < 0.01);
}
#[test]
fn single_month_produces_one_data_point_and_zero_change() {
let snapshots = vec![snap("2026-05", "depository", 20_000.0)];
let result = compute_trend(&snapshots);
assert_eq!(result.monthly_snapshots.len(), 1);
assert!((result.net_worth_change - 0.0).abs() < 0.01);
assert!((result.latest_net_worth - 20_000.0).abs() < 0.01);
}
#[test]
fn empty_snapshots_return_zeros() {
let result = compute_trend(&[]);
assert_eq!(result.monthly_snapshots.len(), 0);
assert!((result.latest_net_worth - 0.0).abs() < 0.01);
assert!((result.net_worth_change - 0.0).abs() < 0.01);
assert!(result.biggest_mover.is_none());
}
#[test]
fn biggest_mover_is_largest_absolute_change() {
let snapshots = vec![
snap("2026-04", "depository", 10_000.0),
snap("2026-04", "brokerage", 40_000.0),
snap("2026-04", "credit", -3_000.0),
snap("2026-05", "depository", 10_200.0),
snap("2026-05", "brokerage", 45_000.0),
snap("2026-05", "credit", -2_900.0),
];
let result = compute_trend(&snapshots);
let mover = result.biggest_mover.expect("must have a biggest mover");
assert_eq!(mover.account_type, "brokerage");
assert!((mover.change - 5_000.0).abs() < 0.01);
}
#[test]
fn liability_reduction_is_positive_change() {
let snapshots = vec![
snap("2026-04", "credit", -10_000.0),
snap("2026-05", "credit", -7_000.0),
];
let result = compute_trend(&snapshots);
let credit = result
.by_account_type
.get("credit")
.expect("credit must be present");
assert!(
(credit.change - 3_000.0).abs() < 0.01,
"got {}",
credit.change
);
}
#[test]
fn asset_liability_split_from_latest_month() {
let snapshots = vec![
snap("2026-05", "depository", 15_000.0),
snap("2026-05", "brokerage", 60_000.0),
snap("2026-05", "credit", -8_000.0),
snap("2026-05", "loan", -20_000.0),
];
let result = compute_trend(&snapshots);
assert!((result.total_assets - 75_000.0).abs() < 0.01);
assert!((result.total_liabilities - 28_000.0).abs() < 0.01);
}
#[test]
fn by_account_type_reports_per_type_change() {
let snapshots = vec![
snap("2026-04", "depository", 10_000.0),
snap("2026-04", "brokerage", 40_000.0),
snap("2026-05", "depository", 10_200.0),
snap("2026-05", "brokerage", 45_000.0),
];
let result = compute_trend(&snapshots);
let brokerage = result
.by_account_type
.get("brokerage")
.expect("brokerage must be present");
assert!((brokerage.change - 5_000.0).abs() < 0.01);
let depository = result
.by_account_type
.get("depository")
.expect("depository must be present");
assert!((depository.change - 200.0).abs() < 0.01);
}
#[test]
fn single_month_has_no_biggest_mover() {
let snapshots = vec![snap("2026-05", "depository", 20_000.0)];
let result = compute_trend(&snapshots);
assert!(result.biggest_mover.is_none());
}
}