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