feat(analytics): доходы, ребалансировка, налоги, бенчмарки и цели — фаза 4

Второй источник выплат: sources/tinvest/sync_events.py (GetDividends,
GetBondCoupons, GetBondEvents) и sources/moex/payouts.py (ISS bondization +
dividends). Приоритет между ними — pricing/payouts.resolve_payouts, решается
на чтении, а не на записи: corporate_action уникален по (instrument_id, kind,
source, source_id), обе версии сосуществуют, и правило можно поменять без
ресинка истории. Амортизация от MOEX идёт в bond_nominal_schedule, а не
в corporate_action — этим типом безраздельно владеет
ledger/corporate_actions.py.

analytics/income.py — metric_income_monthly (факт) и metric_income_calendar
(прошлое и прогноз) с basis paid/announced/history на каждой строке, три
источника числа не смешиваются. analytics/rebalance.py — сделки по
portfolio_target пропорционально внутри бакета, лоты только вниз, покупки не
занимают у ещё не свершившихся продаж. analytics/tax.py — оценка, не замена
справки брокера: дивиденды/купоны gross, реализованный результат из
lot_disposal с переоценкой каждой ноги на свою дату. analytics/benchmarks.py —
TWR индекса на сетке портфеля, kind (price/total_return) не скрывается.
analytics/goals.py — прогресс цели и нужный взнос по trailing XIRR.

