Skip to main content

rustledger_plugin/native/plugins/
generate_base_ccy_prices.rs

1//! Generate base currency prices plugin.
2//!
3//! This plugin generates additional price entries in a base currency by applying
4//! exchange rates to existing prices. For example, if you have:
5//! - `2024-01-01 price ETH 2000 EUR`
6//! - `2024-01-01 price EUR 1.10 USD`
7//!
8//! And the base currency is USD, it will generate:
9//! - `2024-01-01 price ETH 2200 USD` (2000 * 1.10)
10//!
11//! Usage:
12//! ```beancount
13//! plugin "beancount_lazy_plugins.generate_base_ccy_prices" "USD"
14//! ```
15
16use std::collections::HashMap;
17
18use rust_decimal::Decimal;
19
20use crate::types::{
21    AmountData, DirectiveData, DirectiveWrapper, PluginInput, PluginOp, PluginOutput, PriceData,
22};
23
24use super::super::{NativePlugin, RegularPlugin};
25
26/// Plugin for generating base currency prices.
27pub struct GenerateBaseCcyPricesPlugin;
28
29impl NativePlugin for GenerateBaseCcyPricesPlugin {
30    fn name(&self) -> &'static str {
31        "generate_base_ccy_prices"
32    }
33
34    fn description(&self) -> &'static str {
35        "Generate base currency prices by applying exchange rates"
36    }
37
38    fn process(&self, input: PluginInput) -> PluginOutput {
39        // Get the base currency from config
40        let base_ccy = match &input.config {
41            Some(config) => config
42                .trim()
43                .trim_matches('"')
44                .trim_matches('\'')
45                .to_string(),
46            None => {
47                // If no config, just return unchanged
48                return PluginOutput {
49                    ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
50                    errors: Vec::new(),
51                };
52            }
53        };
54
55        // Build price map: (currency, quote_currency) -> Vec<(date, rate)>
56        let price_map = build_price_map(&input.directives);
57
58        // Find additional entries to generate
59        let mut additional_entries = Vec::new();
60
61        for directive in &input.directives {
62            if directive.directive_type != "price" {
63                continue;
64            }
65
66            if let DirectiveData::Price(price) = &directive.data {
67                // Skip if price is already in base currency
68                if price.amount.currency == base_ccy || price.currency == base_ccy {
69                    continue;
70                }
71
72                // Try to find FX rate from price currency to base currency
73                let fx_tuple = (price.amount.currency.clone(), base_ccy.clone());
74                if let Some(fx_rate) = get_price(&price_map, &fx_tuple, &directive.date) {
75                    // Check if price in base currency already exists
76                    let target_tuple = (price.currency.clone(), base_ccy.clone());
77                    if already_existing_price(&price_map, &target_tuple, &directive.date) {
78                        continue;
79                    }
80
81                    // Calculate price in base currency
82                    if let Ok(price_number) = price.amount.number.parse::<Decimal>() {
83                        let price_in_base = price_number * fx_rate;
84
85                        additional_entries.push(DirectiveWrapper {
86                            directive_type: "price".to_string(),
87                            date: directive.date.clone(),
88                            filename: directive.filename.clone(),
89                            lineno: directive.lineno,
90                            data: DirectiveData::Price(PriceData {
91                                currency: price.currency.clone(),
92                                amount: AmountData {
93                                    number: format_decimal(price_in_base),
94                                    currency: base_ccy.clone(),
95                                },
96                                metadata: vec![],
97                            }),
98                        });
99                    }
100                }
101            }
102        }
103
104        // Keep all original directives, then insert generated price entries.
105        let mut ops: Vec<PluginOp> = (0..input.directives.len()).map(PluginOp::Keep).collect();
106        for w in additional_entries {
107            ops.push(PluginOp::Insert(w));
108        }
109
110        PluginOutput {
111            ops,
112            errors: Vec::new(),
113        }
114    }
115}
116
117impl RegularPlugin for GenerateBaseCcyPricesPlugin {}
118
119/// Build a price map from directives.
120/// Returns a map from (currency, `quote_currency`) to Vec<(date, rate)>
121fn build_price_map(
122    directives: &[DirectiveWrapper],
123) -> HashMap<(String, String), Vec<(String, Decimal)>> {
124    let mut price_map: HashMap<(String, String), Vec<(String, Decimal)>> = HashMap::new();
125
126    for directive in directives {
127        if directive.directive_type != "price" {
128            continue;
129        }
130
131        if let DirectiveData::Price(price) = &directive.data
132            && let Ok(rate) = price.amount.number.parse::<Decimal>()
133        {
134            let key = (price.currency.clone(), price.amount.currency.clone());
135            price_map
136                .entry(key)
137                .or_default()
138                .push((directive.date.clone(), rate));
139        }
140    }
141
142    price_map
143}
144
145/// Get price for a currency pair on a specific date.
146fn get_price(
147    price_map: &HashMap<(String, String), Vec<(String, Decimal)>>,
148    pair: &(String, String),
149    date: &str,
150) -> Option<Decimal> {
151    let prices = price_map.get(pair)?;
152
153    // Find exact date match or closest date before
154    let mut best_match: Option<(&str, Decimal)> = None;
155
156    for (price_date, rate) in prices {
157        if price_date.as_str() <= date {
158            match &best_match {
159                None => best_match = Some((price_date.as_str(), *rate)),
160                Some((best_date, _)) => {
161                    if price_date.as_str() > *best_date {
162                        best_match = Some((price_date.as_str(), *rate));
163                    }
164                }
165            }
166        }
167    }
168
169    best_match.map(|(_, rate)| rate)
170}
171
172/// Check if a price already exists for the given pair on the given date.
173fn already_existing_price(
174    price_map: &HashMap<(String, String), Vec<(String, Decimal)>>,
175    pair: &(String, String),
176    date: &str,
177) -> bool {
178    if let Some(prices) = price_map.get(pair) {
179        for (price_date, _) in prices {
180            if price_date == date {
181                return true;
182            }
183        }
184    }
185    false
186}
187
188/// Format a decimal number, trimming trailing zeros.
189fn format_decimal(d: Decimal) -> String {
190    let s = d.to_string();
191    // If it has a decimal point, trim trailing zeros
192    if s.contains('.') {
193        let trimmed = s.trim_end_matches('0').trim_end_matches('.');
194        trimmed.to_string()
195    } else {
196        s
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::super::utils::materialize_ops;
203    use super::*;
204    use crate::types::*;
205
206    fn create_price(
207        date: &str,
208        currency: &str,
209        number: &str,
210        quote_currency: &str,
211    ) -> DirectiveWrapper {
212        DirectiveWrapper {
213            directive_type: "price".to_string(),
214            date: date.to_string(),
215            filename: None,
216            lineno: None,
217            data: DirectiveData::Price(PriceData {
218                currency: currency.to_string(),
219                amount: AmountData {
220                    number: number.to_string(),
221                    currency: quote_currency.to_string(),
222                },
223                metadata: vec![],
224            }),
225        }
226    }
227
228    #[test]
229    fn test_generate_base_ccy_price() {
230        let plugin = GenerateBaseCcyPricesPlugin;
231
232        // Create test data:
233        // ETH priced in EUR
234        // EUR priced in USD (base currency)
235        let input = PluginInput {
236            directives: vec![
237                create_price("2024-01-01", "EUR", "1.10", "USD"),
238                create_price("2024-01-01", "ETH", "2000", "EUR"),
239            ],
240            options: PluginOptions {
241                operating_currencies: vec!["USD".to_string()],
242                title: None,
243            },
244            config: Some("USD".to_string()),
245        };
246
247        let input_dirs = input.directives.clone();
248        let output = plugin.process(input);
249        assert_eq!(output.errors.len(), 0);
250        let directives = materialize_ops(&input_dirs, &output);
251
252        // Should have original 2 prices + 1 generated (ETH in USD)
253        assert_eq!(directives.len(), 3);
254
255        // Find the generated price
256        let generated_prices: Vec<_> = directives
257            .iter()
258            .filter(|d| {
259                if let DirectiveData::Price(p) = &d.data {
260                    p.currency == "ETH" && p.amount.currency == "USD"
261                } else {
262                    false
263                }
264            })
265            .collect();
266
267        assert_eq!(generated_prices.len(), 1);
268
269        if let DirectiveData::Price(p) = &generated_prices[0].data {
270            // 2000 EUR * 1.10 USD/EUR = 2200 USD
271            assert_eq!(p.amount.number, "2200");
272        } else {
273            panic!("Expected Price directive");
274        }
275    }
276
277    #[test]
278    fn test_no_generation_when_already_in_base() {
279        let plugin = GenerateBaseCcyPricesPlugin;
280
281        // ETH directly priced in USD (base currency) - no generation needed
282        let input = PluginInput {
283            directives: vec![create_price("2024-01-01", "ETH", "2200", "USD")],
284            options: PluginOptions {
285                operating_currencies: vec!["USD".to_string()],
286                title: None,
287            },
288            config: Some("USD".to_string()),
289        };
290
291        let input_dirs = input.directives.clone();
292        let output = plugin.process(input);
293        assert_eq!(output.errors.len(), 0);
294        let directives = materialize_ops(&input_dirs, &output);
295        // Should have only the original price
296        assert_eq!(directives.len(), 1);
297    }
298
299    #[test]
300    fn test_no_generation_when_price_exists() {
301        let plugin = GenerateBaseCcyPricesPlugin;
302
303        // ETH priced in EUR and also directly in USD
304        let input = PluginInput {
305            directives: vec![
306                create_price("2024-01-01", "EUR", "1.10", "USD"),
307                create_price("2024-01-01", "ETH", "2000", "EUR"),
308                create_price("2024-01-01", "ETH", "2200", "USD"), // Already exists
309            ],
310            options: PluginOptions {
311                operating_currencies: vec!["USD".to_string()],
312                title: None,
313            },
314            config: Some("USD".to_string()),
315        };
316
317        let input_dirs = input.directives.clone();
318        let output = plugin.process(input);
319        assert_eq!(output.errors.len(), 0);
320        let directives = materialize_ops(&input_dirs, &output);
321        // Should have only original 3 prices
322        assert_eq!(directives.len(), 3);
323    }
324
325    #[test]
326    fn test_no_config_unchanged() {
327        let plugin = GenerateBaseCcyPricesPlugin;
328
329        let input = PluginInput {
330            directives: vec![create_price("2024-01-01", "ETH", "2000", "EUR")],
331            options: PluginOptions {
332                operating_currencies: vec!["USD".to_string()],
333                title: None,
334            },
335            config: None,
336        };
337
338        let input_dirs = input.directives.clone();
339        let output = plugin.process(input);
340        assert_eq!(output.errors.len(), 0);
341        let directives = materialize_ops(&input_dirs, &output);
342        assert_eq!(directives.len(), 1);
343    }
344}