feat(balance): add chrono dep + Modified Dietz return_calculator with tests
Issue #142 / Bilan #4 — TDD step 1. - Added `chrono = "0.4"` (default-features off, `serde` + `std` features) to `src-tauri/Cargo.toml` for day-precision date arithmetic. - New private module `src-tauri/src/commands/return_calculator.rs`: - `pub(crate) fn modified_dietz(value_start, value_end, cash_flows, period_start, period_end) -> AccountReturn` - `AccountReturn { value_start, value_end, net_contributions, return_pct, annualized_pct, is_partial, has_no_transfers_warning }` (Serialize) - Edge cases handled: missing start/end snapshot (`is_partial = true`, `return_pct = None`), no transfers (collapses to simple return + warn flag), zero-length period (skips annualization), V_start = 0 with first flow > 0 (account-created mid-period), depleted-then-refilled (no panic, finite output). - 7 co-located TDD tests covering nominal + every edge case above. - Module declared `pub(crate)` in `commands/mod.rs` (kept out of the wildcard re-export — only `balance_commands.rs` will consume it). `cargo test --lib commands::return_calculator` → 7 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1e261ae2ea
commit
c9cdb5a891
4 changed files with 389 additions and 0 deletions
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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::*;
|
||||||
|
|
|
||||||
380
src-tauri/src/commands/return_calculator.rs
Normal file
380
src-tauri/src/commands/return_calculator.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue