Второй источник выплат: 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).
343 lines
11 KiB
Python
343 lines
11 KiB
Python
"""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() == {}
|