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