From 1b521be35e0f0cf2ecf8f62f1c36aaaf7dab062b Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 19 Sep 2026 21:54:36 +0300 Subject: [PATCH] =?UTF-8?q?fix(analytics):=20=D0=BD=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D1=81=D1=82=D0=B8=D1=87=D0=BD=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=B3=D0=BE=D0=B4=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B4=D0=BE=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=87=D1=91=D1=82=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XIRR за несколько дней истории даёт артефакт солвера за ±100 000 000 %, который переполнял NUMERIC(24,10) и ронял весь refresh. Такое значение теперь считается «нет ответа». --- backend/src/fintracker/analytics/returns.py | 9 ++++++++- backend/tests/analytics/test_returns.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/src/fintracker/analytics/returns.py b/backend/src/fintracker/analytics/returns.py index a1d2aa2..e247773 100644 --- a/backend/src/fintracker/analytics/returns.py +++ b/backend/src/fintracker/analytics/returns.py @@ -58,6 +58,10 @@ ZERO = Decimal(0) ONE = Decimal(1) DAYS_IN_YEAR = Decimal(365) RATE_PLACES = Decimal("0.000001") +MAX_ABS_RATE = Decimal(10**6) +"""An annualised rate beyond ±100 000 000 % is a solver artefact (a few days of history, or +flows with several sign changes), not a return. It is reported as «no answer»: stored, it would +also overflow the NUMERIC(24,10) column and take the whole metrics refresh down with it.""" PERIODS: tuple[str, ...] = ("1m", "3m", "6m", "ytd", "1y", "3y", "all") @@ -205,7 +209,10 @@ def annualize(cumulative: Decimal | None, days: int) -> Decimal | None: def _as_rate(value: float) -> Decimal | None: try: - return _quantize(Decimal(repr(value))) + rate = Decimal(repr(value)) + if not rate.is_finite() or abs(rate) > MAX_ABS_RATE: + return None + return _quantize(rate) except (InvalidOperation, ValueError): return None diff --git a/backend/tests/analytics/test_returns.py b/backend/tests/analytics/test_returns.py index 0e4fea2..b0a83f2 100644 --- a/backend/tests/analytics/test_returns.py +++ b/backend/tests/analytics/test_returns.py @@ -119,3 +119,15 @@ def test_period_start(period: str, expected: date | None): def test_months_back_clamps_to_a_shorter_month(): assert months_back(date(2026, 3, 31), 1) == date(2026, 2, 28) assert months_back(date(2026, 1, 15), 13) == date(2024, 12, 15) + + +def test_a_rate_no_column_could_hold_is_no_answer(): + # doubling in one day annualises to 2**365 - 1: arithmetically right, and not a return + assert xirr([day(0), day(1)], [D("-1000"), D("2000")]) is None + + +def test_a_large_but_plausible_rate_is_kept(): + # +10 % in a month is roughly +214 % a year + rate = xirr([day(0), day(30)], [D("-1000"), D("1100")]) + assert rate is not None + assert D("2.0") < rate < D("2.3")