fix(analytics): TWR не цепляет остаток после полного вывода из портфеля
День, выводящий всё, кроме 1 % стоимости (EMPTIED_SHARE), не участвует в цепочке: остаток в пару рублей превращал комиссию или округление в −60 % за день, и несколько таких опустошений обнуляли всю историю.
This commit is contained in:
@@ -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.
|
#: Periods reported even when the data is younger than their nominal length.
|
||||||
ELASTIC_PERIODS = frozenset({"ytd", "all"})
|
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
|
MIN_XIRR_DAYS = 30
|
||||||
"""Below this, annualising a per-instrument return turns a day of noise into a percent a
|
"""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."""
|
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
|
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
|
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
|
anything, and no flow describes it.
|
||||||
a period contains, because a TWR with holes has to say so.
|
|
||||||
|
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
|
factor = ONE
|
||||||
previous = opening_value
|
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
|
base = previous + point.flow + point.unvalued_flow
|
||||||
if point.missing != previous_missing:
|
if point.missing != previous_missing:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
elif (point.flow + point.unvalued_flow) < ZERO and base < previous * EMPTIED_SHARE:
|
||||||
|
pass
|
||||||
elif base > ZERO:
|
elif base > ZERO:
|
||||||
factor *= point.value / base
|
factor *= point.value / base
|
||||||
used += 1
|
used += 1
|
||||||
|
|||||||
@@ -131,3 +131,26 @@ def test_a_large_but_plausible_rate_is_kept():
|
|||||||
rate = xirr([day(0), day(30)], [D("-1000"), D("1100")])
|
rate = xirr([day(0), day(30)], [D("-1000"), D("1100")])
|
||||||
assert rate is not None
|
assert rate is not None
|
||||||
assert D("2.0") < rate < D("2.3")
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user