use super::funejson::Value; use super::money_amount::{money, money_from_value, money_to_value}; /// n / d rounded half away from zero; d > 0. fn round_half_away(n: i128, d: i128) -> i128 { let a = n.abs(); let mut q = a / d; if 2 * (a - q * d) >= d { q += 1; } if n < 0 { -q } else { q } } /// Each holding's weight, target and drift. Everything is derived from the /// integer values and rounded once at the end, so a weight is never computed /// from another rounded figure. /// /// # Panics /// Panics on an empty list, a duplicate id, mixed currencies, a negative /// value, a target outside 0 to 10000, targets that do not sum to 10000, or a /// portfolio worth nothing. pub fn portfolio_weights(holdings: &[PortfolioHolding]) -> PortfolioWeights { if holdings.is_empty() { panic!("holdings must not be empty"); } let currency = holdings[0].value.currency.clone(); let mut seen: Vec<&str> = Vec::new(); let mut total: i128 = 0; let mut targets: i64 = 0; for h in holdings { if seen.contains(&h.id.as_str()) { panic!("duplicate holding id \"{}\"", h.id); } seen.push(&h.id); if h.value.currency != currency { panic!("currency mismatch: {} and {}", currency, h.value.currency); } if h.value.minor < 0 { panic!( "holding values must be whole minor units, 0 or more; received {} for \"{}\"", h.value.minor, h.id ); } if h.target_basis_points < 0 || h.target_basis_points > 10000 { panic!( "targetBasisPoints must be a whole number from 0 to 10000; received {} for \"{}\"", h.target_basis_points, h.id ); } total += h.value.minor as i128; targets += h.target_basis_points; } if targets != 10000 { panic!("targets must sum to 10000 basis points, received {}", targets); } if total == 0 { panic!("the portfolio total must be greater than zero"); } let mut max_abs: i64 = 0; let rows: Vec = holdings .iter() .map(|h| { let value = h.value.minor as i128; let target = h.target_basis_points as i128; let drift = round_half_away(value * 10000 - target * total, total) as i64; let target_value = round_half_away(total * target, 10000) as i64; max_abs = max_abs.max(drift.abs()); HoldingWeight { id: h.id.clone(), value: money(h.value.minor, ¤cy), weight_basis_points: round_half_away(value * 10000, total) as i64, target_basis_points: h.target_basis_points, drift_basis_points: drift, target_value: money(target_value, ¤cy), drift_value: money(h.value.minor - target_value, ¤cy), } }) .collect(); PortfolioWeights { total: money(total as i64, ¤cy), holdings: rows, max_abs_drift_basis_points: max_abs, } } fn whole(v: &Value, what: &str, id: &str) -> i64 { if let Value::Float(f) = v { if f.fract() != 0.0 { match what { "value" => panic!( "holding values must be whole minor units, 0 or more; received {} for \"{}\"", f, id ), _ => panic!( "targetBasisPoints must be a whole number from 0 to 10000; received {} for \"{}\"", f, id ), } } } v.as_i64() } pub fn portfolio_holding_from_value(v: &Value) -> PortfolioHolding { let id = v.get("id").as_str().to_string(); whole(v.get("value").get("minor"), "value", &id); PortfolioHolding { target_basis_points: whole(v.get("targetBasisPoints"), "target", &id), value: money_from_value(v.get("value")), id, } } pub fn portfolio_weights_to_value(result: &PortfolioWeights) -> Value { Value::obj(vec![ ("total", money_to_value(&result.total)), ( "holdings", Value::Arr( result .holdings .iter() .map(|h| { Value::obj(vec![ ("id", Value::str(&h.id)), ("value", money_to_value(&h.value)), ("weightBasisPoints", Value::Int(h.weight_basis_points)), ("targetBasisPoints", Value::Int(h.target_basis_points)), ("driftBasisPoints", Value::Int(h.drift_basis_points)), ("targetValue", money_to_value(&h.target_value)), ("driftValue", money_to_value(&h.drift_value)), ]) }) .collect(), ), ), ("maxAbsDriftBasisPoints", Value::Int(result.max_abs_drift_basis_points)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let holdings: Vec = args[0].as_arr().iter().map(portfolio_holding_from_value).collect(); portfolio_weights_to_value(&portfolio_weights(&holdings)) }