1use std::collections::{HashMap, HashSet};
27
28use rust_decimal::Decimal;
29
30use crate::types::{
31 AmountData, CommodityData, CostData, DirectiveData, DirectiveWrapper, MetaValueData,
32 PluginError, PluginErrorSeverity, PluginInput, PluginOutput, PostingData, PriceAnnotationData,
33 PriceData, TransactionData,
34};
35
36use super::super::NativePlugin;
37
38const MAPPED_CURRENCY_PRECISION: u32 = 7;
39const TAG_TO_ADD: &str = "valuation-applied";
40const EPSILON: Decimal = Decimal::from_parts(1, 0, 0, false, 9); pub struct ValuationPlugin;
44
45#[derive(Clone, Debug)]
47struct AccountConfig {
48 account: String,
49 currency: String,
50 pnl_account: String,
51}
52
53#[derive(Clone, Debug)]
55struct CostLot {
56 units: Decimal,
57 cost_per_unit: Decimal,
58 date: String,
59}
60
61#[derive(Clone, Debug)]
63struct AccountState {
64 config: AccountConfig,
65 lots: Vec<CostLot>,
66 last_price: Decimal,
67 total_units: Decimal,
68}
69
70impl AccountState {
71 const fn new(config: AccountConfig) -> Self {
72 Self {
73 config,
74 lots: Vec::new(),
75 last_price: Decimal::ONE,
76 total_units: Decimal::ZERO,
77 }
78 }
79}
80
81impl NativePlugin for ValuationPlugin {
82 fn name(&self) -> &'static str {
83 "valuation"
84 }
85
86 fn description(&self) -> &'static str {
87 "Track opaque fund values using synthetic commodities"
88 }
89
90 fn process(&self, input: PluginInput) -> PluginOutput {
91 let mut errors: Vec<PluginError> = Vec::new();
92 let mut output_directives: Vec<DirectiveWrapper> = Vec::new();
93
94 let mut account_states: HashMap<String, AccountState> = HashMap::new();
96
97 let mut commodities_present: HashSet<String> = HashSet::new();
99
100 let mut last_date: Option<String> = None;
102
103 for directive in &input.directives {
105 match &directive.data {
106 DirectiveData::Custom(custom) => {
107 if custom.custom_type == "valuation"
108 && !custom.values.is_empty()
109 && matches!(custom.values.first(), Some(MetaValueData::String(s)) if s == "config")
110 && let Some(config) = parse_config(&custom.metadata)
111 {
112 account_states.insert(config.account.clone(), AccountState::new(config));
113 }
114 }
115 DirectiveData::Commodity(commodity) => {
116 commodities_present.insert(commodity.currency.clone());
117 }
118 _ => {}
119 }
120 }
121
122 for directive in input.directives {
124 last_date = Some(directive.date.clone());
125
126 match &directive.data {
127 DirectiveData::Transaction(txn) => {
128 let has_mapped_posting = txn
130 .postings
131 .iter()
132 .any(|p| account_states.contains_key(&p.account));
133
134 if !has_mapped_posting {
135 output_directives.push(directive);
136 continue;
137 }
138
139 let (transformed, new_directives, new_errors) = transform_transaction(
141 &directive,
142 txn,
143 &mut account_states,
144 &mut commodities_present,
145 );
146
147 output_directives.extend(new_directives);
149 errors.extend(new_errors);
150 output_directives.push(transformed);
151 }
152 DirectiveData::Custom(custom)
153 if custom.custom_type == "valuation" && !custom.values.is_empty() =>
154 {
155 if matches!(custom.values.first(), Some(MetaValueData::String(s)) if s == "config")
157 {
158 output_directives.push(directive);
159 continue;
160 }
161
162 let (new_directives, new_errors) =
164 process_valuation_assertion(&directive, custom, &mut account_states);
165
166 output_directives.extend(new_directives);
167 errors.extend(new_errors);
168 }
169 DirectiveData::Custom(_) => {
170 output_directives.push(directive);
171 }
172 DirectiveData::Commodity(commodity) => {
173 commodities_present.insert(commodity.currency.clone());
174 output_directives.push(directive);
175 }
176 _ => {
177 output_directives.push(directive);
178 }
179 }
180 }
181
182 if let Some(date) = last_date {
185 for state in account_states.values() {
186 if !commodities_present.contains(&state.config.currency) {
187 output_directives.push(DirectiveWrapper {
188 directive_type: "commodity".to_string(),
189 date: date.clone(),
190 filename: Some("<valuation>".to_string()),
191 lineno: Some(0),
192 data: DirectiveData::Commodity(CommodityData {
193 currency: state.config.currency.clone(),
194 metadata: vec![],
195 }),
196 });
197 commodities_present.insert(state.config.currency.clone());
199 }
200 }
201 }
202
203 PluginOutput {
204 directives: output_directives,
205 errors,
206 }
207 }
208}
209
210fn parse_config(metadata: &[(String, MetaValueData)]) -> Option<AccountConfig> {
212 let account = get_meta_string(metadata, "account")?;
213 let currency = get_meta_string(metadata, "currency")?;
214 let pnl_account = get_meta_string(metadata, "pnlAccount")?;
215 Some(AccountConfig {
216 account,
217 currency,
218 pnl_account,
219 })
220}
221
222fn get_meta_string(metadata: &[(String, MetaValueData)], key: &str) -> Option<String> {
224 for (k, v) in metadata {
225 if k == key {
226 match v {
227 MetaValueData::String(s) => return Some(s.clone()),
228 MetaValueData::Account(a) => return Some(a.clone()),
229 _ => {}
230 }
231 }
232 }
233 None
234}
235
236fn transform_transaction(
238 directive: &DirectiveWrapper,
239 txn: &TransactionData,
240 account_states: &mut HashMap<String, AccountState>,
241 _commodities_present: &mut HashSet<String>,
242) -> (DirectiveWrapper, Vec<DirectiveWrapper>, Vec<PluginError>) {
243 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
244 let errors: Vec<PluginError> = Vec::new();
245 let mut new_postings: Vec<PostingData> = Vec::new();
246
247 for posting in &txn.postings {
248 if let Some(state) = account_states.get_mut(&posting.account) {
249 let Some(ref units) = posting.units else {
251 new_postings.push(posting.clone());
252 continue;
253 };
254
255 let Ok(units_number) = units.number.parse::<Decimal>() else {
256 new_postings.push(posting.clone());
257 continue;
258 };
259
260 if let Some(ref price_annot) = posting.price
262 && price_annot.is_total
263 {
264 let (postings, price_directive) = handle_total_price_posting(
266 posting,
267 units_number,
268 &units.currency,
269 price_annot,
270 state,
271 &directive.date,
272 directive,
273 );
274 if let Some(pd) = price_directive {
275 new_directives.push(pd);
276 }
277 new_postings.extend(postings);
278 continue;
279 }
280
281 if state.lots.is_empty() && state.total_units == Decimal::ZERO {
283 new_directives.push(DirectiveWrapper {
284 directive_type: "price".to_string(),
285 date: directive.date.clone(),
286 filename: directive.filename.clone(),
287 lineno: directive.lineno,
288 data: DirectiveData::Price(PriceData {
289 currency: state.config.currency.clone(),
290 amount: AmountData {
291 number: format_decimal(state.last_price),
292 currency: units.currency.clone(),
293 },
294 metadata: vec![],
295 }),
296 });
297 }
298
299 if units_number > Decimal::ZERO {
300 let synthetic_units =
302 round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);
303
304 state.lots.push(CostLot {
306 units: synthetic_units,
307 cost_per_unit: state.last_price,
308 date: directive.date.clone(),
309 });
310 state.total_units += synthetic_units;
311
312 new_postings.push(PostingData {
314 account: posting.account.clone(),
315 units: Some(AmountData {
316 number: format_decimal_fixed(synthetic_units, MAPPED_CURRENCY_PRECISION),
317 currency: state.config.currency.clone(),
318 }),
319 cost: Some(CostData {
320 number_per: Some(format_decimal(state.last_price)),
321 number_total: None,
322 currency: Some(units.currency.clone()),
323 date: Some(directive.date.clone()),
324 label: None,
325 merge: false,
326 }),
327 price: None,
328 flag: posting.flag.clone(),
329 metadata: posting.metadata.clone(),
330 });
331 } else {
332 let amount_to_sell = -units_number;
334 let (sell_postings, total_pnl) = process_fifo_sell(
335 state,
336 amount_to_sell,
337 &posting.account,
338 &units.currency,
339 &posting.flag,
340 &posting.metadata,
341 );
342
343 if total_pnl != Decimal::ZERO {
345 new_postings.push(PostingData {
346 account: state.config.pnl_account.clone(),
347 units: Some(AmountData {
348 number: format_decimal(-total_pnl),
349 currency: units.currency.clone(),
350 }),
351 cost: None,
352 price: None,
353 flag: None,
354 metadata: vec![],
355 });
356 }
357
358 new_postings.extend(sell_postings);
360 }
361 } else {
362 new_postings.push(posting.clone());
364 }
365 }
366
367 let mut new_tags = txn.tags.clone();
369 if !new_tags.contains(&TAG_TO_ADD.to_string()) {
370 new_tags.push(TAG_TO_ADD.to_string());
371 }
372
373 let transformed = DirectiveWrapper {
374 directive_type: "transaction".to_string(),
375 date: directive.date.clone(),
376 filename: directive.filename.clone(),
377 lineno: directive.lineno,
378 data: DirectiveData::Transaction(TransactionData {
379 flag: txn.flag.clone(),
380 payee: txn.payee.clone(),
381 narration: txn.narration.clone(),
382 tags: new_tags,
383 links: txn.links.clone(),
384 metadata: txn.metadata.clone(),
385 postings: new_postings,
386 }),
387 };
388
389 (transformed, new_directives, errors)
390}
391
392fn handle_total_price_posting(
395 posting: &PostingData,
396 units_number: Decimal,
397 units_currency: &str,
398 price_annot: &PriceAnnotationData,
399 state: &mut AccountState,
400 date: &str,
401 _directive: &DirectiveWrapper,
402) -> (Vec<PostingData>, Option<DirectiveWrapper>) {
403 let mut postings = Vec::new();
404
405 let Some(ref price_amount) = price_annot.amount else {
407 return (vec![posting.clone()], None);
408 };
409
410 let Ok(total_price) = price_amount.number.parse::<Decimal>() else {
411 return (vec![posting.clone()], None);
412 };
413
414 let per_unit_price = total_price / units_number;
416
417 postings.push(PostingData {
419 account: posting.account.clone(),
420 units: Some(AmountData {
421 number: format_decimal(units_number),
422 currency: units_currency.to_string(),
423 }),
424 cost: None,
425 price: Some(PriceAnnotationData {
426 is_total: false,
427 amount: Some(AmountData {
428 number: format_decimal(per_unit_price),
429 currency: price_amount.currency.clone(),
430 }),
431 number: None,
432 currency: None,
433 }),
434 flag: posting.flag.clone(),
435 metadata: posting.metadata.clone(),
436 });
437
438 postings.push(PostingData {
440 account: posting.account.clone(),
441 units: Some(AmountData {
442 number: format_decimal(-units_number),
443 currency: units_currency.to_string(),
444 }),
445 cost: None,
446 price: None,
447 flag: None,
448 metadata: vec![],
449 });
450
451 let synthetic_units = round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);
453
454 state.lots.push(CostLot {
456 units: synthetic_units,
457 cost_per_unit: state.last_price,
458 date: date.to_string(),
459 });
460 state.total_units += synthetic_units;
461
462 postings.push(PostingData {
463 account: posting.account.clone(),
464 units: Some(AmountData {
465 number: format_decimal_fixed(synthetic_units, MAPPED_CURRENCY_PRECISION),
466 currency: state.config.currency.clone(),
467 }),
468 cost: Some(CostData {
469 number_per: Some(format_decimal(state.last_price)),
470 number_total: None,
471 currency: Some(units_currency.to_string()),
472 date: Some(date.to_string()),
473 label: None,
474 merge: false,
475 }),
476 price: None,
477 flag: None,
478 metadata: vec![],
479 });
480
481 (postings, None)
482}
483
484fn process_fifo_sell(
486 state: &mut AccountState,
487 amount_to_sell: Decimal,
488 account: &str,
489 currency: &str,
490 flag: &Option<String>,
491 metadata: &[(String, MetaValueData)],
492) -> (Vec<PostingData>, Decimal) {
493 let mut postings = Vec::new();
494 let mut remaining = amount_to_sell;
495 let mut total_pnl = Decimal::ZERO;
496 let current_price = state.last_price;
497
498 while remaining > EPSILON && !state.lots.is_empty() {
499 let lot = &mut state.lots[0];
500 let lot_value_at_current_price = lot.units * current_price;
501
502 if lot_value_at_current_price <= remaining + EPSILON {
503 let units_to_sell = lot.units;
505 let pnl = (current_price - lot.cost_per_unit) * units_to_sell;
506 total_pnl += pnl;
507
508 let rounded_units = round_down(units_to_sell, MAPPED_CURRENCY_PRECISION);
510
511 postings.push(PostingData {
512 account: account.to_string(),
513 units: Some(AmountData {
514 number: format_decimal_fixed(-rounded_units, MAPPED_CURRENCY_PRECISION),
515 currency: state.config.currency.clone(),
516 }),
517 cost: Some(CostData {
518 number_per: Some(format_decimal(lot.cost_per_unit)),
519 number_total: None,
520 currency: Some(currency.to_string()),
521 date: Some(lot.date.clone()),
522 label: None,
523 merge: false,
524 }),
525 price: Some(PriceAnnotationData {
526 is_total: false,
527 amount: Some(AmountData {
528 number: format_decimal(current_price),
529 currency: currency.to_string(),
530 }),
531 number: None,
532 currency: None,
533 }),
534 flag: flag.clone(),
535 metadata: if postings.is_empty() {
536 metadata.to_vec()
537 } else {
538 vec![]
539 },
540 });
541
542 state.total_units -= lot.units;
543 remaining -= lot_value_at_current_price;
544 state.lots.remove(0);
545 } else {
546 let units_to_sell = remaining / current_price;
548 let pnl = (current_price - lot.cost_per_unit) * units_to_sell;
549 total_pnl += pnl;
550
551 let rounded_units = round_down(units_to_sell, MAPPED_CURRENCY_PRECISION);
552
553 postings.push(PostingData {
554 account: account.to_string(),
555 units: Some(AmountData {
556 number: format_decimal_fixed(-rounded_units, MAPPED_CURRENCY_PRECISION),
557 currency: state.config.currency.clone(),
558 }),
559 cost: Some(CostData {
560 number_per: Some(format_decimal(lot.cost_per_unit)),
561 number_total: None,
562 currency: Some(currency.to_string()),
563 date: Some(lot.date.clone()),
564 label: None,
565 merge: false,
566 }),
567 price: Some(PriceAnnotationData {
568 is_total: false,
569 amount: Some(AmountData {
570 number: format_decimal(current_price),
571 currency: currency.to_string(),
572 }),
573 number: None,
574 currency: None,
575 }),
576 flag: flag.clone(),
577 metadata: if postings.is_empty() {
578 metadata.to_vec()
579 } else {
580 vec![]
581 },
582 });
583
584 lot.units -= units_to_sell;
585 state.total_units -= units_to_sell;
586 remaining = Decimal::ZERO;
587 }
588 }
589
590 (postings, total_pnl)
591}
592
593fn process_valuation_assertion(
595 directive: &DirectiveWrapper,
596 custom: &crate::types::CustomData,
597 account_states: &mut HashMap<String, AccountState>,
598) -> (Vec<DirectiveWrapper>, Vec<PluginError>) {
599 let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
600 let mut errors: Vec<PluginError> = Vec::new();
601
602 if custom.values.len() < 2 {
604 new_directives.push(directive.clone());
605 return (new_directives, errors);
606 }
607
608 let account = match &custom.values[0] {
609 MetaValueData::Account(a) => a.clone(),
610 MetaValueData::String(s) => s.clone(),
611 _ => {
612 new_directives.push(directive.clone());
613 return (new_directives, errors);
614 }
615 };
616
617 let Some(state) = account_states.get_mut(&account) else {
618 errors.push(PluginError {
619 message: format!("No valuation config for account {account}"),
620 source_file: directive.filename.clone(),
621 line_number: directive.lineno,
622 severity: PluginErrorSeverity::Error,
623 });
624 new_directives.push(directive.clone());
625 return (new_directives, errors);
626 };
627
628 let Some((valuation_amount, valuation_currency)) = parse_valuation_amount(&custom.values[1])
629 else {
630 new_directives.push(directive.clone());
631 return (new_directives, errors);
632 };
633
634 let last_balance = state.total_units;
636
637 if last_balance.abs() < EPSILON {
638 errors.push(PluginError {
639 message: format!("Valuation called on empty account {account}"),
640 source_file: directive.filename.clone(),
641 line_number: directive.lineno,
642 severity: PluginErrorSeverity::Error,
643 });
644 new_directives.push(directive.clone());
645 return (new_directives, errors);
646 }
647
648 let calculated_price = valuation_amount / last_balance;
650 state.last_price = calculated_price;
651
652 let mut new_metadata = custom.metadata.clone();
654 new_metadata.push((
655 "lastBalance".to_string(),
656 MetaValueData::Number(format_decimal(last_balance)),
657 ));
658 new_metadata.push((
659 "calculatedPrice".to_string(),
660 MetaValueData::Number(format_decimal(calculated_price)),
661 ));
662
663 new_directives.push(DirectiveWrapper {
665 directive_type: "custom".to_string(),
666 date: directive.date.clone(),
667 filename: directive.filename.clone(),
668 lineno: directive.lineno,
669 data: DirectiveData::Custom(crate::types::CustomData {
670 custom_type: custom.custom_type.clone(),
671 values: custom.values.clone(),
672 metadata: new_metadata.clone(),
673 }),
674 });
675
676 new_directives.push(DirectiveWrapper {
678 directive_type: "price".to_string(),
679 date: directive.date.clone(),
680 filename: directive.filename.clone(),
681 lineno: directive.lineno,
682 data: DirectiveData::Price(PriceData {
683 currency: state.config.currency.clone(),
684 amount: AmountData {
685 number: format_decimal(calculated_price),
686 currency: valuation_currency,
687 },
688 metadata: vec![
689 (
690 "lastBalance".to_string(),
691 MetaValueData::Number(format_decimal(last_balance)),
692 ),
693 (
694 "calculatedPrice".to_string(),
695 MetaValueData::Number(format_decimal(calculated_price)),
696 ),
697 ],
698 }),
699 });
700
701 (new_directives, errors)
702}
703
704fn parse_valuation_amount(value: &MetaValueData) -> Option<(Decimal, String)> {
706 match value {
707 MetaValueData::Amount(amount) => amount
708 .number
709 .parse::<Decimal>()
710 .ok()
711 .map(|n| (n, amount.currency.clone())),
712 _ => None,
713 }
714}
715
716fn round_up(value: Decimal, decimals: u32) -> Decimal {
718 let scale = Decimal::new(1, decimals);
719 (value / scale).ceil() * scale
720}
721
722fn round_down(value: Decimal, decimals: u32) -> Decimal {
724 let scale = Decimal::new(1, decimals);
725 (value / scale).floor() * scale
726}
727
728fn format_decimal(d: Decimal) -> String {
730 let s = d.to_string();
731 if s.contains('.') {
732 s.trim_end_matches('0').trim_end_matches('.').to_string()
733 } else {
734 s
735 }
736}
737
738fn format_decimal_fixed(d: Decimal, decimals: u32) -> String {
740 let scaled = d.round_dp(decimals);
741 let s = format!("{:.1$}", scaled, decimals as usize);
742 s.trim_end_matches('0').to_string()
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749 use crate::types::*;
750
751 #[test]
752 fn test_valuation_config_parsing() {
753 let metadata = vec![
754 (
755 "account".to_string(),
756 MetaValueData::String("Assets:Fund".to_string()),
757 ),
758 (
759 "currency".to_string(),
760 MetaValueData::String("FUND_USD".to_string()),
761 ),
762 (
763 "pnlAccount".to_string(),
764 MetaValueData::String("Income:Fund:PnL".to_string()),
765 ),
766 ];
767
768 let config = parse_config(&metadata);
769 assert!(config.is_some());
770 let config = config.unwrap();
771 assert_eq!(config.account, "Assets:Fund");
772 assert_eq!(config.currency, "FUND_USD");
773 assert_eq!(config.pnl_account, "Income:Fund:PnL");
774 }
775
776 #[test]
777 fn test_round_up() {
778 let value = Decimal::new(12_345_678, 8); let rounded = round_up(value, 7);
780 assert!(rounded >= value);
781 assert_eq!(rounded, Decimal::new(1_234_568, 7));
783 }
784
785 #[test]
786 fn test_round_down() {
787 let value = Decimal::new(12_345_678, 8); let rounded = round_down(value, 7);
789 assert!(rounded <= value);
790 assert_eq!(rounded, Decimal::new(1_234_567, 7));
792 }
793
794 #[test]
795 fn test_fifo_lot_tracking() {
796 let config = AccountConfig {
797 account: "Assets:Fund".to_string(),
798 currency: "FUND_USD".to_string(),
799 pnl_account: "Income:PnL".to_string(),
800 };
801
802 let mut state = AccountState::new(config);
803
804 state.lots.push(CostLot {
806 units: Decimal::new(1000, 0),
807 cost_per_unit: Decimal::ONE,
808 date: "2024-01-10".to_string(),
809 });
810 state.total_units = Decimal::new(1000, 0);
811
812 state.last_price = Decimal::new(8, 1);
814
815 let second_units = Decimal::new(500, 0) / state.last_price; state.lots.push(CostLot {
818 units: second_units,
819 cost_per_unit: state.last_price,
820 date: "2024-01-13".to_string(),
821 });
822 state.total_units += second_units;
823
824 assert_eq!(state.lots.len(), 2);
825 assert_eq!(state.lots[0].cost_per_unit, Decimal::ONE);
826 assert_eq!(state.lots[1].cost_per_unit, Decimal::new(8, 1));
827 }
828
829 #[test]
830 fn test_format_decimal() {
831 assert_eq!(format_decimal(Decimal::new(12345, 4)), "1.2345");
832 assert_eq!(format_decimal(Decimal::new(10000, 4)), "1");
833 assert_eq!(format_decimal(Decimal::new(12300, 4)), "1.23");
834 }
835
836 #[test]
837 fn test_format_decimal_fixed() {
838 let d = Decimal::new(1000, 0); let formatted = format_decimal_fixed(d, 7);
840 assert!(formatted.starts_with("1000."));
841 }
842}