Skip to main content

rustledger_plugin/native/plugins/
unrealized.rs

1//! Calculate unrealized gains/losses.
2
3use crate::types::{DirectiveData, PluginError, PluginInput, PluginOp, PluginOutput};
4
5use super::super::{NativePlugin, RegularPlugin};
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                        // Unrealized-gains operates on post-booking
83                        // transactions. Prefer the preserved total
84                        // when available (PerUnitFromTotal) for exact
85                        // cost basis; fall back to per_unit * |units|
86                        // for source `{...}` per-unit costs.
87                        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        // Calculate unrealized gains for positions with known prices
116        for (account, currencies) in &positions {
117            for (currency, (units, cost_basis)) in currencies {
118                if *units == Decimal::ZERO {
119                    continue;
120                }
121
122                // Look for a price to the operating currency (assume USD for now)
123                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                        // More than $0.01
130                        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 {}