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