Skip to main content

rustledger_plugin/native/plugins/
valuation.rs

1//! Valuation plugin - track opaque fund values using synthetic commodities.
2//!
3//! This plugin allows specifying total investment account value over time and
4//! creates an underlying fictional commodity whose price is set to match the
5//! total value of the account.
6//!
7//! All incoming and outgoing transactions are converted into transactions
8//! buying and selling this commodity at a calculated price.
9//!
10//! Usage:
11//! ```beancount
12//! plugin "beancount_lazy_plugins.valuation"
13//!
14//! 1970-01-01 open Assets:Fund:Total "FIFO"
15//! 1970-01-01 open Income:Fund:PnL
16//!
17//! 1970-01-01 custom "valuation" "config"
18//!     account: "Assets:Fund:Total"
19//!     currency: "FUND_USD"
20//!     pnlAccount: "Income:Fund:PnL"
21//!
22//! ; Assert total value
23//! 2024-01-05 custom "valuation" Assets:Fund:Total 2345 USD
24//! ```
25
26use 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); // 1e-9
41
42/// Plugin for tracking opaque fund values.
43pub struct ValuationPlugin;
44
45/// Account mapping configuration.
46#[derive(Clone, Debug)]
47struct AccountConfig {
48    account: String,
49    currency: String,
50    pnl_account: String,
51}
52
53/// A cost lot for FIFO tracking.
54///
55/// # Why this is NOT `rustledger_core::Inventory` (Phase-1 sweep Z3)
56///
57/// This plugin deliberately keeps a private lot list instead of reusing the
58/// core inventory's booking-method reduction, because the two operations
59/// are different in kind, not just in code:
60///
61/// - **Core `Inventory::reduce` is unit-denominated**: a reduction consumes
62///   N units, matched against lots per booking method. This plugin's sells
63///   are **value-denominated**: [`process_fifo_sell`] consumes lots against
64///   a currency amount (`lot.units × current_price` vs the remaining value),
65///   which core has no API for — porting would mean re-deriving exactly the
66///   loop below on top of core types.
67/// - The plugin applies its own **rounding policy** (`round_down` on sells,
68///   `round_up` on buys, at `MAPPED_CURRENCY_PRECISION`) and computes `PnL`
69///   against `last_price`; core reduction is exact and PnL-agnostic
70///   (capital gains live in the booking engine).
71/// - The plugin operates in the **DTO domain** (`PostingData`, string
72///   numbers) on the plugin wire, not on core `Position`s.
73///
74/// Revisit only if core ever grows a value-denominated reduction — then
75/// this loop is the candidate call site. For unit-denominated needs, use
76/// `rustledger_core::Inventory`; do not extend this struct.
77#[derive(Clone, Debug)]
78struct CostLot {
79    units: Decimal,
80    cost_per_unit: Decimal,
81    date: String,
82}
83
84/// State for a mapped account.
85#[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        // Track state per account
118        let mut account_states: HashMap<String, AccountState> = HashMap::new();
119
120        // Track which commodities already exist
121        let mut commodities_present: HashSet<String> = HashSet::new();
122
123        // Track last date for commodity directive generation
124        let mut last_date: Option<String> = None;
125
126        // First pass: collect configs and existing commodities
127        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        // Second pass: process directives in order
146        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                    // Check if any posting is on a mapped account
152                    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                    // Transform the transaction
163                    let (transformed, new_directives, new_errors) = transform_transaction(
164                        &directive,
165                        txn,
166                        &mut account_states,
167                        &mut commodities_present,
168                    );
169
170                    // Add any price directives generated as Inserts.
171                    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                    // Check if this is a config (pass through) or a valuation assertion
181                    if matches!(custom.values.first(), Some(MetaValueData::String(s)) if s == "config")
182                    {
183                        ops.push(PluginOp::Keep(i));
184                        continue;
185                    }
186
187                    // This is a valuation assertion — replace it with the
188                    // synthesized directives (Delete + Inserts).
189                    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        // Generate commodity directives for synthetic currencies that don't exist
212        // Use the last transaction date, not 1970-01-01
213        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                    // Only add once
227                    commodities_present.insert(state.config.currency.clone());
228                }
229            }
230        }
231
232        PluginOutput { ops, errors }
233    }
234}
235
236impl RegularPlugin for ValuationPlugin {}
237
238/// Parse config metadata into `AccountConfig`.
239fn 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
250/// Get a string value from metadata.
251fn 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
264/// Transform a transaction that has postings on mapped accounts.
265fn 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            // This is a mapped account posting
278            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            // Check for @@ total price annotation
289            if let Some(ref price_annot) = posting.price
290                && price_annot.is_total
291            {
292                // Handle @@ price annotation - generates 3 postings
293                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            // Generate initial price directive if this is the first transaction
310            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                // INFLOW: Convert to synthetic currency
329                let synthetic_units =
330                    round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);
331
332                // Add to lots
333                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                // Create posting with cost basis
341                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                // OUTFLOW: FIFO sell from lots
363                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                // Add PnL posting first (negative PnL = gain)
374                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                // Add the sell postings
390                new_postings.extend(sell_postings);
391            }
392        } else {
393            // Not a mapped account, pass through
394            new_postings.push(posting.clone());
395        }
396    }
397
398    // Create modified transaction with tag
399    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
423/// Handle posting with @@ total price annotation.
424/// Returns the new postings and optionally a price directive.
425fn 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    // Get the total price amount
437    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    // Calculate per-unit price
446    let per_unit_price = total_price / units_number;
447
448    // 1. Original posting with @ per_unit price
449    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    // 2. Reversal posting
471    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    // 3. Synthetic currency posting
485    let synthetic_units = round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);
486
487    // Add to lots
488    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
519/// Process FIFO sell and return postings and total `PnL`.
520fn 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            // Sell entire lot
539            let units_to_sell = lot.units;
540            let pnl = (current_price - lot.cost_per_unit) * units_to_sell;
541            total_pnl += pnl;
542
543            // Round down for sells
544            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            // Partial sell from this lot
584            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
632/// Process a valuation assertion custom directive.
633fn 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    // Parse the valuation: custom "valuation" Account Amount
642    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    // Get current balance in synthetic units
674    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    // Calculate new price
688    let calculated_price = valuation_amount / last_balance;
689    state.last_price = calculated_price;
690
691    // Create metadata for lastBalance and calculatedPrice
692    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    // Add modified custom directive
703    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    // Add price directive with same metadata
716    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
743/// Parse a valuation amount from a `MetaValueData`.
744fn 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
755/// Round up with given precision.
756fn round_up(value: Decimal, decimals: u32) -> Decimal {
757    let scale = Decimal::new(1, decimals);
758    (value / scale).ceil() * scale
759}
760
761/// Round down with given precision.
762fn round_down(value: Decimal, decimals: u32) -> Decimal {
763    let scale = Decimal::new(1, decimals);
764    (value / scale).floor() * scale
765}
766
767/// Format a decimal number, stripping trailing zeros.
768fn 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
777/// Format a decimal with fixed precision (for synthetic amounts).
778fn 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    // Trim trailing zeros but keep at least 7 decimal places for consistency
782    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); // 0.12345678
818        let rounded = round_up(value, 7);
819        assert!(rounded >= value);
820        // 0.12345678 rounded up to 7 decimals = 0.1234568
821        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); // 0.12345678
827        let rounded = round_down(value, 7);
828        assert!(rounded <= value);
829        // 0.12345678 rounded down to 7 decimals = 0.1234567
830        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        // Add first lot at price 1.0
844        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        // Update price to 0.8
852        state.last_price = Decimal::new(8, 1);
853
854        // Add second lot at price 0.8
855        let second_units = Decimal::new(500, 0) / state.last_price; // 625
856        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); // 1000
878        let formatted = format_decimal_fixed(d, 7);
879        assert!(formatted.starts_with("1000."));
880    }
881}