Functional Weave
Code in Python

invest.rebalance@1.0.1

impl/rust.rs

8,136 bytes · the Rust implementation · view raw

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, &currency),
                units_after: units_after as i64,
                value_after: money(value_after as i64, &currency),
                weight_after_basis_points: weight as i64,
            }
        })
        .collect();
    RebalancePlan {
        trades: rows,
        total_value: money(total as i64, &currency),
        bought: money(bought as i64, &currency),
        sold: money(sold as i64, &currency),
        cash_after: money(cash_after as i64, &currency),
    }
}

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])))
}