diff --git a/backend/src/fintracker/analytics/returns.py b/backend/src/fintracker/analytics/returns.py index e247773..bdc3c8c 100644 --- a/backend/src/fintracker/analytics/returns.py +++ b/backend/src/fintracker/analytics/returns.py @@ -68,6 +68,11 @@ PERIODS: tuple[str, ...] = ("1m", "3m", "6m", "ytd", "1y", "3y", "all") #: Periods reported even when the data is younger than their nominal length. ELASTIC_PERIODS = frozenset({"ytd", "all"}) +EMPTIED_SHARE = Decimal("0.1") +"""A day that withdraws all but this share of the previous value has emptied the portfolio: what +is left is a residue of rounding, fees and dust, and its daily change says nothing about the +market. See `twr`.""" + MIN_XIRR_DAYS = 30 """Below this, annualising a per-instrument return turns a day of noise into a percent a year. A paper bought yesterday gets no XIRR rather than a triple-digit one.""" @@ -178,8 +183,16 @@ def twr(points: Sequence[Point], *, opening_value: Decimal, opening_missing: int One case is still skipped rather than adjusted: a day where the NUMBER of unvalued positions changed. The value then jumps by a whole position that neither gained nor lost - anything, and no flow describes it. `TwrResult.days_skipped` reports how many such days - a period contains, because a TWR with holes has to say so. + anything, and no flow describes it. + + A day that withdraws all but 10 % of what the portfolio was worth (`EMPTIED_SHARE`) is not + chained either: the base left is dust, and the fee, rounding or unrecorded small item that eats + a share of it reads as -60 %. Chained, a few such emptyings over the years multiply the whole + history to -99.9 %. The day is neither used nor skipped — like a zero base, there was + nothing invested to earn a return on — and the chain carries on from the next day. + + `TwrResult.days_skipped` reports how many days of the first kind a period contains, because + a TWR with holes has to say so. """ factor = ONE previous = opening_value @@ -189,6 +202,8 @@ def twr(points: Sequence[Point], *, opening_value: Decimal, opening_missing: int base = previous + point.flow + point.unvalued_flow if point.missing != previous_missing: skipped += 1 + elif (point.flow + point.unvalued_flow) < ZERO and base < previous * EMPTIED_SHARE: + pass elif base > ZERO: factor *= point.value / base used += 1 diff --git a/backend/tests/analytics/test_returns.py b/backend/tests/analytics/test_returns.py index b0a83f2..42a6f24 100644 --- a/backend/tests/analytics/test_returns.py +++ b/backend/tests/analytics/test_returns.py @@ -131,3 +131,26 @@ def test_a_large_but_plausible_rate_is_kept(): rate = xirr([day(0), day(30)], [D("-1000"), D("1100")]) assert rate is not None assert D("2.0") < rate < D("2.3") + + +def test_emptying_the_portfolio_does_not_chain_the_residue(): + """1 302 ₽ out of 1 302 ₽ minus a 3 ₽ residue that then loses 3 ₽ is not a -94 % day.""" + points = [ + Point(d=day(1), value=D("1302.27"), flow=D(0)), + Point(d=day(2), value=D("0.20"), flow=D("-1299.00")), + Point(d=day(3), value=D("5000.20"), flow=D("5000")), + Point(d=day(4), value=D("5500.22"), flow=D(0)), + ] + chain = twr(points, opening_value=D("1300")) + # day 1: +0.17 %; day 2 is a residue, not chained; day 3: flat; day 4: +10 % + assert chain.days_skipped == 0 + assert chain.days_used == 3 + assert chain.value is not None + assert D("0.1019") < chain.value < D("0.1020") + + +def test_a_partial_withdrawal_is_still_chained(): + points = [Point(d=day(1), value=D(55), flow=D(-50))] + chain = twr(points, opening_value=D(100)) + assert chain.days_used == 1 + assert chain.value == D("0.100000")