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, 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); // 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#[derive(Clone, Debug)]
55struct CostLot {
56    units: Decimal,
57    cost_per_unit: Decimal,
58    date: String,
59}
60
61/// State for a mapped account.
62#[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        // Track state per account
95        let mut account_states: HashMap<String, AccountState> = HashMap::new();
96
97        // Track which commodities already exist
98        let mut commodities_present: HashSet<String> = HashSet::new();
99
100        // Track last date for commodity directive generation
101        let mut last_date: Option<String> = None;
102
103        // First pass: collect configs and existing commodities
104        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        // Second pass: process directives in order
123        for directive in input.directives {
124            last_date = Some(directive.date.clone());
125
126            match &directive.data {
127                DirectiveData::Transaction(txn) => {
128                    // Check if any posting is on a mapped account
129                    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                    // Transform the transaction
140                    let (transformed, new_directives, new_errors) = transform_transaction(
141                        &directive,
142                        txn,
143                        &mut account_states,
144                        &mut commodities_present,
145                    );
146
147                    // Add any price directives generated
148                    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                        // Check if this is a config (pass through) or a valuation assertion
155                        if matches!(custom.values.first(), Some(MetaValueData::String(s)) if s == "config")
156                        {
157                            output_directives.push(directive);
158                            continue;
159                        }
160
161                        // This is a valuation assertion
162                        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        // Generate commodity directives for synthetic currencies that don't exist
182        // Use the last transaction date, not 1970-01-01
183        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                    // Only add once
197                    commodities_present.insert(state.config.currency.clone());
198                }
199            }
200        }
201
202        PluginOutput {
203            directives: output_directives,
204            errors,
205        }
206    }
207}
208
209/// Parse config metadata into `AccountConfig`.
210fn 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
221/// Get a string value from metadata.
222fn 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
235/// Transform a transaction that has postings on mapped accounts.
236fn 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            // This is a mapped account posting
249            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            // Check for @@ total price annotation
260            if let Some(ref price_annot) = posting.price
261                && price_annot.is_total
262            {
263                // Handle @@ price annotation - generates 3 postings
264                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            // Generate initial price directive if this is the first transaction
281            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                // INFLOW: Convert to synthetic currency
300                let synthetic_units =
301                    round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);
302
303                // Add to lots
304                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                // Create posting with cost basis
312                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                // OUTFLOW: FIFO sell from lots
332                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                // Add PnL posting first (negative PnL = gain)
343                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                // Add the sell postings
358                new_postings.extend(sell_postings);
359            }
360        } else {
361            // Not a mapped account, pass through
362            new_postings.push(posting.clone());
363        }
364    }
365
366    // Create modified transaction with tag
367    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
391/// Handle posting with @@ total price annotation.
392/// Returns the new postings and optionally a price directive.
393fn 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    // Get the total price amount
405    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    // Calculate per-unit price
414    let per_unit_price = total_price / units_number;
415
416    // 1. Original posting with @ per_unit price
417    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    // 2. Reversal posting
438    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    // 3. Synthetic currency posting
451    let synthetic_units = round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);
452
453    // Add to lots
454    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
483/// Process FIFO sell and return postings and total `PnL`.
484fn 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            // Sell entire lot
503            let units_to_sell = lot.units;
504            let pnl = (current_price - lot.cost_per_unit) * units_to_sell;
505            total_pnl += pnl;
506
507            // Round down for sells
508            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            // Partial sell from this lot
546            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
592/// Process a valuation assertion custom directive.
593fn 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    // Parse the valuation: custom "valuation" Account Amount
602    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    // Get current balance in synthetic units
634    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    // Calculate new price
648    let calculated_price = valuation_amount / last_balance;
649    state.last_price = calculated_price;
650
651    // Create metadata for lastBalance and calculatedPrice
652    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    // Add modified custom directive
663    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    // Add price directive with same metadata
676    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
703/// Parse a valuation amount from a `MetaValueData`.
704fn 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
715/// Round up with given precision.
716fn round_up(value: Decimal, decimals: u32) -> Decimal {
717    let scale = Decimal::new(1, decimals);
718    (value / scale).ceil() * scale
719}
720
721/// Round down with given precision.
722fn round_down(value: Decimal, decimals: u32) -> Decimal {
723    let scale = Decimal::new(1, decimals);
724    (value / scale).floor() * scale
725}
726
727/// Format a decimal number, stripping trailing zeros.
728fn 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
737/// Format a decimal with fixed precision (for synthetic amounts).
738fn 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    // Trim trailing zeros but keep at least 7 decimal places for consistency
742    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); // 0.12345678
778        let rounded = round_up(value, 7);
779        assert!(rounded >= value);
780        // 0.12345678 rounded up to 7 decimals = 0.1234568
781        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); // 0.12345678
787        let rounded = round_down(value, 7);
788        assert!(rounded <= value);
789        // 0.12345678 rounded down to 7 decimals = 0.1234567
790        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        // Add first lot at price 1.0
804        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        // Update price to 0.8
812        state.last_price = Decimal::new(8, 1);
813
814        // Add second lot at price 0.8
815        let second_units = Decimal::new(500, 0) / state.last_price; // 625
816        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); // 1000
838        let formatted = format_decimal_fixed(d, 7);
839        assert!(formatted.starts_with("1000."));
840    }
841}