Functional Weave
Code in Python

invest.rebalance@1.0.1

impl/typescript.ts

4,680 bytes · the TypeScript 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.

import { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { type RebalanceHolding, type RebalancePlan, type RebalanceTrade } from "./invest_rebalance_types.ts";

function isWhole(value: number): boolean {
  return Number.isInteger(value);
}

/**
 * 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.
 */
export function rebalance(holdings: readonly RebalanceHolding[], cash: Money, minimumTrade: Money): RebalancePlan {
  if (holdings.length === 0) throw new RangeError("holdings must not be empty");
  const currency = cash.currency;
  if (minimumTrade.currency !== currency) throw new RangeError(`currency mismatch: ${currency} and ${minimumTrade.currency}`);
  if (!isWhole(cash.minor) || cash.minor < 0) throw new RangeError(`cash must be whole minor units, 0 or more; received ${cash.minor}`);
  if (!isWhole(minimumTrade.minor) || minimumTrade.minor < 0) {
    throw new RangeError(`minimumTrade must be whole minor units, 0 or more; received ${minimumTrade.minor}`);
  }
  const seen = new Set<string>();
  let total = BigInt(cash.minor);
  let targets = 0;
  for (const h of holdings) {
    if (seen.has(h.id)) throw new RangeError(`duplicate holding id "${h.id}"`);
    seen.add(h.id);
    if (h.unitPrice.currency !== currency) throw new RangeError(`currency mismatch: ${currency} and ${h.unitPrice.currency}`);
    if (!isWhole(h.units) || h.units < 0) throw new RangeError(`units must be a whole number, 0 or more; received ${h.units} for "${h.id}"`);
    if (!isWhole(h.unitPrice.minor) || h.unitPrice.minor <= 0) {
      throw new RangeError(`unitPrice must be whole minor units greater than zero; received ${h.unitPrice.minor} for "${h.id}"`);
    }
    if (!isWhole(h.targetBasisPoints) || h.targetBasisPoints < 0 || h.targetBasisPoints > 10000) {
      throw new RangeError(`targetBasisPoints must be a whole number from 0 to 10000; received ${h.targetBasisPoints} for "${h.id}"`);
    }
    targets += h.targetBasisPoints;
    total += BigInt(h.units) * BigInt(h.unitPrice.minor);
  }
  if (targets > 10000) throw new RangeError(`targets must sum to at most 10000 basis points, received ${targets}`);
  if (total === 0n) throw new RangeError("the portfolio total must be greater than zero");

  const minimum = BigInt(minimumTrade.minor);
  const trades: bigint[] = holdings.map((h) => {
    const price = BigInt(h.unitPrice.minor);
    const units = BigInt(h.units);
    const n = total * BigInt(h.targetBasisPoints);
    const d = 10000n * price;
    let target = n / d;
    const twice = 2n * (n - target * d);
    if (twice > d || (twice === d && units > target)) target += 1n;
    const trade = target - units;
    const size = trade < 0n ? -trade * price : trade * price;
    return size < minimum ? 0n : trade;
  });

  const spent = () => trades.reduce((sum, t, i) => sum + t * BigInt(holdings[i].unitPrice.minor), 0n);
  let cashAfter = BigInt(cash.minor) - spent();
  while (cashAfter < 0n) {
    let pick = -1;
    let worst = 0n;
    holdings.forEach((h, i) => {
      if (trades[i] <= 0n) return;
      const price = BigInt(h.unitPrice.minor);
      const excess = (BigInt(h.units) + trades[i]) * price * 10000n - total * BigInt(h.targetBasisPoints);
      if (pick < 0 || excess > worst) {
        pick = i;
        worst = excess;
      }
    });
    const price = BigInt(holdings[pick].unitPrice.minor);
    trades[pick] -= 1n;
    if (trades[pick] > 0n && trades[pick] * price < minimum) trades[pick] = 0n;
    cashAfter = BigInt(cash.minor) - spent();
  }

  let bought = 0n;
  let sold = 0n;
  const rows: RebalanceTrade[] = holdings.map((h, i) => {
    const price = BigInt(h.unitPrice.minor);
    const value = trades[i] * price;
    if (value > 0n) bought += value;
    else sold -= value;
    const unitsAfter = BigInt(h.units) + trades[i];
    const valueAfter = unitsAfter * price;
    let weight = (valueAfter * 10000n) / total;
    if (2n * (valueAfter * 10000n - weight * total) >= total) weight += 1n;
    return {
      id: h.id,
      units: Number(trades[i]),
      value: money(Number(value), currency),
      unitsAfter: Number(unitsAfter),
      valueAfter: money(Number(valueAfter), currency),
      weightAfterBasisPoints: Number(weight),
    };
  });
  return {
    trades: rows,
    totalValue: money(Number(total), currency),
    bought: money(Number(bought), currency),
    sold: money(Number(sold), currency),
    cashAfter: money(Number(cashAfter), currency),
  };
}