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