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