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;
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
117fn 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
143fn 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 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
170fn 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
186fn format_decimal(d: Decimal) -> String {
188 let s = d.to_string();
189 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 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 assert_eq!(directives.len(), 3);
252
253 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 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 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 assert_eq!(directives.len(), 1);
295 }
296
297 #[test]
298 fn test_no_generation_when_price_exists() {
299 let plugin = GenerateBaseCcyPricesPlugin;
300
301 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"), ],
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 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}