use super::funejson::Value; use super::money_amount::{money, money_from_value, money_to_value, Money}; /// Whole-unit trades back to target weights. Target units are the nearest /// whole unit (a tie stays nearer the units held), trades below the minimum /// are dropped, and buys are then trimmed one unit at a time, from the holding /// that would end furthest above its target, until the cash covers them. /// /// # Panics /// Panics on no holdings, a duplicate id, mixed currencies, negative units or /// cash or minimum trade, a price of 0 or less, a target outside 0 to 10000, /// targets over 10000 in total, or a portfolio worth nothing. pub fn rebalance(holdings: &[RebalanceHolding], cash: &Money, minimum_trade: &Money) -> RebalancePlan { if holdings.is_empty() { panic!("holdings must not be empty"); } let currency = cash.currency.clone(); if minimum_trade.currency != currency { panic!("currency mismatch: {} and {}", currency, minimum_trade.currency); } if cash.minor < 0 { panic!("cash must be whole minor units, 0 or more; received {}", cash.minor); } if minimum_trade.minor < 0 { panic!("minimumTrade must be whole minor units, 0 or more; received {}", minimum_trade.minor); } let mut seen: Vec<&str> = Vec::new(); let mut total: i128 = cash.minor as i128; 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.unit_price.currency != currency { panic!("currency mismatch: {} and {}", currency, h.unit_price.currency); } if h.units < 0 { panic!("units must be a whole number, 0 or more; received {} for \"{}\"", h.units, h.id); } if h.unit_price.minor <= 0 { panic!( "unitPrice must be whole minor units greater than zero; received {} for \"{}\"", h.unit_price.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 ); } targets += h.target_basis_points; total += h.units as i128 * h.unit_price.minor as i128; } if targets > 10000 { panic!("targets must sum to at most 10000 basis points, received {}", targets); } if total == 0 { panic!("the portfolio total must be greater than zero"); } let minimum = minimum_trade.minor as i128; let mut trades: Vec = holdings .iter() .map(|h| { let price = h.unit_price.minor as i128; let units = h.units as i128; let n = total * h.target_basis_points as i128; let d = 10000 * price; let mut target = n / d; let twice = 2 * (n - target * d); if twice > d || (twice == d && units > target) { target += 1; } let trade = target - units; if trade.abs() * price < minimum { 0 } else { trade } }) .collect(); let spent = |trades: &Vec| -> i128 { trades .iter() .zip(holdings) .map(|(t, h)| t * h.unit_price.minor as i128) .sum() }; let mut cash_after = cash.minor as i128 - spent(&trades); while cash_after < 0 { let mut pick: Option = None; let mut worst: i128 = 0; for (i, h) in holdings.iter().enumerate() { if trades[i] <= 0 { continue; } let excess = (h.units as i128 + trades[i]) * h.unit_price.minor as i128 * 10000 - total * h.target_basis_points as i128; if pick.is_none() || excess > worst { pick = Some(i); worst = excess; } } let i = pick.unwrap(); trades[i] -= 1; if trades[i] > 0 && trades[i] * (holdings[i].unit_price.minor as i128) < minimum { trades[i] = 0; } cash_after = cash.minor as i128 - spent(&trades); } let mut bought: i128 = 0; let mut sold: i128 = 0; let rows: Vec = holdings .iter() .zip(&trades) .map(|(h, t)| { let price = h.unit_price.minor as i128; let value = t * price; if value > 0 { bought += value; } else { sold -= value; } let units_after = h.units as i128 + t; let value_after = units_after * price; let mut weight = value_after * 10000 / total; if 2 * (value_after * 10000 - weight * total) >= total { weight += 1; } RebalanceTrade { id: h.id.clone(), units: *t as i64, value: money(value as i64, ¤cy), units_after: units_after as i64, value_after: money(value_after as i64, ¤cy), weight_after_basis_points: weight as i64, } }) .collect(); RebalancePlan { trades: rows, total_value: money(total as i64, ¤cy), bought: money(bought as i64, ¤cy), sold: money(sold as i64, ¤cy), cash_after: money(cash_after as i64, ¤cy), } } fn fraction(v: &Value) -> Option { match v { Value::Float(f) if f.fract() != 0.0 => Some(*f), _ => None, } } pub fn rebalance_holding_from_value(v: &Value) -> RebalanceHolding { let id = v.get("id").as_str().to_string(); if let Some(f) = fraction(v.get("units")) { panic!("units must be a whole number, 0 or more; received {} for \"{}\"", f, id); } if let Some(f) = fraction(v.get("unitPrice").get("minor")) { panic!("unitPrice must be whole minor units greater than zero; received {} for \"{}\"", f, id); } if let Some(f) = fraction(v.get("targetBasisPoints")) { panic!("targetBasisPoints must be a whole number from 0 to 10000; received {} for \"{}\"", f, id); } RebalanceHolding { units: v.get("units").as_i64(), unit_price: money_from_value(v.get("unitPrice")), target_basis_points: v.get("targetBasisPoints").as_i64(), id, } } pub fn rebalance_plan_to_value(plan: &RebalancePlan) -> Value { Value::obj(vec![ ( "trades", Value::Arr( plan.trades .iter() .map(|t| { Value::obj(vec![ ("id", Value::str(&t.id)), ("units", Value::Int(t.units)), ("value", money_to_value(&t.value)), ("unitsAfter", Value::Int(t.units_after)), ("valueAfter", money_to_value(&t.value_after)), ("weightAfterBasisPoints", Value::Int(t.weight_after_basis_points)), ]) }) .collect(), ), ), ("totalValue", money_to_value(&plan.total_value)), ("bought", money_to_value(&plan.bought)), ("sold", money_to_value(&plan.sold)), ("cashAfter", money_to_value(&plan.cash_after)), ]) } pub fn fune_vector(args: &[Value]) -> Value { let holdings: Vec = args[0].as_arr().iter().map(rebalance_holding_from_value).collect(); if let Some(f) = fraction(args[1].get("minor")) { panic!("cash must be whole minor units, 0 or more; received {}", f); } if let Some(f) = fraction(args[2].get("minor")) { panic!("minimumTrade must be whole minor units, 0 or more; received {}", f); } rebalance_plan_to_value(&rebalance(&holdings, &money_from_value(&args[1]), &money_from_value(&args[2]))) }