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, 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 directives: input.directives,
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 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
115fn 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
141fn 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 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
168fn 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
184fn format_decimal(d: Decimal) -> String {
186 let s = d.to_string();
187 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 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 assert_eq!(output.directives.len(), 3);
247
248 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 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 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 assert_eq!(output.directives.len(), 1);
289 }
290
291 #[test]
292 fn test_no_generation_when_price_exists() {
293 let plugin = GenerateBaseCcyPricesPlugin;
294
295 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"), ],
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 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}