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:
@@ -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() == {}
|
||||
Reference in New Issue
Block a user