invest.money-weighted-return@1.0.0
impl/python.py
3,795 bytes · the Python 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.
from typing import Callable, Dict, List, Sequence, Tuple
from .dates_add_days import epoch_day_from_iso ← from dates.add-days ^1.0.0 · built alongside by fune
from .invest_money_weighted_return_types import DatedFlow, XirrResult
from .math_fractional_power import FIXED_SCALE, pow_fixed ← from math.fractional-power ^1.0.0 · built alongside by fune
_MAX_SPAN_DAYS = 36500
_NO_SINGLE_RATE = "no single rate of return solves these cash flows"
def _sign(value: int) -> int:
return (value > 0) - (value < 0)
def _round_half_away(n: int, d: int) -> int:
"""n / d rounded half away from zero; d > 0."""
magnitude = (abs(n) * 2 + d) // (2 * d)
return -magnitude if n < 0 else magnitude
def _net_flows(flows: Sequence[DatedFlow]) -> List[Tuple[int, int]]:
"""Net flow per day, in day order, counted from the earliest date with a nonzero net flow."""
if len(flows) == 0:
raise ValueError("flows must not be empty")
currency = flows[0].amount.currency
net: Dict[int, int] = {}
for flow in flows:
amount = flow.amount
if amount.currency != currency:
raise ValueError("currency mismatch: %s and %s" % (currency, amount.currency))
if isinstance(amount.minor, bool) or not isinstance(amount.minor, int):
raise ValueError("amounts must be whole minor units, received %r" % (amount.minor,))
day = epoch_day_from_iso(flow.date)
net[day] = net.get(day, 0) + amount.minor
days = sorted((d, a) for d, a in net.items() if a != 0)
if not any(a > 0 for _, a in days) or not any(a < 0 for _, a in days):
raise ValueError("flows need at least one payment and one receipt")
first = days[0][0]
if days[-1][0] - first > _MAX_SPAN_DAYS:
raise ValueError("flows must fall within %d days of each other" % _MAX_SPAN_DAYS)
return [(d - first, a) for d, a in days]
def _bisect(f: Callable[[int], int]) -> int:
"""Bisection on x in [0, FIXED_SCALE] for a change of sign of f; the two ends' signs differ."""
lo, hi = 0, FIXED_SCALE
lo_sign = _sign(f(lo))
while hi - lo > 1:
mid = (lo + hi) // 2
s = _sign(f(mid))
if s == 0:
return mid
if s == lo_sign:
lo = mid
else:
hi = mid
return lo
def money_weighted_return(flows: Sequence[DatedFlow]) -> XirrResult:
"""XIRR: the annual rate r at which sum(amount / (1 + r)^(days / 365)) = 0,
days counted from the earliest flow. Solved by bisection in 18-place fixed
point on the per-day discount factor, then settled to 12 places and rounded
half away from zero to 9 places and to a basis point."""
net = _net_flows(flows)
total = sum(a for _, a in net)
rate = 0
if total != 0:
last = net[-1][0]
positive_side = _sign(net[0][1]) != _sign(total)
negative_side = _sign(net[-1][1]) != _sign(total)
if positive_side == negative_side:
raise ValueError(_NO_SINGLE_RATE)
if positive_side:
# v = (1 + r)^(-1/365) in (0, 1).
v = _bisect(lambda x: sum(a * pow_fixed(x, t) for t, a in net))
growth = pow_fixed(v, 365)
if growth == 0:
raise ValueError("the rate of return is too large to compute")
rate = FIXED_SCALE * FIXED_SCALE // growth - FIXED_SCALE
else:
# u = (1 + r)^(1/365) in (0, 1); the sum is multiplied through by u^last.
u = _bisect(lambda x: sum(a * pow_fixed(x, last - t) for t, a in net))
rate = pow_fixed(u, 365) - FIXED_SCALE
settled = _round_half_away(rate, 10 ** 6)
bp = _round_half_away(settled * 10000, 10 ** 12)
nine = _round_half_away(settled, 1000)
magnitude = abs(nine)
return XirrResult(
basis_points=bp,
rate="%s%d.%09d" % ("-" if nine < 0 else "", magnitude // 10 ** 9, magnitude % 10 ** 9),
)