From 0093cabfc9bee48cbc0ff5dcbbfea393fef045ba Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 10 Nov 2026 20:35:03 -0300 Subject: [PATCH] fix(investments): solve short-period return over the holding period Holdings under a year annualized their rate before turning it back into the period return, so a few days with a modest gain (+1.3% in one day is ~10,600% a year) fell outside xirr's search range and showed no percentage. Solve the rate over the period held instead. --- app/apps/investments/services.py | 22 ++++++++++----------- app/apps/investments/tests/test_services.py | 6 ++++++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/app/apps/investments/services.py b/app/apps/investments/services.py index 1f666ccd..7546b3bd 100644 --- a/app/apps/investments/services.py +++ b/app/apps/investments/services.py @@ -239,13 +239,14 @@ def get_holding_performance(account, as_of=None, with_history=False): return perf -def xirr(flows): - """Annual rate r where the dated flows' present value is zero, as a float - (0.138 = 13.8% a year). None when it can't be solved.""" +def xirr(flows, period_days=365): + """Rate r per ``period_days`` (a year by default) where the dated flows' + present value is zero, as a float (0.138 = 13.8% a period). None when it + can't be solved.""" if not flows: return None start = min(flow[0] for flow in flows) - points = [((flow[0] - start).days / 365, float(flow[1])) for flow in flows] + points = [((flow[0] - start).days / period_days, float(flow[1])) for flow in flows] if not (any(a < 0 for _, a in points) and any(a > 0 for _, a in points)): return None @@ -272,17 +273,16 @@ def money_weighted_return(flows, market_value, as_of): ``{"percentage": Decimal, "annualized": bool}`` or None. Over a year or more it is annualized (XIRR). Below a year an annual rate - exaggerates, so it is turned back into the return over the period held.""" + exaggerates, so the rate is solved over the period held instead: annualizing + a few days blows past what xirr can solve (+1.3% in one day is ~10,600% a year).""" if not flows or market_value is None: return None - rate = xirr(flows + [(as_of, market_value)]) + days = (as_of - min(flow[0] for flow in flows)).days + annualized = days >= 365 + rate = xirr(flows + [(as_of, market_value)], 365 if annualized else max(days, 1)) if rate is None: return None - days = (as_of - min(flow[0] for flow in flows)).days - if days >= 365: - return {"percentage": Decimal(str(round(rate * 100, 4))), "annualized": True} - period = (1 + rate) ** (days / 365) - 1 - return {"percentage": Decimal(str(round(period * 100, 4))), "annualized": False} + return {"percentage": Decimal(str(round(rate * 100, 4))), "annualized": annualized} def _month_ends(transactions, as_of): diff --git a/app/apps/investments/tests/test_services.py b/app/apps/investments/tests/test_services.py index 590c9b0d..446228fb 100644 --- a/app/apps/investments/tests/test_services.py +++ b/app/apps/investments/tests/test_services.py @@ -306,6 +306,12 @@ class HoldingPerformanceTests(TestCase): self.assertFalse(mw["annualized"]) self.assertAlmostEqual(float(mw["percentage"]), 5, places=3) + # A day old with a big move: annualized it'd be far past what xirr can solve + day = datetime.date(2025, 1, 2) + mw = money_weighted_return([(start, D(-1000), "in")], D(1050), day) + self.assertFalse(mw["annualized"]) + self.assertAlmostEqual(float(mw["percentage"]), 5, places=3) + # Money added late weighs less: +100 on 1000 held all year beats the # simple +100 / 2000 = 5% when the second 1000 only came in at the end flows = [(start, D(-1000), "in"), (datetime.date(2025, 12, 31), D(-1000), "in")]