Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::money_amount::{money, money_from_value, money_to_value, Money}; ← from money.amount ^1.0.0 · built alongside by fune
/// 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<i128> = 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>| -> 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<usize> = 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<RebalanceTrade> = 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<f64> {
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<RebalanceHolding> = 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])))
}