rustledger_plugin/native/plugins/
unrealized.rs1use crate::types::{DirectiveData, PluginError, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7pub struct UnrealizedPlugin {
12 pub gains_account: String,
14}
15
16impl UnrealizedPlugin {
17 pub fn new() -> Self {
19 Self {
20 gains_account: "Income:Unrealized".to_string(),
21 }
22 }
23
24 pub const fn with_account(account: String) -> Self {
26 Self {
27 gains_account: account,
28 }
29 }
30}
31
32impl Default for UnrealizedPlugin {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl NativePlugin for UnrealizedPlugin {
39 fn name(&self) -> &'static str {
40 "unrealized"
41 }
42
43 fn description(&self) -> &'static str {
44 "Calculate unrealized gains/losses"
45 }
46
47 fn process(&self, input: PluginInput) -> PluginOutput {
48 use rust_decimal::Decimal;
49 use std::collections::HashMap;
50 use std::str::FromStr;
51
52 let mut prices: HashMap<(String, String), (String, Decimal)> = HashMap::new(); for wrapper in &input.directives {
56 if let DirectiveData::Price(price) = &wrapper.data {
57 let key = (price.currency.clone(), price.amount.currency.clone());
58 let price_val = Decimal::from_str(&price.amount.number).unwrap_or_default();
59
60 if let Some((existing_date, _)) = prices.get(&key) {
62 if &wrapper.date > existing_date {
63 prices.insert(key, (wrapper.date.clone(), price_val));
64 }
65 } else {
66 prices.insert(key, (wrapper.date.clone(), price_val));
67 }
68 }
69 }
70
71 let mut positions: HashMap<String, HashMap<String, (Decimal, Decimal)>> = HashMap::new(); let mut errors = Vec::new();
75
76 for wrapper in &input.directives {
77 if let DirectiveData::Transaction(txn) = &wrapper.data {
78 for posting in &txn.postings {
79 if let Some(units) = &posting.units {
80 let units_num = Decimal::from_str(&units.number).unwrap_or_default();
81
82 let cost_basis = if let Some(cost) = &posting.cost {
83 cost.number_per
84 .as_ref()
85 .and_then(|s| Decimal::from_str(s).ok())
86 .unwrap_or_default()
87 * units_num.abs()
88 } else {
89 Decimal::ZERO
90 };
91
92 let account_positions =
93 positions.entry(posting.account.clone()).or_default();
94
95 let (existing_units, existing_cost) = account_positions
96 .entry(units.currency.clone())
97 .or_insert((Decimal::ZERO, Decimal::ZERO));
98
99 *existing_units += units_num;
100 *existing_cost += cost_basis;
101 }
102 }
103 }
104 }
105
106 for (account, currencies) in &positions {
108 for (currency, (units, cost_basis)) in currencies {
109 if *units == Decimal::ZERO {
110 continue;
111 }
112
113 if let Some((_, market_price)) = prices.get(&(currency.clone(), "USD".to_string()))
115 {
116 let market_value = *units * market_price;
117 let unrealized_gain = market_value - cost_basis;
118
119 if unrealized_gain.abs() > Decimal::new(1, 2) {
120 errors.push(PluginError::warning(format!(
122 "Unrealized gain on {units} {currency} in {account}: {unrealized_gain} USD"
123 )));
124 }
125 }
126 }
127 }
128
129 PluginOutput {
130 directives: input.directives,
131 errors,
132 }
133 }
134}