Skip to main content

rustledger_plugin/native/plugins/
valuation.rs

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