invest.portfolio-weights@1.0.1
impl/typescript.ts
2,756 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 HoldingWeight, type PortfolioHolding, type PortfolioWeights } from "./invest_portfolio_weights_types.ts";
/** n / d rounded half away from zero; d > 0. */
function roundHalfAway(n: bigint, d: bigint): bigint {
const negative = n < 0n;
const a = negative ? -n : n;
let q = a / d;
if (2n * (a - q * d) >= d) q += 1n;
return negative ? -q : q;
}
/**
* Each holding's weight, target and drift. Everything is derived from the
* integer values and rounded once at the end, so a weight is never computed
* from another rounded figure.
*/
export function portfolioWeights(holdings: readonly PortfolioHolding[]): PortfolioWeights {
if (holdings.length === 0) throw new RangeError("holdings must not be empty");
const currency = holdings[0].value.currency;
const seen = new Set<string>();
let total = 0n;
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.value.currency !== currency) throw new RangeError(`currency mismatch: ${currency} and ${h.value.currency}`);
if (!Number.isInteger(h.value.minor) || h.value.minor < 0) {
throw new RangeError(`holding values must be whole minor units, 0 or more; received ${h.value.minor} for "${h.id}"`);
}
if (!Number.isInteger(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}"`);
}
total += BigInt(h.value.minor);
targets += h.targetBasisPoints;
}
if (targets !== 10000) throw new RangeError(`targets must sum to 10000 basis points, received ${targets}`);
if (total === 0n) throw new RangeError("the portfolio total must be greater than zero");
let maxAbs = 0;
const rows: HoldingWeight[] = holdings.map((h) => {
const value = BigInt(h.value.minor);
const target = BigInt(h.targetBasisPoints);
const weight = Number(roundHalfAway(value * 10000n, total));
const drift = Number(roundHalfAway(value * 10000n - target * total, total));
const targetValue = Number(roundHalfAway(total * target, 10000n));
maxAbs = Math.max(maxAbs, Math.abs(drift));
return {
id: h.id,
value: money(h.value.minor, currency),
weightBasisPoints: weight,
targetBasisPoints: h.targetBasisPoints,
driftBasisPoints: drift,
targetValue: money(targetValue, currency),
driftValue: money(h.value.minor - targetValue, currency),
};
});
const totalMoney: Money = money(Number(total), currency);
return { total: totalMoney, holdings: rows, maxAbsDriftBasisPoints: maxAbs };
}