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;
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
117/// Build a price map from directives.
118/// Returns a map from (currency, `quote_currency`) to Vec<(date, rate)>
119fn build_price_map(
120    directives: &[DirectiveWrapper],
121) -> HashMap<(String, String), Vec<(String, Decimal)>> {
122    let mut price_map: HashMap<(String, String), Vec<(String, Decimal)>> = HashMap::new();
123
124    for directive in directives {
125        if directive.directive_type != "price" {
126            continue;
127        }
128
129        if let DirectiveData::Price(price) = &directive.data
130            && let Ok(rate) = price.amount.number.parse::<Decimal>()
131        {
132            let key = (price.currency.clone(), price.amount.currency.clone());
133            price_map
134                .entry(key)
135                .or_default()
136                .push((directive.date.clone(), rate));
137        }
138    }
139
140    price_map
141}
142
143/// Get price for a currency pair on a specific date.
144fn get_price(
145    price_map: &HashMap<(String, String), Vec<(String, Decimal)>>,
146    pair: &(String, String),
147    date: &str,
148) -> Option<Decimal> {
149    let prices = price_map.get(pair)?;
150
151    // Find exact date match or closest date before
152    let mut best_match: Option<(&str, Decimal)> = None;
153
154    for (price_date, rate) in prices {
155        if price_date.as_str() <= date {
156            match &best_match {
157                None => best_match = Some((price_date.as_str(), *rate)),
158                Some((best_date, _)) => {
159                    if price_date.as_str() > *best_date {
160                        best_match = Some((price_date.as_str(), *rate));
161                    }
162                }
163            }
164        }
165    }
166
167    best_match.map(|(_, rate)| rate)
168}
169
170/// Check if a price already exists for the given pair on the given date.
171fn already_existing_price(
172    price_map: &HashMap<(String, String), Vec<(String, Decimal)>>,
173    pair: &(String, String),
174    date: &str,
175) -> bool {
176    if let Some(prices) = price_map.get(pair) {
177        for (price_date, _) in prices {
178            if price_date == date {
179                return true;
180            }
181        }
182    }
183    false
184}
185
186/// Format a decimal number, trimming trailing zeros.
187fn format_decimal(d: Decimal) -> String {
188    let s = d.to_string();
189    // If it has a decimal point, trim trailing zeros
190    if s.contains('.') {
191        let trimmed = s.trim_end_matches('0').trim_end_matches('.');
192        trimmed.to_string()
193    } else {
194        s
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::super::utils::materialize_ops;
201    use super::*;
202    use crate::types::*;
203
204    fn create_price(
205        date: &str,
206        currency: &str,
207        number: &str,
208        quote_currency: &str,
209    ) -> DirectiveWrapper {
210        DirectiveWrapper {
211            directive_type: "price".to_string(),
212            date: date.to_string(),
213            filename: None,
214            lineno: None,
215            data: DirectiveData::Price(PriceData {
216                currency: currency.to_string(),
217                amount: AmountData {
218                    number: number.to_string(),
219                    currency: quote_currency.to_string(),
220                },
221                metadata: vec![],
222            }),
223        }
224    }
225
226    #[test]
227    fn test_generate_base_ccy_price() {
228        let plugin = GenerateBaseCcyPricesPlugin;
229
230        // Create test data:
231        // ETH priced in EUR
232        // EUR priced in USD (base currency)
233        let input = PluginInput {
234            directives: vec![
235                create_price("2024-01-01", "EUR", "1.10", "USD"),
236                create_price("2024-01-01", "ETH", "2000", "EUR"),
237            ],
238            options: PluginOptions {
239                operating_currencies: vec!["USD".to_string()],
240                title: None,
241            },
242            config: Some("USD".to_string()),
243        };
244
245        let input_dirs = input.directives.clone();
246        let output = plugin.process(input);
247        assert_eq!(output.errors.len(), 0);
248        let directives = materialize_ops(&input_dirs, &output);
249
250        // Should have original 2 prices + 1 generated (ETH in USD)
251        assert_eq!(directives.len(), 3);
252
253        // Find the generated price
254        let generated_prices: Vec<_> = directives
255            .iter()
256            .filter(|d| {
257                if let DirectiveData::Price(p) = &d.data {
258                    p.currency == "ETH" && p.amount.currency == "USD"
259                } else {
260                    false
261                }
262            })
263            .collect();
264
265        assert_eq!(generated_prices.len(), 1);
266
267        if let DirectiveData::Price(p) = &generated_prices[0].data {
268            // 2000 EUR * 1.10 USD/EUR = 2200 USD
269            assert_eq!(p.amount.number, "2200");
270        } else {
271            panic!("Expected Price directive");
272        }
273    }
274
275    #[test]
276    fn test_no_generation_when_already_in_base() {
277        let plugin = GenerateBaseCcyPricesPlugin;
278
279        // ETH directly priced in USD (base currency) - no generation needed
280        let input = PluginInput {
281            directives: vec![create_price("2024-01-01", "ETH", "2200", "USD")],
282            options: PluginOptions {
283                operating_currencies: vec!["USD".to_string()],
284                title: None,
285            },
286            config: Some("USD".to_string()),
287        };
288
289        let input_dirs = input.directives.clone();
290        let output = plugin.process(input);
291        assert_eq!(output.errors.len(), 0);
292        let directives = materialize_ops(&input_dirs, &output);
293        // Should have only the original price
294        assert_eq!(directives.len(), 1);
295    }
296
297    #[test]
298    fn test_no_generation_when_price_exists() {
299        let plugin = GenerateBaseCcyPricesPlugin;
300
301        // ETH priced in EUR and also directly in USD
302        let input = PluginInput {
303            directives: vec![
304                create_price("2024-01-01", "EUR", "1.10", "USD"),
305                create_price("2024-01-01", "ETH", "2000", "EUR"),
306                create_price("2024-01-01", "ETH", "2200", "USD"), // Already exists
307            ],
308            options: PluginOptions {
309                operating_currencies: vec!["USD".to_string()],
310                title: None,
311            },
312            config: Some("USD".to_string()),
313        };
314
315        let input_dirs = input.directives.clone();
316        let output = plugin.process(input);
317        assert_eq!(output.errors.len(), 0);
318        let directives = materialize_ops(&input_dirs, &output);
319        // Should have only original 3 prices
320        assert_eq!(directives.len(), 3);
321    }
322
323    #[test]
324    fn test_no_config_unchanged() {
325        let plugin = GenerateBaseCcyPricesPlugin;
326
327        let input = PluginInput {
328            directives: vec![create_price("2024-01-01", "ETH", "2000", "EUR")],
329            options: PluginOptions {
330                operating_currencies: vec!["USD".to_string()],
331                title: None,
332            },
333            config: None,
334        };
335
336        let input_dirs = input.directives.clone();
337        let output = plugin.process(input);
338        assert_eq!(output.errors.len(), 0);
339        let directives = materialize_ops(&input_dirs, &output);
340        assert_eq!(directives.len(), 1);
341    }
342}