Files
Dmitry 15f5812ea4 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).
2026-09-19 10:42:50 +03:00

343 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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() == {}