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),
};
}