use std::collections::{HashMap, HashSet};
use rust_decimal::Decimal;
use crate::types::{
AmountData, CommodityData, CostData, DirectiveData, DirectiveWrapper, MetaValueData,
PluginError, PluginErrorSeverity, PluginInput, PluginOutput, PostingData, PriceAnnotationData,
PriceData, TransactionData,
};
use super::super::NativePlugin;
const MAPPED_CURRENCY_PRECISION: u32 = 7;
const TAG_TO_ADD: &str = "valuation-applied";
const EPSILON: Decimal = Decimal::from_parts(1, 0, 0, false, 9);
pub struct ValuationPlugin;
#[derive(Clone, Debug)]
struct AccountConfig {
account: String,
currency: String,
pnl_account: String,
}
#[derive(Clone, Debug)]
struct CostLot {
units: Decimal,
cost_per_unit: Decimal,
date: String,
}
#[derive(Clone, Debug)]
struct AccountState {
config: AccountConfig,
lots: Vec<CostLot>,
last_price: Decimal,
total_units: Decimal,
}
impl AccountState {
const fn new(config: AccountConfig) -> Self {
Self {
config,
lots: Vec::new(),
last_price: Decimal::ONE,
total_units: Decimal::ZERO,
}
}
}
impl NativePlugin for ValuationPlugin {
fn name(&self) -> &'static str {
"valuation"
}
fn description(&self) -> &'static str {
"Track opaque fund values using synthetic commodities"
}
fn process(&self, input: PluginInput) -> PluginOutput {
let mut errors: Vec<PluginError> = Vec::new();
let mut output_directives: Vec<DirectiveWrapper> = Vec::new();
let mut account_states: HashMap<String, AccountState> = HashMap::new();
let mut commodities_present: HashSet<String> = HashSet::new();
let mut last_date: Option<String> = None;
for directive in &input.directives {
match &directive.data {
DirectiveData::Custom(custom) => {
if custom.custom_type == "valuation"
&& !custom.values.is_empty()
&& matches!(custom.values.first(), Some(MetaValueData::String(s)) if s == "config")
&& let Some(config) = parse_config(&custom.metadata)
{
account_states.insert(config.account.clone(), AccountState::new(config));
}
}
DirectiveData::Commodity(commodity) => {
commodities_present.insert(commodity.currency.clone());
}
_ => {}
}
}
for directive in input.directives {
last_date = Some(directive.date.clone());
match &directive.data {
DirectiveData::Transaction(txn) => {
let has_mapped_posting = txn
.postings
.iter()
.any(|p| account_states.contains_key(&p.account));
if !has_mapped_posting {
output_directives.push(directive);
continue;
}
let (transformed, new_directives, new_errors) = transform_transaction(
&directive,
txn,
&mut account_states,
&mut commodities_present,
);
output_directives.extend(new_directives);
errors.extend(new_errors);
output_directives.push(transformed);
}
DirectiveData::Custom(custom)
if custom.custom_type == "valuation" && !custom.values.is_empty() =>
{
if matches!(custom.values.first(), Some(MetaValueData::String(s)) if s == "config")
{
output_directives.push(directive);
continue;
}
let (new_directives, new_errors) =
process_valuation_assertion(&directive, custom, &mut account_states);
output_directives.extend(new_directives);
errors.extend(new_errors);
}
DirectiveData::Custom(_) => {
output_directives.push(directive);
}
DirectiveData::Commodity(commodity) => {
commodities_present.insert(commodity.currency.clone());
output_directives.push(directive);
}
_ => {
output_directives.push(directive);
}
}
}
if let Some(date) = last_date {
for state in account_states.values() {
if !commodities_present.contains(&state.config.currency) {
output_directives.push(DirectiveWrapper {
directive_type: "commodity".to_string(),
date: date.clone(),
filename: Some("<valuation>".to_string()),
lineno: Some(0),
data: DirectiveData::Commodity(CommodityData {
currency: state.config.currency.clone(),
metadata: vec![],
}),
});
commodities_present.insert(state.config.currency.clone());
}
}
}
PluginOutput {
directives: output_directives,
errors,
}
}
}
fn parse_config(metadata: &[(String, MetaValueData)]) -> Option<AccountConfig> {
let account = get_meta_string(metadata, "account")?;
let currency = get_meta_string(metadata, "currency")?;
let pnl_account = get_meta_string(metadata, "pnlAccount")?;
Some(AccountConfig {
account,
currency,
pnl_account,
})
}
fn get_meta_string(metadata: &[(String, MetaValueData)], key: &str) -> Option<String> {
for (k, v) in metadata {
if k == key {
match v {
MetaValueData::String(s) => return Some(s.clone()),
MetaValueData::Account(a) => return Some(a.clone()),
_ => {}
}
}
}
None
}
fn transform_transaction(
directive: &DirectiveWrapper,
txn: &TransactionData,
account_states: &mut HashMap<String, AccountState>,
_commodities_present: &mut HashSet<String>,
) -> (DirectiveWrapper, Vec<DirectiveWrapper>, Vec<PluginError>) {
let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
let errors: Vec<PluginError> = Vec::new();
let mut new_postings: Vec<PostingData> = Vec::new();
for posting in &txn.postings {
if let Some(state) = account_states.get_mut(&posting.account) {
let Some(ref units) = posting.units else {
new_postings.push(posting.clone());
continue;
};
let Ok(units_number) = units.number.parse::<Decimal>() else {
new_postings.push(posting.clone());
continue;
};
if let Some(ref price_annot) = posting.price
&& price_annot.is_total
{
let (postings, price_directive) = handle_total_price_posting(
posting,
units_number,
&units.currency,
price_annot,
state,
&directive.date,
directive,
);
if let Some(pd) = price_directive {
new_directives.push(pd);
}
new_postings.extend(postings);
continue;
}
if state.lots.is_empty() && state.total_units == Decimal::ZERO {
new_directives.push(DirectiveWrapper {
directive_type: "price".to_string(),
date: directive.date.clone(),
filename: directive.filename.clone(),
lineno: directive.lineno,
data: DirectiveData::Price(PriceData {
currency: state.config.currency.clone(),
amount: AmountData {
number: format_decimal(state.last_price),
currency: units.currency.clone(),
},
metadata: vec![],
}),
});
}
if units_number > Decimal::ZERO {
let synthetic_units =
round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);
state.lots.push(CostLot {
units: synthetic_units,
cost_per_unit: state.last_price,
date: directive.date.clone(),
});
state.total_units += synthetic_units;
new_postings.push(PostingData {
account: posting.account.clone(),
units: Some(AmountData {
number: format_decimal_fixed(synthetic_units, MAPPED_CURRENCY_PRECISION),
currency: state.config.currency.clone(),
}),
cost: Some(CostData {
number_per: Some(format_decimal(state.last_price)),
number_total: None,
currency: Some(units.currency.clone()),
date: Some(directive.date.clone()),
label: None,
merge: false,
}),
price: None,
flag: posting.flag.clone(),
metadata: posting.metadata.clone(),
});
} else {
let amount_to_sell = -units_number;
let (sell_postings, total_pnl) = process_fifo_sell(
state,
amount_to_sell,
&posting.account,
&units.currency,
&posting.flag,
&posting.metadata,
);
if total_pnl != Decimal::ZERO {
new_postings.push(PostingData {
account: state.config.pnl_account.clone(),
units: Some(AmountData {
number: format_decimal(-total_pnl),
currency: units.currency.clone(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
});
}
new_postings.extend(sell_postings);
}
} else {
new_postings.push(posting.clone());
}
}
let mut new_tags = txn.tags.clone();
if !new_tags.contains(&TAG_TO_ADD.to_string()) {
new_tags.push(TAG_TO_ADD.to_string());
}
let transformed = DirectiveWrapper {
directive_type: "transaction".to_string(),
date: directive.date.clone(),
filename: directive.filename.clone(),
lineno: directive.lineno,
data: DirectiveData::Transaction(TransactionData {
flag: txn.flag.clone(),
payee: txn.payee.clone(),
narration: txn.narration.clone(),
tags: new_tags,
links: txn.links.clone(),
metadata: txn.metadata.clone(),
postings: new_postings,
}),
};
(transformed, new_directives, errors)
}
fn handle_total_price_posting(
posting: &PostingData,
units_number: Decimal,
units_currency: &str,
price_annot: &PriceAnnotationData,
state: &mut AccountState,
date: &str,
_directive: &DirectiveWrapper,
) -> (Vec<PostingData>, Option<DirectiveWrapper>) {
let mut postings = Vec::new();
let Some(ref price_amount) = price_annot.amount else {
return (vec![posting.clone()], None);
};
let Ok(total_price) = price_amount.number.parse::<Decimal>() else {
return (vec![posting.clone()], None);
};
let per_unit_price = total_price / units_number;
postings.push(PostingData {
account: posting.account.clone(),
units: Some(AmountData {
number: format_decimal(units_number),
currency: units_currency.to_string(),
}),
cost: None,
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: format_decimal(per_unit_price),
currency: price_amount.currency.clone(),
}),
number: None,
currency: None,
}),
flag: posting.flag.clone(),
metadata: posting.metadata.clone(),
});
postings.push(PostingData {
account: posting.account.clone(),
units: Some(AmountData {
number: format_decimal(-units_number),
currency: units_currency.to_string(),
}),
cost: None,
price: None,
flag: None,
metadata: vec![],
});
let synthetic_units = round_up(units_number / state.last_price, MAPPED_CURRENCY_PRECISION);
state.lots.push(CostLot {
units: synthetic_units,
cost_per_unit: state.last_price,
date: date.to_string(),
});
state.total_units += synthetic_units;
postings.push(PostingData {
account: posting.account.clone(),
units: Some(AmountData {
number: format_decimal_fixed(synthetic_units, MAPPED_CURRENCY_PRECISION),
currency: state.config.currency.clone(),
}),
cost: Some(CostData {
number_per: Some(format_decimal(state.last_price)),
number_total: None,
currency: Some(units_currency.to_string()),
date: Some(date.to_string()),
label: None,
merge: false,
}),
price: None,
flag: None,
metadata: vec![],
});
(postings, None)
}
fn process_fifo_sell(
state: &mut AccountState,
amount_to_sell: Decimal,
account: &str,
currency: &str,
flag: &Option<String>,
metadata: &[(String, MetaValueData)],
) -> (Vec<PostingData>, Decimal) {
let mut postings = Vec::new();
let mut remaining = amount_to_sell;
let mut total_pnl = Decimal::ZERO;
let current_price = state.last_price;
while remaining > EPSILON && !state.lots.is_empty() {
let lot = &mut state.lots[0];
let lot_value_at_current_price = lot.units * current_price;
if lot_value_at_current_price <= remaining + EPSILON {
let units_to_sell = lot.units;
let pnl = (current_price - lot.cost_per_unit) * units_to_sell;
total_pnl += pnl;
let rounded_units = round_down(units_to_sell, MAPPED_CURRENCY_PRECISION);
postings.push(PostingData {
account: account.to_string(),
units: Some(AmountData {
number: format_decimal_fixed(-rounded_units, MAPPED_CURRENCY_PRECISION),
currency: state.config.currency.clone(),
}),
cost: Some(CostData {
number_per: Some(format_decimal(lot.cost_per_unit)),
number_total: None,
currency: Some(currency.to_string()),
date: Some(lot.date.clone()),
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: format_decimal(current_price),
currency: currency.to_string(),
}),
number: None,
currency: None,
}),
flag: flag.clone(),
metadata: if postings.is_empty() {
metadata.to_vec()
} else {
vec![]
},
});
state.total_units -= lot.units;
remaining -= lot_value_at_current_price;
state.lots.remove(0);
} else {
let units_to_sell = remaining / current_price;
let pnl = (current_price - lot.cost_per_unit) * units_to_sell;
total_pnl += pnl;
let rounded_units = round_down(units_to_sell, MAPPED_CURRENCY_PRECISION);
postings.push(PostingData {
account: account.to_string(),
units: Some(AmountData {
number: format_decimal_fixed(-rounded_units, MAPPED_CURRENCY_PRECISION),
currency: state.config.currency.clone(),
}),
cost: Some(CostData {
number_per: Some(format_decimal(lot.cost_per_unit)),
number_total: None,
currency: Some(currency.to_string()),
date: Some(lot.date.clone()),
label: None,
merge: false,
}),
price: Some(PriceAnnotationData {
is_total: false,
amount: Some(AmountData {
number: format_decimal(current_price),
currency: currency.to_string(),
}),
number: None,
currency: None,
}),
flag: flag.clone(),
metadata: if postings.is_empty() {
metadata.to_vec()
} else {
vec![]
},
});
lot.units -= units_to_sell;
state.total_units -= units_to_sell;
remaining = Decimal::ZERO;
}
}
(postings, total_pnl)
}
fn process_valuation_assertion(
directive: &DirectiveWrapper,
custom: &crate::types::CustomData,
account_states: &mut HashMap<String, AccountState>,
) -> (Vec<DirectiveWrapper>, Vec<PluginError>) {
let mut new_directives: Vec<DirectiveWrapper> = Vec::new();
let mut errors: Vec<PluginError> = Vec::new();
if custom.values.len() < 2 {
new_directives.push(directive.clone());
return (new_directives, errors);
}
let account = match &custom.values[0] {
MetaValueData::Account(a) => a.clone(),
MetaValueData::String(s) => s.clone(),
_ => {
new_directives.push(directive.clone());
return (new_directives, errors);
}
};
let Some(state) = account_states.get_mut(&account) else {
errors.push(PluginError {
message: format!("No valuation config for account {account}"),
source_file: directive.filename.clone(),
line_number: directive.lineno,
severity: PluginErrorSeverity::Error,
});
new_directives.push(directive.clone());
return (new_directives, errors);
};
let Some((valuation_amount, valuation_currency)) = parse_valuation_amount(&custom.values[1])
else {
new_directives.push(directive.clone());
return (new_directives, errors);
};
let last_balance = state.total_units;
if last_balance.abs() < EPSILON {
errors.push(PluginError {
message: format!("Valuation called on empty account {account}"),
source_file: directive.filename.clone(),
line_number: directive.lineno,
severity: PluginErrorSeverity::Error,
});
new_directives.push(directive.clone());
return (new_directives, errors);
}
let calculated_price = valuation_amount / last_balance;
state.last_price = calculated_price;
let mut new_metadata = custom.metadata.clone();
new_metadata.push((
"lastBalance".to_string(),
MetaValueData::Number(format_decimal(last_balance)),
));
new_metadata.push((
"calculatedPrice".to_string(),
MetaValueData::Number(format_decimal(calculated_price)),
));
new_directives.push(DirectiveWrapper {
directive_type: "custom".to_string(),
date: directive.date.clone(),
filename: directive.filename.clone(),
lineno: directive.lineno,
data: DirectiveData::Custom(crate::types::CustomData {
custom_type: custom.custom_type.clone(),
values: custom.values.clone(),
metadata: new_metadata.clone(),
}),
});
new_directives.push(DirectiveWrapper {
directive_type: "price".to_string(),
date: directive.date.clone(),
filename: directive.filename.clone(),
lineno: directive.lineno,
data: DirectiveData::Price(PriceData {
currency: state.config.currency.clone(),
amount: AmountData {
number: format_decimal(calculated_price),
currency: valuation_currency,
},
metadata: vec![
(
"lastBalance".to_string(),
MetaValueData::Number(format_decimal(last_balance)),
),
(
"calculatedPrice".to_string(),
MetaValueData::Number(format_decimal(calculated_price)),
),
],
}),
});
(new_directives, errors)
}
fn parse_valuation_amount(value: &MetaValueData) -> Option<(Decimal, String)> {
match value {
MetaValueData::Amount(amount) => amount
.number
.parse::<Decimal>()
.ok()
.map(|n| (n, amount.currency.clone())),
_ => None,
}
}
fn round_up(value: Decimal, decimals: u32) -> Decimal {
let scale = Decimal::new(1, decimals);
(value / scale).ceil() * scale
}
fn round_down(value: Decimal, decimals: u32) -> Decimal {
let scale = Decimal::new(1, decimals);
(value / scale).floor() * scale
}
fn format_decimal(d: Decimal) -> String {
let s = d.to_string();
if s.contains('.') {
s.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
s
}
}
fn format_decimal_fixed(d: Decimal, decimals: u32) -> String {
let scaled = d.round_dp(decimals);
let s = format!("{:.1$}", scaled, decimals as usize);
s.trim_end_matches('0').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
#[test]
fn test_valuation_config_parsing() {
let metadata = vec![
(
"account".to_string(),
MetaValueData::String("Assets:Fund".to_string()),
),
(
"currency".to_string(),
MetaValueData::String("FUND_USD".to_string()),
),
(
"pnlAccount".to_string(),
MetaValueData::String("Income:Fund:PnL".to_string()),
),
];
let config = parse_config(&metadata);
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.account, "Assets:Fund");
assert_eq!(config.currency, "FUND_USD");
assert_eq!(config.pnl_account, "Income:Fund:PnL");
}
#[test]
fn test_round_up() {
let value = Decimal::new(12_345_678, 8); let rounded = round_up(value, 7);
assert!(rounded >= value);
assert_eq!(rounded, Decimal::new(1_234_568, 7));
}
#[test]
fn test_round_down() {
let value = Decimal::new(12_345_678, 8); let rounded = round_down(value, 7);
assert!(rounded <= value);
assert_eq!(rounded, Decimal::new(1_234_567, 7));
}
#[test]
fn test_fifo_lot_tracking() {
let config = AccountConfig {
account: "Assets:Fund".to_string(),
currency: "FUND_USD".to_string(),
pnl_account: "Income:PnL".to_string(),
};
let mut state = AccountState::new(config);
state.lots.push(CostLot {
units: Decimal::new(1000, 0),
cost_per_unit: Decimal::ONE,
date: "2024-01-10".to_string(),
});
state.total_units = Decimal::new(1000, 0);
state.last_price = Decimal::new(8, 1);
let second_units = Decimal::new(500, 0) / state.last_price; state.lots.push(CostLot {
units: second_units,
cost_per_unit: state.last_price,
date: "2024-01-13".to_string(),
});
state.total_units += second_units;
assert_eq!(state.lots.len(), 2);
assert_eq!(state.lots[0].cost_per_unit, Decimal::ONE);
assert_eq!(state.lots[1].cost_per_unit, Decimal::new(8, 1));
}
#[test]
fn test_format_decimal() {
assert_eq!(format_decimal(Decimal::new(12345, 4)), "1.2345");
assert_eq!(format_decimal(Decimal::new(10000, 4)), "1");
assert_eq!(format_decimal(Decimal::new(12300, 4)), "1.23");
}
#[test]
fn test_format_decimal_fixed() {
let d = Decimal::new(1000, 0); let formatted = format_decimal_fixed(d, 7);
assert!(formatted.starts_with("1000."));
}
}