Четыре шага зарегистрированы в register_steps: benchmarks после returns
(общая сетка дат), rebalance после allocation (её веса, не пересчитывает),
income и tax после lots (нужен lot_disposal).
This commit is contained in:
Dmitry
2026-09-19 10:42:50 +03:00
parent ff3b76871d
commit 15f5812ea4
42 changed files with 10607 additions and 3 deletions
+342
View File
@@ -0,0 +1,342 @@
"""Goal progress: the projection rules, then the refresh step end to end."""
from datetime import date, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import select
from factories import make_account, make_event, make_instrument, make_price, refresh
from fintracker.analytics import today_local
from fintracker.analytics.goals import (
MAX_HORIZON_MONTHS,
MIN_XIRR_HISTORY_DAYS,
evaluate,
monthly_needed,
months_between,
pick_rate,
rebuild_goal_progress,
)
from fintracker.db import get_sessionmaker
from fintracker.models import (
AccountKind,
AccountRole,
AssetClass,
EventKind,
Goal,
MetricGoalProgress,
)
D = Decimal
def progress_of(
*,
current: str = "100000",
target: str = "200000",
target_date: date | None = None,
monthly: str | None = None,
xirr: str | None = None,
as_of: date | None = None,
):
return evaluate(
goal_id=1,
as_of=as_of or today_local(),
current=D(current),
target=D(target),
target_date=target_date,
monthly_contribution=None if monthly is None else D(monthly),
trailing_xirr=None if xirr is None else D(xirr),
)
# --------------------------------------------------------------------------- the projection
def test_a_growing_portfolio_gets_a_date_in_the_future():
result = progress_of(current="100000", target="200000", xirr="0.2")
assert result.basis == "xirr"
assert result.projected_date is not None
assert result.projected_date > today_local()
# 20 % a year doubles in a bit under four years
assert result.projected_date < today_local() + timedelta(days=365 * 5)
def test_a_flat_portfolio_with_no_contributions_gets_null_not_a_far_date():
result = progress_of(current="100000", target="200000", xirr="0")
assert result.basis == "xirr"
assert result.projected_date is None
def test_a_falling_portfolio_with_no_contributions_gets_null():
result = progress_of(current="100000", target="200000", xirr="-0.1")
assert result.basis == "xirr"
assert result.assumed_rate == D("-0.100000")
assert result.projected_date is None
def test_a_falling_portfolio_may_still_be_reached_by_contributions():
result = progress_of(current="100000", target="200000", xirr="-0.02", monthly="20000")
assert result.basis == "xirr"
assert result.projected_date is not None
def test_without_a_trailing_return_the_plan_is_the_contributions():
result = progress_of(current="100000", target="200000", monthly="10000")
assert result.basis == "contribution"
assert result.assumed_rate == D("0.000000")
# 100 000 left to raise at 10 000 a month is ten months of deposits
assert result.projected_date == _add(today_local(), 10)
def test_with_neither_a_return_nor_a_contribution_there_is_nothing_to_project():
result = progress_of(current="100000", target="200000")
assert result.basis == "none"
assert result.projected_date is None
assert result.assumed_rate is None
def test_a_goal_already_met_is_projected_to_today():
result = progress_of(current="300000", target="200000", xirr="0.1")
assert result.projected_date == today_local()
assert result.progress == D("1.500000")
def test_the_projection_gives_up_rather_than_naming_a_date_beyond_the_horizon():
# 0.01 % a year against a target ten times away: reachable in theory, not in 30 years
result = progress_of(current="100000", target="1000000", xirr="0.0001")
assert result.projected_date is None
assert MAX_HORIZON_MONTHS == 360
def _add(d: date, months: int) -> date:
from fintracker.analytics.goals import add_months
return add_months(d, months)
# --------------------------------------------------------------------------- monthly needed
def test_monthly_needed_is_null_without_a_deadline():
assert progress_of(monthly="1000").monthly_needed_rub is None
def test_monthly_needed_is_computed_when_there_is_a_deadline():
as_of = date(2026, 1, 1)
result = progress_of(
current="100000", target="220000", target_date=date(2027, 1, 1), as_of=as_of
)
# no growth assumed: 120 000 over 12 months
assert result.monthly_needed_rub == D("10000.00")
def test_monthly_needed_is_zero_when_the_trend_already_gets_there():
as_of = date(2026, 1, 1)
result = progress_of(
current="100000",
target="105000",
target_date=date(2027, 1, 1),
xirr="0.2",
as_of=as_of,
)
assert result.monthly_needed_rub == D(0)
assert result.on_track is True
def test_monthly_needed_is_null_once_the_deadline_has_passed():
as_of = date(2026, 1, 1)
result = progress_of(target_date=date(2025, 1, 1), as_of=as_of, monthly="1000")
assert result.monthly_needed_rub is None
def test_a_deadline_the_trend_misses_is_not_on_track():
as_of = date(2026, 1, 1)
result = progress_of(
current="100000",
target="200000",
target_date=date(2026, 6, 1),
monthly="1000",
as_of=as_of,
)
assert result.on_track is False
def test_monthly_needed_accounts_for_the_assumed_growth():
as_of = date(2026, 1, 1)
flat = progress_of(current="100000", target="220000", target_date=date(2027, 1, 1), as_of=as_of)
growing = progress_of(
current="100000",
target="220000",
target_date=date(2027, 1, 1),
xirr="0.2",
as_of=as_of,
)
assert growing.monthly_needed_rub is not None
assert flat.monthly_needed_rub is not None
assert growing.monthly_needed_rub < flat.monthly_needed_rub
def test_months_between_counts_whole_months_only():
assert months_between(date(2026, 1, 15), date(2027, 1, 14)) == 11
assert months_between(date(2026, 1, 15), date(2027, 1, 15)) == 12
assert months_between(date(2026, 5, 1), date(2026, 1, 1)) == 0
# --------------------------------------------------------------------------- rate choice
def test_a_short_window_is_not_extrapolated_into_a_forecast():
rows = [
{
"period": "1m",
"date_from": date(2026, 8, 18),
"date_to": date(2026, 9, 18),
"xirr": D("3.5"),
}
]
assert pick_rate(rows) == (None, "")
def test_the_shortest_qualifying_window_wins():
rows = [
{
"period": "all",
"date_from": date(2020, 1, 1),
"date_to": date(2026, 9, 18),
"xirr": D("0.05"),
},
{
"period": "1y",
"date_from": date(2025, 9, 18),
"date_to": date(2026, 9, 18),
"xirr": D("0.18"),
},
{
"period": "3m",
"date_from": date(2026, 6, 18),
"date_to": date(2026, 9, 18),
"xirr": D("9"),
},
]
assert pick_rate(rows) == (D("0.18"), "1y")
assert MIN_XIRR_HISTORY_DAYS == 180
def test_a_period_without_an_xirr_is_skipped():
rows = [
{
"period": "1y",
"date_from": date(2025, 9, 18),
"date_to": date(2026, 9, 18),
"xirr": None,
}
]
assert pick_rate(rows) == (None, "")
def test_every_number_in_the_progress_is_a_decimal():
result = progress_of(
current="100000", target="200000", target_date=date(2030, 1, 1), xirr="0.1"
)
for value in (result.current_value_rub, result.target_amount_rub, result.progress):
assert isinstance(value, Decimal)
assert isinstance(result.assumed_rate, Decimal)
assert isinstance(result.monthly_needed_rub, Decimal)
def test_monthly_needed_refuses_a_zero_month_window():
assert monthly_needed(current=D(1), target=D(2), months=0, annual_rate=None) is None
# --------------------------------------------------------------------------- database
async def _portfolio(close_today: str) -> None:
"""A year of history: 100 000 in, 1000 shares at 100, ending at `close_today`."""
t = today_local()
start = t - timedelta(days=365)
account = await make_account(
name="Брокерский",
kind=AccountKind.broker,
role=AccountRole.investment,
balance=None,
include_in_net_worth=False,
source="tinvest",
)
share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
await make_event(start, account_id=account, kind=EventKind.deposit, amount="100000")
await make_event(
start,
account_id=account,
kind=EventKind.buy,
instrument_id=share,
quantity="1000",
price="100",
amount="-100000",
)
d = start
while d <= t:
close = "100" if d < t else close_today
await make_price(d, instrument_id=share, close=close)
d += timedelta(days=1)
await refresh()
async def _goal(**kwargs) -> int:
async with get_sessionmaker()() as session:
goal = Goal(**kwargs)
session.add(goal)
await session.commit()
await session.refresh(goal)
return goal.id
async def _rebuild() -> dict[int, MetricGoalProgress]:
async with get_sessionmaker()() as session:
await rebuild_goal_progress(session)
await session.commit()
rows = (await session.execute(select(MetricGoalProgress))).scalars().all()
return {r.goal_id: r for r in rows}
@pytest.mark.parametrize(
("close_today", "reachable"),
[("150", True), ("100", False), ("60", False)],
)
async def test_the_projection_follows_the_real_trend(app, close_today: str, reachable: bool):
await _portfolio(close_today)
goal_id = await _goal(name="Капитал", scope="all", target_amount=D("1000000"))
rows = await _rebuild()
row = rows[goal_id]
assert row.current_value_rub == D(close_today) * 1000
assert row.progress == (D(close_today) * 1000 / D("1000000")).quantize(D("0.000001"))
assert row.basis == "xirr"
if reachable:
assert row.projected_date is not None and row.projected_date > today_local()
else:
assert row.projected_date is None
async def test_a_deadline_produces_a_monthly_need_and_none_without_one(app):
await _portfolio("100")
dated = await _goal(
name="С датой",
scope="all",
target_amount=D("400000"),
target_date=today_local() + timedelta(days=365),
)
undated = await _goal(name="Без даты", scope="all", target_amount=D("400000"))
rows = await _rebuild()
needed = rows[dated].monthly_needed_rub
assert needed is not None
assert needed > 0
assert rows[undated].monthly_needed_rub is None
async def test_an_archived_goal_is_not_computed(app):
await _portfolio("100")
await _goal(name="Старое", scope="all", target_amount=D("1000"), archived=True)
assert await _rebuild() == {}