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

184 lines
6.6 KiB
Python

"""Goal CRUD and progress over HTTP (docs/ai/phase4-contract.md §4).
The router is mounted here rather than taken from `create_app`: wiring it into
`api/app.py` belongs to the phase-4 integration.
"""
from collections.abc import AsyncIterator
from datetime import timedelta
from decimal import Decimal
import pytest
from httpx import ASGITransport, AsyncClient
from factories import make_account, make_event, make_instrument, make_price, refresh
from fintracker.analytics import today_local
from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind
D = Decimal
PREFIX = "/api/v1"
@pytest.fixture
async def client(app) -> AsyncIterator[AsyncClient]:
from fintracker.api.routers import goals
app.include_router(goals.router, prefix=PREFIX)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
@pytest.fixture
async def portfolio(app) -> None:
"""A year of history ending 50 % up: 1000 shares bought at 100, now worth 150."""
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:
await make_price(d, instrument_id=share, close="100" if d < t else "150")
d += timedelta(days=1)
await refresh()
async def create(client, auth_headers, **body):
payload = {"name": "Капитал", "scope": "all", "target_amount": "1000000"} | body
return await client.post(f"{PREFIX}/goals", json=payload, headers=auth_headers)
# --------------------------------------------------------------------------- CRUD
async def test_a_goal_round_trips(client, auth_headers, portfolio):
r = await create(client, auth_headers, target_date="2030-01-01", monthly_contribution="30000")
assert r.status_code == 201, r.text
body = r.json()
assert body["name"] == "Капитал"
assert Decimal(body["target_amount"]) == 1000000
assert body["target_date"] == "2030-01-01"
assert Decimal(body["monthly_contribution"]) == 30000
assert body["archived"] is False
listing = await client.get(f"{PREFIX}/goals", headers=auth_headers)
assert [g["id"] for g in listing.json()] == [body["id"]]
async def test_a_duplicate_name_is_a_conflict(client, auth_headers, portfolio):
await create(client, auth_headers)
r = await create(client, auth_headers)
assert r.status_code == 409
async def test_a_scope_the_metrics_never_built_is_refused(client, auth_headers, portfolio):
r = await create(client, auth_headers, scope="portfolio:999")
assert r.status_code == 404
async def test_patch_changes_only_what_is_sent(client, auth_headers, portfolio):
created = (await create(client, auth_headers, monthly_contribution="1000")).json()
r = await client.patch(
f"{PREFIX}/goals/{created['id']}",
json={"target_amount": "500000"},
headers=auth_headers,
)
assert r.status_code == 200
body = r.json()
assert Decimal(body["target_amount"]) == 500000
assert Decimal(body["monthly_contribution"]) == 1000
async def test_archived_goals_are_hidden_unless_asked_for(client, auth_headers, portfolio):
created = (await create(client, auth_headers)).json()
await client.patch(
f"{PREFIX}/goals/{created['id']}", json={"archived": True}, headers=auth_headers
)
assert (await client.get(f"{PREFIX}/goals", headers=auth_headers)).json() == []
shown = await client.get(
f"{PREFIX}/goals", params={"include_archived": "true"}, headers=auth_headers
)
assert [g["id"] for g in shown.json()] == [created["id"]]
async def test_a_goal_can_be_deleted(client, auth_headers, portfolio):
created = (await create(client, auth_headers)).json()
r = await client.delete(f"{PREFIX}/goals/{created['id']}", headers=auth_headers)
assert r.status_code == 204
assert (await client.get(f"{PREFIX}/goals", headers=auth_headers)).json() == []
assert (
await client.delete(f"{PREFIX}/goals/{created['id']}", headers=auth_headers)
).status_code == 404
async def test_goals_need_a_token(client, portfolio):
assert (await client.get(f"{PREFIX}/goals")).status_code == 401
# --------------------------------------------------------------------------- progress
async def test_progress_is_computed_from_the_live_metrics(client, auth_headers, portfolio):
created = (await create(client, auth_headers, target_amount="300000")).json()
r = await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
assert r.status_code == 200, r.text
body = r.json()
assert Decimal(body["current_value_rub"]) == 150000
assert Decimal(body["target_amount_rub"]) == 300000
assert Decimal(body["progress"]) == Decimal("0.5")
assert body["basis"] == "xirr"
assert body["projected_date"] is not None
assert body["projected_date"] > str(today_local())
assert body["monthly_needed_rub"] is None
assert body["on_track"] is None
async def test_a_deadline_produces_a_monthly_need_and_an_on_track_flag(
client, auth_headers, portfolio
):
created = (
await create(
client,
auth_headers,
target_amount="10000000",
target_date=str(today_local() + timedelta(days=365)),
)
).json()
body = (
await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
).json()
assert Decimal(body["monthly_needed_rub"]) > 0
assert body["on_track"] is False
async def test_every_money_field_is_a_string(client, auth_headers, portfolio):
created = (await create(client, auth_headers)).json()
body = (
await client.get(f"{PREFIX}/goals/{created['id']}/progress", headers=auth_headers)
).json()
for key in ("current_value_rub", "target_amount_rub", "progress"):
assert isinstance(body[key], str)
for key in ("assumed_rate", "monthly_needed_rub"):
assert body[key] is None or isinstance(body[key], str)
async def test_progress_of_an_unknown_goal_is_a_404(client, auth_headers, portfolio):
r = await client.get(f"{PREFIX}/goals/999/progress", headers=auth_headers)
assert r.status_code == 404