rustledger_plugin/native/plugins/
unrealized.rs1use crate::types::{DirectiveData, PluginError, PluginInput, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
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 = posting
88 .cost
89 .as_ref()
90 .and_then(|c| c.number.as_ref())
91 .map_or(Decimal::ZERO, |cn| match cn.total() {
92 Some(s) => Decimal::from_str(s).unwrap_or_default(),
93 None => cn
94 .per_unit()
95 .map(|s| {
96 Decimal::from_str(s).unwrap_or_default() * units_num.abs()
97 })
98 .unwrap_or_default(),
99 });
100
101 let account_positions =
102 positions.entry(posting.account.clone()).or_default();
103
104 let (existing_units, existing_cost) = account_positions
105 .entry(units.currency.clone())
106 .or_insert((Decimal::ZERO, Decimal::ZERO));
107
108 *existing_units += units_num;
109 *existing_cost += cost_basis;
110 }
111 }
112 }
113 }
114
115 for (account, currencies) in &positions {
117 for (currency, (units, cost_basis)) in currencies {
118 if *units == Decimal::ZERO {
119 continue;
120 }
121
122 if let Some((_, market_price)) = prices.get(&(currency.clone(), "USD".to_string()))
124 {
125 let market_value = *units * market_price;
126 let unrealized_gain = market_value - cost_basis;
127
128 if unrealized_gain.abs() > Decimal::new(1, 2) {
129 errors.push(PluginError::warning(format!(
131 "Unrealized gain on {units} {currency} in {account}: {unrealized_gain} USD"
132 )));
133 }
134 }
135 }
136 }
137
138 PluginOutput {
139 ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
140 errors,
141 }
142 }
143}
144
145impl RegularPlugin for UnrealizedPlugin {}