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