feat(balance): Modified Dietz returns + transfer linking (#142) #151

Merged
maximus merged 8 commits from issue-142-bilan-4 into main 2026-04-26 13:25:32 +00:00
4 changed files with 389 additions and 0 deletions
Showing only changes of commit c9cdb5a891 - Show all commits

1
src-tauri/Cargo.lock generated
View file

@ -4428,6 +4428,7 @@ dependencies = [
"aes-gcm", "aes-gcm",
"argon2", "argon2",
"base64 0.22.1", "base64 0.22.1",
"chrono",
"ed25519-dalek", "ed25519-dalek",
"encoding_rs", "encoding_rs",
"hmac", "hmac",

View file

@ -41,6 +41,10 @@ rand = "0.8"
jsonwebtoken = "9" jsonwebtoken = "9"
machine-uid = "0.5" machine-uid = "0.5"
reqwest = { version = "0.12", features = ["json"] } 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"] } tokio = { version = "1", features = ["macros"] }
hostname = "0.4" hostname = "0.4"
urlencoding = "2" urlencoding = "2"

View file

@ -7,6 +7,10 @@ pub mod feedback_commands;
pub mod fs_commands; pub mod fs_commands;
pub mod license_commands; pub mod license_commands;
pub mod profile_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 mod token_store;
pub use auth_commands::*; pub use auth_commands::*;

View file

@ -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<f64>,
/// Account value at `period_end` (latest snapshot ≤ period_end).
pub value_end: Option<f64>,
/// 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<f64>,
/// Annualized return `(1 + R)^(365 / T) - 1`. `None` for zero-length
/// periods or whenever `return_pct` is `None`.
pub annualized_pct: Option<f64>,
/// `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<f64>,
value_end: Option<f64>,
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<f64>,
value_end: Option<f64>,
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
);
}
}