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,144 @@
|
||||
"""`GET /tax` and `GET /tax/lots` — the screen that prices selling before the three-year mark.
|
||||
|
||||
The router is not wired into `api/app.py` by this module's author, so the tests mount it on a
|
||||
copy of the application. That keeps the check honest about the routes' own behaviour while
|
||||
leaving the inclusion order to whoever owns `app.py`.
|
||||
"""
|
||||
|
||||
import json
|
||||
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
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.analytics.tax import rebuild_tax_year
|
||||
from fintracker.api.routers import tax as tax_router
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.ledger.rebuild import rebuild_lots
|
||||
from fintracker.models import AccountKind, AccountRole, AssetClass, EventKind
|
||||
from fintracker.pricing.fx import rebuild_fx_daily
|
||||
|
||||
D = Decimal
|
||||
LDV_DAYS = 3 * 365
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tax_client(app, auth_headers) -> AsyncIterator[AsyncClient]:
|
||||
"""The application plus the tax router, which `app.py` does not include yet."""
|
||||
app.include_router(tax_router.router, prefix="/api/v1")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
c.headers.update(auth_headers)
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def lots(app) -> dict[str, object]:
|
||||
"""Two open lots of the same paper: one past the ЛДВ mark, one still short of it."""
|
||||
t = today_local()
|
||||
old_date = t - timedelta(days=LDV_DAYS + 30)
|
||||
young_date = t - timedelta(days=400)
|
||||
|
||||
account = await make_account(
|
||||
name="ИИС",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
old_share = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
||||
young_share = await make_instrument(ticker="GAZP", name="Газпром", asset_class=AssetClass.share)
|
||||
await make_event(
|
||||
old_date,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=old_share,
|
||||
quantity="10",
|
||||
price="100",
|
||||
amount="-1000",
|
||||
)
|
||||
await make_event(
|
||||
young_date,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=young_share,
|
||||
quantity="20",
|
||||
price="240",
|
||||
amount="-4800",
|
||||
)
|
||||
await make_price(t, instrument_id=old_share, close="150")
|
||||
await make_price(t, instrument_id=young_share, close="275.89")
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_fx_daily(session)
|
||||
await session.commit()
|
||||
await rebuild_lots(session)
|
||||
await session.commit()
|
||||
await rebuild_tax_year(session)
|
||||
await session.commit()
|
||||
return {
|
||||
"account": account,
|
||||
"old": old_share,
|
||||
"young": young_share,
|
||||
"old_date": old_date,
|
||||
"young_date": young_date,
|
||||
"today": t,
|
||||
}
|
||||
|
||||
|
||||
async def test_lots_show_the_days_left_to_the_exemption(tax_client, lots):
|
||||
r = await tax_client.get("/api/v1/tax/lots")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["estimated"] is True
|
||||
assert body["tax_rate"] == "0.13"
|
||||
assert body["disclaimer"]
|
||||
|
||||
by_ticker = {lot["ticker"]: lot for lot in body["lots"]}
|
||||
young = by_ticker["GAZP"]
|
||||
ldv_date = lots["young_date"] + timedelta(days=LDV_DAYS)
|
||||
assert young["ldv_date"] == ldv_date.isoformat()
|
||||
assert young["days_to_ldv"] == (ldv_date - lots["today"]).days
|
||||
assert young["ldv_eligible"] is False
|
||||
# 20 x 275.89 = 5517.80 against a cost of 4800 -> 717.80 unrealised, 13 % of it is 93.31
|
||||
assert young["market_value_rub"] == "5517.80"
|
||||
assert young["cost_rub"] == "4800.00"
|
||||
assert young["unrealized_gain_rub"] == "717.80"
|
||||
assert young["tax_if_sold_now_rub"] == "93.31"
|
||||
|
||||
|
||||
async def test_a_lot_past_three_years_costs_nothing_to_sell(tax_client, lots):
|
||||
body = (await tax_client.get("/api/v1/tax/lots")).json()
|
||||
old = {lot["ticker"]: lot for lot in body["lots"]}["SBER"]
|
||||
|
||||
assert old["ldv_eligible"] is True
|
||||
assert old["days_to_ldv"] == 0
|
||||
# 10 x 150 = 1500 against 1000 is a real gain, and art. 219.1 makes it untaxed
|
||||
assert old["unrealized_gain_rub"] == "500.00"
|
||||
assert old["tax_if_sold_now_rub"] == "0.00"
|
||||
|
||||
|
||||
async def test_the_year_summary_is_marked_an_estimate(tax_client, lots):
|
||||
r = await tax_client.get("/api/v1/tax", params={"year": lots["today"].year})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["estimated"] is True
|
||||
assert body["tax_rate"] == "0.13"
|
||||
assert "брокер" in body["disclaimer"]
|
||||
# nothing was sold, so the year is empty — but it is still a well-formed answer
|
||||
assert body["totals"]["estimated_tax_rub"] == "0.00"
|
||||
assert body["accounts"] == []
|
||||
|
||||
|
||||
async def test_no_float_reaches_the_wire(tax_client, lots):
|
||||
def reject_float(raw: str) -> None:
|
||||
raise AssertionError(f"float in the response: {raw}")
|
||||
|
||||
for path in ("/api/v1/tax", "/api/v1/tax/lots"):
|
||||
r = await tax_client.get(path)
|
||||
assert r.status_code == 200, r.text
|
||||
json.loads(r.text, parse_float=reject_float)
|
||||
Reference in New Issue
Block a user