aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/ledger.rs469
1 files changed, 421 insertions, 48 deletions
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<chrono::Utc>;
@@ -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<T : std::ops::AddAssign + From::<bool> + Copy>(id: &mut T) -> T {
+ fn next<T: std::ops::AddAssign + From<bool> + Copy>(id: &mut T) -> T {
let tmp = *id;
*id += T::from(true);
tmp
@@ -85,6 +108,50 @@ mod test {
}
#[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 {
fiduciary_account_id: 0,
@@ -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");
+ }
+}