diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4bbc26b..a1d0125 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4428,6 +4428,7 @@ dependencies = [ "aes-gcm", "argon2", "base64 0.22.1", + "chrono", "ed25519-dalek", "encoding_rs", "hmac", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e5f7b75..cbc71c5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,10 @@ rand = "0.8" jsonwebtoken = "9" machine-uid = "0.5" reqwest = { version = "0.12", features = ["json"] } +# Date arithmetic for the Modified Dietz return calculator (Issue #142): +# we need day-precision diffs to weight cash flows W_i = (T - t_i) / T. +# `serde` feature lets `NaiveDate` cross the Tauri command boundary in JSON. +chrono = { version = "0.4", default-features = false, features = ["serde", "std"] } tokio = { version = "1", features = ["macros"] } hostname = "0.4" urlencoding = "2" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8c6542f..5d45944 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,10 @@ pub mod feedback_commands; pub mod fs_commands; pub mod license_commands; pub mod profile_commands; +// Modified Dietz return calculator — private helper module used by +// `balance_commands.rs`. Kept out of the wildcard re-export below because +// nothing outside `commands/` should depend on it. +pub(crate) mod return_calculator; pub mod token_store; pub use auth_commands::*; diff --git a/src-tauri/src/commands/return_calculator.rs b/src-tauri/src/commands/return_calculator.rs new file mode 100644 index 0000000..ccf65c9 --- /dev/null +++ b/src-tauri/src/commands/return_calculator.rs @@ -0,0 +1,380 @@ +//! Modified Dietz return calculator (Issue #142 / Bilan #4). +//! +//! Computes the time- and contribution-weighted return of a single account +//! over a period, given: +//! - the account value at `period_start` (snapshot lookup, may be missing), +//! - the account value at `period_end` (snapshot lookup, may be missing), +//! - the cash flows during the period (linked transfers — `+` for IN, +//! `-` for OUT; the caller already applies the direction sign). +//! +//! Modified Dietz formula: +//! +//! R = (V_end - V_start - sum(CF_i)) / (V_start + sum(W_i * CF_i)) +//! +//! where `W_i = (T - t_i) / T`, `T = period_days`, `t_i = days from period_start +//! to flow date`. A flow on day 0 is fully invested for the whole period +//! (W_i = 1) and a flow on the last day contributes nothing (W_i = 0). +//! +//! Annualization: `(1 + R)^(365 / T) - 1` for periods of strictly positive +//! length. A zero-length period (`period_start == period_end`) skips the +//! annualization step (would divide by zero). +//! +//! Edge cases (each surface as a typed flag on `AccountReturn` so the UI can +//! render an explicit warning instead of an opaque empty value): +//! - `value_start == None` → `is_partial = true`, `return_pct = None` +//! - `value_end == None` → `is_partial = true`, `return_pct = None` +//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`, +//! return collapses to the simple +//! `(V_end - V_start) / V_start` +//! - `period_start == period_end` → no annualization (stays = return_pct) +//! - V_start = 0 and first flow > 0 → account created mid-period; the +//! denominator is `0 + W_first * CF_first`, +//! which is positive as long as the +//! flow lands strictly before period_end +//! - account depleted then refilled → mathematically defined; the +//! function does not panic but the +//! magnitude can look extreme — that is +//! the inherent Modified Dietz behaviour +//! on accounts with near-zero invested +//! capital. +//! +//! Module is **private to the crate** (`pub(crate)`) and lives under +//! `commands/` per the spec — reused only by `balance_commands.rs`. + +use chrono::NaiveDate; +use serde::Serialize; + +/// Result of a Modified Dietz computation, ready to ship across the Tauri +/// boundary. Optional fields are `None` whenever the calculation cannot be +/// completed (missing snapshot endpoints) — the UI renders a dash + a tooltip +/// pointing at `is_partial` / `has_no_transfers_warning`. +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct AccountReturn { + /// Account value at `period_start` (latest snapshot ≤ period_start). + pub value_start: Option, + /// Account value at `period_end` (latest snapshot ≤ period_end). + pub value_end: Option, + /// Sum of signed cash flows during the period (`+` IN, `-` OUT). + pub net_contributions: f64, + /// Modified Dietz return as a fraction (0.05 = +5%). `None` if either + /// endpoint snapshot is missing or the denominator is non-positive. + pub return_pct: Option, + /// Annualized return `(1 + R)^(365 / T) - 1`. `None` for zero-length + /// periods or whenever `return_pct` is `None`. + pub annualized_pct: Option, + /// `true` when at least one snapshot endpoint is missing — the UI labels + /// the result as "partial / non-significatif". + pub is_partial: bool, + /// `true` when the account had zero linked transfers during the period — + /// Modified Dietz collapses to the simple `(V_end - V_start) / V_start`, + /// but the UI surfaces a warning so the user can verify whether real + /// transfers were forgotten (untagged contributions skew the return). + pub has_no_transfers_warning: bool, +} + +impl AccountReturn { + /// Default partial return when an endpoint is missing — keeps the + /// constructor calls in the algorithm body terse. + fn partial( + value_start: Option, + value_end: Option, + net_contributions: f64, + has_no_transfers_warning: bool, + ) -> Self { + Self { + value_start, + value_end, + net_contributions, + return_pct: None, + annualized_pct: None, + is_partial: true, + has_no_transfers_warning, + } + } +} + +/// Computes the Modified Dietz return for one account over the period +/// `[period_start, period_end]`. See module docs for the full formula and +/// edge-case handling. +/// +/// `cash_flows` is `(date, signed_amount)`. The caller is responsible for +/// applying the direction sign (`in` → `+`, `out` → `−`) and for filtering +/// flows to the period; flows outside `[period_start, period_end]` are +/// skipped here too as a safety net. +pub(crate) fn modified_dietz( + value_start: Option, + value_end: Option, + cash_flows: &[(NaiveDate, f64)], + period_start: NaiveDate, + period_end: NaiveDate, +) -> AccountReturn { + // Filter flows to the period (defensive — caller already does this via + // SQL, but keep the guarantee here so the math never sees out-of-range + // weights). + let in_period: Vec<(NaiveDate, f64)> = cash_flows + .iter() + .copied() + .filter(|(d, _)| *d >= period_start && *d <= period_end) + .collect(); + + let net_contributions: f64 = in_period.iter().map(|(_, cf)| *cf).sum(); + let has_no_transfers_warning = in_period.is_empty(); + + // Endpoint guards — without both V_start and V_end we cannot return a + // numeric result. + let v_start = match value_start { + Some(v) => v, + None => { + return AccountReturn::partial( + value_start, + value_end, + net_contributions, + has_no_transfers_warning, + ); + } + }; + let v_end = match value_end { + Some(v) => v, + None => { + return AccountReturn::partial( + value_start, + value_end, + net_contributions, + has_no_transfers_warning, + ); + } + }; + + // Period length in days. `(period_end - period_start)` returns + // `chrono::Duration`; `.num_days()` is `i64`. A zero-length period + // (same-day) skips weighting and annualization. + let total_days = (period_end - period_start).num_days(); + + let denominator: f64 = if total_days <= 0 { + // Same-day period: weights collapse to either 0 or undefined; treat + // every flow as fully invested (W = 1) so the denominator is + // V_start + sum(CF). This keeps the function defined when callers + // pass `period_start == period_end`. + v_start + net_contributions + } else { + let total = total_days as f64; + let weighted_sum: f64 = in_period + .iter() + .map(|(date, cf)| { + let t_i = (*date - period_start).num_days() as f64; + let w_i = (total - t_i) / total; + w_i * cf + }) + .sum(); + v_start + weighted_sum + }; + + // A non-positive denominator means we have no invested base to annualize + // against (e.g. depleted then refilled with a single late flow). Return + // the raw V_end - V_start - CF as the numerator and flag is_partial so + // the UI can show "Performance non significative" — but only when V_start + // is also 0 / negative; if V_start > 0 we keep the standard math. + if denominator <= 0.0 { + return AccountReturn { + value_start: Some(v_start), + value_end: Some(v_end), + net_contributions, + return_pct: None, + annualized_pct: None, + is_partial: true, + has_no_transfers_warning, + }; + } + + let numerator = v_end - v_start - net_contributions; + let return_pct = numerator / denominator; + + // Annualization only makes sense for strictly positive periods. + let annualized_pct = if total_days > 0 { + let exponent = 365.0 / total_days as f64; + Some((1.0 + return_pct).powf(exponent) - 1.0) + } else { + None + }; + + AccountReturn { + value_start: Some(v_start), + value_end: Some(v_end), + net_contributions, + return_pct: Some(return_pct), + annualized_pct, + is_partial: false, + has_no_transfers_warning, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Small helper that turns a `YYYY-MM-DD` string literal into a + /// `NaiveDate` — keeps the test bodies readable. + fn d(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("test date parses") + } + + fn approx(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol + } + + #[test] + fn nominal_two_flows_at_one_quarter_and_three_quarters() { + // 100-day period (2026-01-01 → 2026-04-11). V_start = 1000, V_end = + // 1100. CF1 = +50 at day 25, CF2 = +30 at day 75. + let start = d("2026-01-01"); + let end = d("2026-04-11"); // 100 days later + let flows = vec![(d("2026-01-26"), 50.0), (d("2026-03-17"), 30.0)]; + + let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end); + + // Sanity / shape + assert_eq!(r.value_start, Some(1000.0)); + assert_eq!(r.value_end, Some(1100.0)); + assert_eq!(r.net_contributions, 80.0); + assert!(!r.is_partial); + assert!(!r.has_no_transfers_warning); + + // Hand calc: + // T = 100, t1 = 25, t2 = 75 + // W1 = 75/100 = 0.75, W2 = 25/100 = 0.25 + // numerator = 1100 - 1000 - 80 = 20 + // denominator = 1000 + 0.75*50 + 0.25*30 = 1045 + // R = 20 / 1045 ≈ 0.01913876 + let r_pct = r.return_pct.expect("nominal case has a return"); + assert!( + approx(r_pct, 20.0 / 1045.0, 1e-9), + "return_pct = {} (expected ≈ {})", + r_pct, + 20.0 / 1045.0 + ); + + // Annualization: (1 + R)^(365/100) - 1 + let expected_ann = (1.0_f64 + 20.0 / 1045.0).powf(365.0 / 100.0) - 1.0; + let ann = r.annualized_pct.expect("nominal case is annualized"); + assert!(approx(ann, expected_ann, 1e-9), "annualized = {}", ann); + } + + #[test] + fn no_prior_snapshot_marks_partial() { + let start = d("2026-01-01"); + let end = d("2026-04-01"); + let flows = vec![(d("2026-02-01"), 200.0)]; + + let r = modified_dietz(None, Some(1500.0), &flows, start, end); + + assert_eq!(r.value_start, None); + assert_eq!(r.value_end, Some(1500.0)); + assert!(r.is_partial, "missing V_start must flag is_partial"); + assert_eq!(r.return_pct, None); + assert_eq!(r.annualized_pct, None); + assert!(!r.has_no_transfers_warning); + // Still surface the contributions sum for the UI breakdown card. + assert_eq!(r.net_contributions, 200.0); + } + + #[test] + fn no_end_snapshot_marks_partial() { + let start = d("2026-01-01"); + let end = d("2026-04-01"); + let flows = vec![(d("2026-02-15"), -100.0)]; + + let r = modified_dietz(Some(2000.0), None, &flows, start, end); + + assert_eq!(r.value_start, Some(2000.0)); + assert_eq!(r.value_end, None); + assert!(r.is_partial); + assert_eq!(r.return_pct, None); + assert_eq!(r.annualized_pct, None); + assert_eq!(r.net_contributions, -100.0); + } + + #[test] + fn account_created_mid_period_with_first_flow() { + // V_start = 0, single +500 flow at day 30 of a 90-day period, V_end + // = 510. The flow's weight is W = (90-30)/90 = 2/3. + let start = d("2026-01-01"); + let end = d("2026-04-01"); // 90 days + let flows = vec![(d("2026-01-31"), 500.0)]; + + let r = modified_dietz(Some(0.0), Some(510.0), &flows, start, end); + + // numerator = 510 - 0 - 500 = 10 + // W = (90-30)/90 ≈ 0.6666667 + // denominator = 0 + 0.6666667 * 500 ≈ 333.3333 + // R ≈ 10 / 333.3333 = 0.03 + let expected = 10.0 / ((90.0 - 30.0) / 90.0 * 500.0); + let r_pct = r.return_pct.expect("account-created case computes"); + assert!( + approx(r_pct, expected, 1e-9), + "return_pct = {} (expected ≈ {})", + r_pct, + expected + ); + assert!(!r.is_partial); + assert!(!r.has_no_transfers_warning); + } + + #[test] + fn depleted_then_refilled_does_not_panic() { + // Pathological: V_start = 100, then -100 flow on day 1 (account + // emptied), then +200 flow on day 60 of a 90-day period, V_end = + // 210. Modified Dietz handles this without panicking; the value + // may look extreme but the function must stay defined. + let start = d("2026-01-01"); + let end = d("2026-04-01"); + let flows = vec![(d("2026-01-02"), -100.0), (d("2026-03-02"), 200.0)]; + + let r = modified_dietz(Some(100.0), Some(210.0), &flows, start, end); + + // Whatever the math says, the call must complete cleanly. We don't + // assert a precise return — the goal is "no panic, finite output if + // the denominator is positive, else partial flag". + if let Some(rp) = r.return_pct { + assert!(rp.is_finite(), "return must be a finite f64"); + } + // Net flows = -100 + 200 = 100 + assert_eq!(r.net_contributions, 100.0); + // Not flagged "no transfers" since we have two flows. + assert!(!r.has_no_transfers_warning); + } + + #[test] + fn no_transfers_collapses_to_simple_return() { + // No cash flows → R should equal (V_end - V_start) / V_start exactly. + let start = d("2026-01-01"); + let end = d("2026-04-01"); + let flows: Vec<(NaiveDate, f64)> = vec![]; + + let r = modified_dietz(Some(1000.0), Some(1100.0), &flows, start, end); + + assert!(r.has_no_transfers_warning); + assert_eq!(r.net_contributions, 0.0); + let r_pct = r.return_pct.expect("simple-return case has a value"); + let simple = (1100.0 - 1000.0) / 1000.0; // = 0.1 + assert!(approx(r_pct, simple, 1e-12), "simple return mismatch: {}", r_pct); + } + + #[test] + fn annualization_on_90_day_period_matches_compound_formula() { + // Direct check of the annualization branch with a clean R. + let start = d("2026-01-01"); + let end = d("2026-04-01"); // 90 days + let flows: Vec<(NaiveDate, f64)> = vec![]; + + // V_start = 1000, V_end = 1050 → R = 0.05 + let r = modified_dietz(Some(1000.0), Some(1050.0), &flows, start, end); + let expected_ann = (1.0_f64 + 0.05).powf(365.0 / 90.0) - 1.0; + let ann = r.annualized_pct.expect("90-day period annualizes"); + assert!( + approx(ann, expected_ann, 1e-12), + "annualized = {} (expected {})", + ann, + expected_ann + ); + } +}