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