rustledger_plugin/native/plugins/
generate_base_ccy_prices.rs1use 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
26pub 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 let base_ccy = match &input.config {
41 Some(config) => config
42 .trim()
43 .trim_matches('"')
44 .trim_matches('\'')
45 .to_string(),
46 None => {
47 return PluginOutput {
49 ops: (0..input.directives.len()).map(PluginOp::Keep).collect(),
50 errors: Vec::new(),
51 };
52 }
53 };
54
55 let price_map = build_price_map(&input.directives);
57
58 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 if price.amount.currency == base_ccy || price.currency == base_ccy {
69 continue;
70 }
71
72 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 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 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 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
119fn 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
145fn 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 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
172fn 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
188fn format_decimal(d: Decimal) -> String {
190 let s = d.to_string();
191 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 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 assert_eq!(directives.len(), 3);
254
255 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 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 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 assert_eq!(directives.len(), 1);
297 }
298
299 #[test]
300 fn test_no_generation_when_price_exists() {
301 let plugin = GenerateBaseCcyPricesPlugin;
302
303 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"), ],
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 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}