From 8877b4976a10b3d3a908e07c41858f90fae0e27e Mon Sep 17 00:00:00 2001 From: Kevin Hoerr Date: Tue, 21 Apr 2026 18:13:50 -0400 Subject: ledger.rs: Refactor methods and add tests --- src/app/ledger.rs | 469 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 421 insertions(+), 48 deletions(-) (limited to 'src/app') diff --git a/src/app/ledger.rs b/src/app/ledger.rs index 7829d0b..255b188 100644 --- a/src/app/ledger.rs +++ b/src/app/ledger.rs @@ -1,5 +1,6 @@ +use crate::app::store::records::{EquityChangeEvent, EquitySymbol, FiduciaryAccount}; use bigdecimal::{BigDecimal, Zero}; -use crate::app::store::records::{EquityChangeEvent, EquitySymbol, FiduciaryAccount, ROUNDING_MODE}; +use miette::{ErrReport, miette}; type DateTime = chrono::DateTime; @@ -14,8 +15,6 @@ pub struct EstimatedLedger<'a> { pub equity_change_events: Vec<&'a EquityChangeEvent>, /// The quantity of shares derived from all events for the account and symbol pub account_symbol_quantity: BigDecimal, - /// The estimated price of all events in the ledger derived from the cost basis and quantity - pub account_symbol_cost_basis_price: BigDecimal, /// The basis of all purchases and sales not derived from quantity pub account_symbol_cost_basis_total: BigDecimal, /// The estimated price of the equity from the latest event in the ledger @@ -25,46 +24,70 @@ pub struct EstimatedLedger<'a> { } impl EstimatedLedger<'_> { + fn new<'a>( + fiduciary_account: &'a FiduciaryAccount, + equity_symbol: &'a EquitySymbol, + events: Vec<&'a EquityChangeEvent>, + ) -> EstimatedLedger<'a> { + EstimatedLedger { + fiduciary_account, + equity_symbol, + equity_change_events: events, + account_symbol_quantity: BigDecimal::zero(), + account_symbol_cost_basis_total: BigDecimal::zero(), + account_symbol_last_effective_price: BigDecimal::zero(), + account_symbol_last_update_timestamp: DateTime::MIN_UTC, + } + } /// Takes a list of all change events, a fiduciary account, and an equity /// symbol for which to create a ledger. - /// + /// /// All values are taken by reference, so they need to be maintained elsewhere - pub fn compile_change_events<'a>(fiduciary_account: &'a FiduciaryAccount, equity_symbol: &'a EquitySymbol, events: Vec<&'a EquityChangeEvent>) -> EstimatedLedger<'a> { - let mut acsym_events = vec![]; - let mut quantity = BigDecimal::zero(); - let mut cost_basis_total = BigDecimal::zero(); - let mut last_event: Option<&EquityChangeEvent> = None; + pub fn compile_change_events<'a>( + fiduciary_account: &'a FiduciaryAccount, + equity_symbol: &'a EquitySymbol, + events: Vec<&'a EquityChangeEvent>, + ) -> EstimatedLedger<'a> { + let mut ledger = EstimatedLedger::new(fiduciary_account, equity_symbol, vec![]); for event in events { - if event.fiduciary_account_id != fiduciary_account.fiduciary_account_id || event.equity_symbol_id != equity_symbol.equity_symbol_id { - continue; - } - - if last_event.is_none() || last_event.unwrap().equity_change_timestamp < event.equity_change_timestamp { - last_event = Some(event); - } + // Err result expected if event does not belong to ledger; for mass processing, we ignore + let _ = ledger.add_event(event); + } - quantity += event.scored_quantity(); - cost_basis_total += event.scored_cost_basis(); + ledger + } - acsym_events.push(event); + /// The estimated price of all events in the ledger derived from the cost basis and quantity + pub fn get_cost_basis_price(&self) -> BigDecimal { + if self.account_symbol_quantity.is_zero() { + BigDecimal::zero() + } else { + self.account_symbol_cost_basis_total.clone() / self.account_symbol_quantity.clone() } + } +} - let cost_basis_price = if quantity.is_zero() { BigDecimal::zero() } else { cost_basis_total.clone() / quantity.clone() }; +impl<'a> EstimatedLedger<'a> { + pub fn add_event(&mut self, event: &'a EquityChangeEvent) -> Result<(), ErrReport> { + if event.fiduciary_account_id != self.fiduciary_account.fiduciary_account_id + || event.equity_symbol_id != self.equity_symbol.equity_symbol_id + { + Err(miette!("Event does not belong to ledger"))?; + } - EstimatedLedger { - fiduciary_account, - equity_symbol, - equity_change_events: acsym_events, - account_symbol_quantity: quantity, - account_symbol_cost_basis_price: cost_basis_price.with_scale_round(2, ROUNDING_MODE), - account_symbol_cost_basis_total: cost_basis_total.with_scale_round(2, ROUNDING_MODE), - account_symbol_last_effective_price: last_event.map_or_else(|| BigDecimal::zero().with_scale(2), |ev| ev.estimated_price()), - account_symbol_last_update_timestamp: last_event.map_or_else(|| DateTime::MIN_UTC, |ev| ev.equity_change_timestamp), + if self.account_symbol_last_update_timestamp < event.equity_change_timestamp { + self.account_symbol_last_effective_price = event.estimated_price(); + self.account_symbol_last_update_timestamp = event.equity_change_timestamp; } - } + self.account_symbol_quantity += event.scored_quantity(); + self.account_symbol_cost_basis_total += event.scored_cost_basis(); + + self.equity_change_events.push(event); + Ok(()) + } } mod test { @@ -74,7 +97,7 @@ mod test { use super::*; /// simple generic to provide auto-incrementing value for the ID type that's needed - fn next + Copy>(id: &mut T) -> T { + fn next + Copy>(id: &mut T) -> T { let tmp = *id; *id += T::from(true); tmp @@ -84,6 +107,50 @@ mod test { BigDecimal::from_biguint(bigdecimal::num_bigint::BigUint::from(n), scale) } + #[test] + fn estimated_ledger_new() { + let account = FiduciaryAccount { + fiduciary_account_id: 0, + fiduciary_account_number: 12345678, + fiduciary_account_name: "account".into(), + fiduciary_account_description: "Brokerage Acct".into(), + fiduciary_account_created_timestamp: DateTime::from_timestamp_secs(1761399660).unwrap(), + }; + let symbol = EquitySymbol { + equity_symbol_id: 0, + equity_symbol_name: "Business Company".into(), + equity_symbol_managing_company: "Business Company".into(), + equity_symbol_type: EquityType::STOCK, + equity_symbol_created_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }; + let events = vec![]; + + let ledger = EstimatedLedger::new(&account, &symbol, events); + + assert_eq!( + ledger.fiduciary_account.fiduciary_account_id, + account.fiduciary_account_id + ); + assert_eq!( + ledger.equity_symbol.equity_symbol_id, + symbol.equity_symbol_id + ); + assert!(ledger.equity_change_events.is_empty()); + assert_eq!(ledger.account_symbol_quantity.to_plain_string(), "0"); + assert_eq!( + ledger.account_symbol_cost_basis_total.to_plain_string(), + "0" + ); + assert_eq!( + ledger.account_symbol_last_effective_price.to_plain_string(), + "0" + ); + assert_eq!( + ledger.account_symbol_last_update_timestamp, + DateTime::MIN_UTC + ); + } + #[test] fn compile_change_events_small() { let account = FiduciaryAccount { @@ -111,7 +178,8 @@ mod test { equity_change_type: ChangeEventType::BUY, equity_change_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), }, - EquityChangeEvent { // different symbol, same account + EquityChangeEvent { + // different symbol, same account equity_change_event_id: next(evid), fiduciary_account_id: account.fiduciary_account_id, equity_symbol_id: symbol.equity_symbol_id + 1, @@ -128,27 +196,47 @@ mod test { equity_change_cost_basis: dec(998_u32, 2), equity_change_type: ChangeEventType::SELL, equity_change_timestamp: DateTime::from_timestamp_secs(1767354190).unwrap(), - } + }, ]; let event_refs = vec![&events[0], &events[1], &events[2]]; let ledger = EstimatedLedger::compile_change_events(&account, &symbol, event_refs); // Check account, symbol, events references of ledger are what expected - assert_eq!(ledger.fiduciary_account.fiduciary_account_id, account.fiduciary_account_id); - assert_eq!(ledger.equity_symbol.equity_symbol_id, symbol.equity_symbol_id); + assert_eq!( + ledger.fiduciary_account.fiduciary_account_id, + account.fiduciary_account_id + ); + assert_eq!( + ledger.equity_symbol.equity_symbol_id, + symbol.equity_symbol_id + ); assert_eq!(ledger.equity_change_events.len(), 2); - assert_eq!(ledger.equity_change_events[0].equity_change_event_id, events[0].equity_change_event_id); - assert_eq!(ledger.equity_change_events[1].equity_change_event_id, events[2].equity_change_event_id); + assert_eq!( + ledger.equity_change_events[0].equity_change_event_id, + events[0].equity_change_event_id + ); + assert_eq!( + ledger.equity_change_events[1].equity_change_event_id, + events[2].equity_change_event_id + ); // Check computed values assert_eq!(ledger.account_symbol_quantity.to_plain_string(), "0.110"); - assert_eq!(ledger.account_symbol_cost_basis_total.to_plain_string(), "72.82"); - assert_eq!(ledger.account_symbol_cost_basis_price.to_plain_string(), "662.00"); + assert_eq!( + ledger.account_symbol_cost_basis_total.to_plain_string(), + "72.82" + ); // "665.33", repeating, of course - assert_eq!(ledger.account_symbol_last_effective_price, events[2].estimated_price()); - assert_eq!(ledger.account_symbol_last_update_timestamp, events[2].equity_change_timestamp); + assert_eq!( + ledger.account_symbol_last_effective_price, + events[2].estimated_price() + ); + assert_eq!( + ledger.account_symbol_last_update_timestamp, + events[2].equity_change_timestamp + ); } #[test] @@ -172,15 +260,300 @@ mod test { let ledger = EstimatedLedger::compile_change_events(&account, &symbol, events); // Check account, symbol, events references of ledger are what expected - assert_eq!(ledger.fiduciary_account.fiduciary_account_id, account.fiduciary_account_id); - assert_eq!(ledger.equity_symbol.equity_symbol_id, symbol.equity_symbol_id); + assert_eq!( + ledger.fiduciary_account.fiduciary_account_id, + account.fiduciary_account_id + ); + assert_eq!( + ledger.equity_symbol.equity_symbol_id, + symbol.equity_symbol_id + ); assert!(ledger.equity_change_events.is_empty()); // Check computed values assert_eq!(ledger.account_symbol_quantity.to_plain_string(), "0"); - assert_eq!(ledger.account_symbol_cost_basis_total.to_plain_string(), "0.00"); - assert_eq!(ledger.account_symbol_cost_basis_price.to_plain_string(), "0.00"); - assert_eq!(ledger.account_symbol_last_effective_price.to_plain_string(), "0.00"); - assert_eq!(ledger.account_symbol_last_update_timestamp, DateTime::MIN_UTC); + assert_eq!( + ledger.account_symbol_cost_basis_total.to_plain_string(), + "0" + ); + assert_eq!( + ledger.account_symbol_last_effective_price.to_plain_string(), + "0" + ); + assert_eq!( + ledger.account_symbol_last_update_timestamp, + DateTime::MIN_UTC + ); } -} \ No newline at end of file + + #[test] + fn get_cost_basis_price_small() { + let account = FiduciaryAccount { + fiduciary_account_id: 0, + fiduciary_account_number: 12345678, + fiduciary_account_name: "account".into(), + fiduciary_account_description: "Brokerage Acct".into(), + fiduciary_account_created_timestamp: DateTime::from_timestamp_secs(1761399660).unwrap(), + }; + let symbol = EquitySymbol { + equity_symbol_id: 0, + equity_symbol_name: "Business Company".into(), + equity_symbol_managing_company: "Business Company".into(), + equity_symbol_type: EquityType::STOCK, + equity_symbol_created_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }; + let evid = &mut 0_usize; + let events = vec![ + EquityChangeEvent { + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id, + equity_symbol_id: symbol.equity_symbol_id, + equity_change_quantity: dec(125_u32, 3), + equity_change_cost_basis: dec(8280_u32, 2), + equity_change_type: ChangeEventType::BUY, + equity_change_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }, + EquityChangeEvent { + // different symbol, same account + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id, + equity_symbol_id: symbol.equity_symbol_id + 1, + equity_change_quantity: dec(10001_u32, 2), + equity_change_cost_basis: dec(545_u32, 2), + equity_change_type: ChangeEventType::BUY, + equity_change_timestamp: DateTime::from_timestamp_secs(1767350590).unwrap(), + }, + EquityChangeEvent { + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id, + equity_symbol_id: symbol.equity_symbol_id, + equity_change_quantity: dec(15_u32, 3), + equity_change_cost_basis: dec(998_u32, 2), + equity_change_type: ChangeEventType::SELL, + equity_change_timestamp: DateTime::from_timestamp_secs(1767354190).unwrap(), + }, + ]; + let event_refs = vec![&events[0], &events[1], &events[2]]; + + let ledger = EstimatedLedger::compile_change_events(&account, &symbol, event_refs); + + assert_eq!(ledger.get_cost_basis_price().to_plain_string(), "662") + } + + #[test] + fn get_cost_basis_price_empty() { + let account = FiduciaryAccount { + fiduciary_account_id: 0, + fiduciary_account_number: 12345678, + fiduciary_account_name: "account".into(), + fiduciary_account_description: "Brokerage Acct".into(), + fiduciary_account_created_timestamp: DateTime::from_timestamp_secs(1761399660).unwrap(), + }; + let symbol = EquitySymbol { + equity_symbol_id: 0, + equity_symbol_name: "US Equity ETF".into(), + equity_symbol_managing_company: "Business Company".into(), + equity_symbol_type: EquityType::ETF, + equity_symbol_created_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }; + let events: Vec<&EquityChangeEvent> = vec![]; + + let ledger = EstimatedLedger::compile_change_events(&account, &symbol, events); + + // Check account, symbol, events references of ledger are what expected + assert_eq!(ledger.get_cost_basis_price().to_plain_string(), "0"); + } + + #[test] + fn add_event_long() { + let account = FiduciaryAccount { + fiduciary_account_id: 0, + fiduciary_account_number: 12345678, + fiduciary_account_name: "account".into(), + fiduciary_account_description: "Brokerage Acct".into(), + fiduciary_account_created_timestamp: DateTime::from_timestamp_secs(1761399660).unwrap(), + }; + let symbol = EquitySymbol { + equity_symbol_id: 0, + equity_symbol_name: "Business Company".into(), + equity_symbol_managing_company: "Business Company".into(), + equity_symbol_type: EquityType::STOCK, + equity_symbol_created_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }; + let evid = &mut 0_usize; + let events = vec![EquityChangeEvent { + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id, + equity_symbol_id: symbol.equity_symbol_id, + equity_change_quantity: dec(125_u32, 3), + equity_change_cost_basis: dec(8280_u32, 2), + equity_change_type: ChangeEventType::BUY, + equity_change_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }]; + let event_refs = vec![&events[0]]; + + let mut ledger = EstimatedLedger::compile_change_events(&account, &symbol, event_refs); + + // Check account, symbol, events references of ledger are what expected + assert_eq!( + ledger.fiduciary_account.fiduciary_account_id, + account.fiduciary_account_id + ); + assert_eq!( + ledger.equity_symbol.equity_symbol_id, + symbol.equity_symbol_id + ); + + assert_eq!(ledger.equity_change_events.len(), 1); + assert_eq!( + ledger.equity_change_events[0].equity_change_event_id, + events[0].equity_change_event_id + ); + + // Check computed values + assert_eq!(ledger.account_symbol_quantity.to_plain_string(), "0.125"); + assert_eq!( + ledger.account_symbol_cost_basis_total.to_plain_string(), + "82.80" + ); + assert_eq!( + ledger.account_symbol_last_effective_price, + events[0].estimated_price() + ); + assert_eq!( + ledger.account_symbol_last_update_timestamp, + events[0].equity_change_timestamp + ); + + let new_event = EquityChangeEvent { + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id, + equity_symbol_id: symbol.equity_symbol_id, + equity_change_quantity: dec(125_u32, 3), + equity_change_cost_basis: dec(7849_u32, 2), + equity_change_type: ChangeEventType::BUY, + equity_change_timestamp: DateTime::from_timestamp_secs(1767354190).unwrap(), + }; + + let res = ledger.add_event(&new_event); + + assert!(res.is_ok()); + assert_eq!(ledger.equity_change_events.len(), 2); + assert_eq!( + ledger.equity_change_events[1].equity_change_event_id, + new_event.equity_change_event_id + ); + + assert_eq!(ledger.account_symbol_quantity.to_plain_string(), "0.250"); + assert_eq!( + ledger.account_symbol_cost_basis_total.to_plain_string(), + "161.29" + ); + assert_eq!( + ledger.account_symbol_last_effective_price, + new_event.estimated_price() + ); + assert_eq!( + ledger.account_symbol_last_update_timestamp, + new_event.equity_change_timestamp + ); + + assert_eq!(ledger.get_cost_basis_price().to_plain_string(), "645.16"); + } + + #[test] + fn add_event_different_account() { + + let account = FiduciaryAccount { + fiduciary_account_id: 0, + fiduciary_account_number: 12345678, + fiduciary_account_name: "account".into(), + fiduciary_account_description: "Brokerage Acct".into(), + fiduciary_account_created_timestamp: DateTime::from_timestamp_secs(1761399660).unwrap(), + }; + let symbol = EquitySymbol { + equity_symbol_id: 0, + equity_symbol_name: "Business Company".into(), + equity_symbol_managing_company: "Business Company".into(), + equity_symbol_type: EquityType::STOCK, + equity_symbol_created_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }; + let evid = &mut 0_usize; + let events = vec![EquityChangeEvent { + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id, + equity_symbol_id: symbol.equity_symbol_id, + equity_change_quantity: dec(125_u32, 3), + equity_change_cost_basis: dec(8280_u32, 2), + equity_change_type: ChangeEventType::BUY, + equity_change_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }]; + let event_refs = vec![&events[0]]; + + let mut ledger = EstimatedLedger::compile_change_events(&account, &symbol, event_refs); + + let new_event = EquityChangeEvent { + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id + 2, + equity_symbol_id: symbol.equity_symbol_id, + equity_change_quantity: dec(125_u32, 3), + equity_change_cost_basis: dec(7849_u32, 2), + equity_change_type: ChangeEventType::BUY, + equity_change_timestamp: DateTime::from_timestamp_secs(1767354190).unwrap(), + }; + + let res = ledger.add_event(&new_event); + + assert!(res.is_err()); + + assert_eq!(res.err().unwrap().to_string(), "Event does not belong to ledger"); + } + + #[test] + fn add_event_different_symbol() { + + let account = FiduciaryAccount { + fiduciary_account_id: 0, + fiduciary_account_number: 12345678, + fiduciary_account_name: "account".into(), + fiduciary_account_description: "Brokerage Acct".into(), + fiduciary_account_created_timestamp: DateTime::from_timestamp_secs(1761399660).unwrap(), + }; + let symbol = EquitySymbol { + equity_symbol_id: 0, + equity_symbol_name: "Business Company".into(), + equity_symbol_managing_company: "Business Company".into(), + equity_symbol_type: EquityType::STOCK, + equity_symbol_created_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }; + let evid = &mut 0_usize; + let events = vec![EquityChangeEvent { + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id, + equity_symbol_id: symbol.equity_symbol_id, + equity_change_quantity: dec(125_u32, 3), + equity_change_cost_basis: dec(8280_u32, 2), + equity_change_type: ChangeEventType::BUY, + equity_change_timestamp: DateTime::from_timestamp_secs(1767346990).unwrap(), + }]; + let event_refs = vec![&events[0]]; + + let mut ledger = EstimatedLedger::compile_change_events(&account, &symbol, event_refs); + + let new_event = EquityChangeEvent { + equity_change_event_id: next(evid), + fiduciary_account_id: account.fiduciary_account_id, + equity_symbol_id: symbol.equity_symbol_id + 3, + equity_change_quantity: dec(125_u32, 3), + equity_change_cost_basis: dec(7849_u32, 2), + equity_change_type: ChangeEventType::BUY, + equity_change_timestamp: DateTime::from_timestamp_secs(1767354190).unwrap(), + }; + + let res = ledger.add_event(&new_event); + + assert!(res.is_err()); + + assert_eq!(res.err().unwrap().to_string(), "Event does not belong to ledger"); + } +} -- cgit v1.3