Второй источник выплат: 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).
184 lines
6.6 KiB
Python
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
|