Skip to main content

rustledger_plugin/native/plugins/
unrealized.rs

1//! Calculate unrealized gains/losses.
2
3use crate::types::{DirectiveData, PluginError, PluginInput, PluginOutput};
4
5use super::super::NativePlugin;
6
7/// Plugin that calculates unrealized gains on positions.
8///
9/// For each position held at cost, this plugin can generate unrealized
10/// gain/loss entries based on current market prices from the price database.
11pub struct UnrealizedPlugin {
12    /// Account to book unrealized gains to.
13    pub gains_account: String,
14}
15
16impl UnrealizedPlugin {
17    /// Create a new plugin with the default gains account.
18    pub fn new() -> Self {
19        Self {
20            gains_account: "Income:Unrealized".to_string(),
21        }
22    }
23
24    /// Create with a custom gains account.
25    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        // Build price database from Price directives
53        let mut prices: HashMap<(String, String), (String, Decimal)> = HashMap::new(); // (base, quote) -> (date, price)
54
55        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                // Keep the most recent price
61                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        // Track positions by account
72        let mut positions: HashMap<String, HashMap<String, (Decimal, Decimal)>> = HashMap::new(); // account -> currency -> (units, cost_basis)
73
74        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        // Calculate unrealized gains for positions with known prices
107        for (account, currencies) in &positions {
108            for (currency, (units, cost_basis)) in currencies {
109                if *units == Decimal::ZERO {
110                    continue;
111                }
112
113                // Look for a price to the operating currency (assume USD for now)
114                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                        // More than $0.01
121                        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}