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,183 @@
|
||||
"""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
|
||||
@@ -0,0 +1,320 @@
|
||||
"""`/income` over a small portfolio: one paid dividend, one announced, one bond coupon.
|
||||
|
||||
The router is not wired into `create_app` yet (that is done separately), so the fixture mounts
|
||||
it on the same application the rest of the API tests use.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from factories import make_account, make_event, make_instrument, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.analytics.income import add_months, rebuild_income
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
BondNominalSchedule,
|
||||
CorporateAction,
|
||||
CorporateActionKind,
|
||||
CorporateActionStatus,
|
||||
Event,
|
||||
EventKind,
|
||||
Instrument,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
PREFIX = "/api/v1/income"
|
||||
|
||||
|
||||
async def make_bond(*, ticker: str, maturity: date | None = None) -> int:
|
||||
async with get_sessionmaker()() as session:
|
||||
bond = Instrument(
|
||||
asset_class=AssetClass.bond,
|
||||
ticker=ticker,
|
||||
board="TQOB",
|
||||
name=ticker,
|
||||
currency="RUB",
|
||||
nominal=D(1000),
|
||||
nominal_currency="RUB",
|
||||
maturity_date=maturity,
|
||||
)
|
||||
session.add(bond)
|
||||
await session.commit()
|
||||
await session.refresh(bond)
|
||||
return bond.id
|
||||
|
||||
|
||||
async def make_payout(
|
||||
d: date,
|
||||
*,
|
||||
account_id: int,
|
||||
instrument_id: int,
|
||||
amount: str,
|
||||
kind: EventKind = EventKind.dividend,
|
||||
tax: str | None = None,
|
||||
) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
Event(
|
||||
account_id=account_id,
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
ts=datetime.combine(d, datetime.min.time(), tzinfo=UTC),
|
||||
trade_date=d,
|
||||
amount=D(amount),
|
||||
currency="RUB",
|
||||
tax=D(tax) if tax is not None else None,
|
||||
tax_currency="RUB" if tax is not None else None,
|
||||
source="tinvest",
|
||||
source_id=f"pay-{instrument_id}-{d}-{amount}",
|
||||
dedupe_key=f"tinvest:pay-{instrument_id}-{d}-{amount}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_action(
|
||||
*,
|
||||
instrument_id: int,
|
||||
kind: CorporateActionKind,
|
||||
status: CorporateActionStatus,
|
||||
pay_date: date,
|
||||
record_date: date | None = None,
|
||||
amount_per_unit: str,
|
||||
) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
CorporateAction(
|
||||
instrument_id=instrument_id,
|
||||
kind=kind,
|
||||
status=status,
|
||||
pay_date=pay_date,
|
||||
record_date=record_date,
|
||||
amount_per_unit=D(amount_per_unit),
|
||||
currency="RUB",
|
||||
source="moex",
|
||||
source_id=f"{kind}-{pay_date}",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def make_nominal(instrument_id: int, effective: date, nominal: str) -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
session.add(
|
||||
BondNominalSchedule(
|
||||
instrument_id=instrument_id,
|
||||
effective_date=effective,
|
||||
nominal=D(nominal),
|
||||
currency="RUB",
|
||||
source="moex",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def rebuild() -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
await rebuild_income(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def income_client(app, user) -> AsyncIterator[AsyncClient]:
|
||||
from fintracker.api.routers import income
|
||||
|
||||
app.include_router(income.router, prefix="/api/v1")
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
r = await c.post("/api/v1/auth/login", json=user)
|
||||
assert r.status_code == 200, r.text
|
||||
c.headers["Authorization"] = f"Bearer {r.json()['access_token']}"
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> dict[str, int]:
|
||||
"""A share that paid twice and has a declared payout, and an amortising bond."""
|
||||
today = today_local()
|
||||
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="Сбербанк России")
|
||||
bond = await make_bond(ticker="RU000API", maturity=add_months(today, 30))
|
||||
|
||||
await make_event(
|
||||
add_months(today, -20),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=share,
|
||||
quantity="20",
|
||||
price="250",
|
||||
amount="-5000",
|
||||
)
|
||||
await make_event(
|
||||
add_months(today, -20),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=bond,
|
||||
quantity="10",
|
||||
price="1000",
|
||||
amount="-10000",
|
||||
)
|
||||
await make_payout(
|
||||
add_months(today, -13), account_id=account, instrument_id=share, amount="696.80", tax="104"
|
||||
)
|
||||
await make_payout(
|
||||
add_months(today, -1), account_id=account, instrument_id=share, amount="696.80", tax="104"
|
||||
)
|
||||
await make_action(
|
||||
instrument_id=share,
|
||||
kind=CorporateActionKind.dividend,
|
||||
status=CorporateActionStatus.announced,
|
||||
record_date=today + timedelta(days=21),
|
||||
pay_date=today + timedelta(days=24),
|
||||
amount_per_unit="34.84",
|
||||
)
|
||||
await make_nominal(bond, add_months(today, -24), "1000")
|
||||
await make_action(
|
||||
instrument_id=bond,
|
||||
kind=CorporateActionKind.coupon,
|
||||
status=CorporateActionStatus.announced,
|
||||
pay_date=add_months(today, 2),
|
||||
amount_per_unit="40",
|
||||
)
|
||||
await refresh()
|
||||
await rebuild()
|
||||
return {"account": account, "share": share, "bond": bond}
|
||||
|
||||
|
||||
def floats(value: Any, path: str = "$") -> list[str]:
|
||||
"""Every place a float leaked into the payload — money must travel as a string."""
|
||||
if isinstance(value, bool):
|
||||
return []
|
||||
if isinstance(value, float):
|
||||
return [path]
|
||||
if isinstance(value, dict):
|
||||
return [p for k, v in value.items() for p in floats(v, f"{path}.{k}")]
|
||||
if isinstance(value, list):
|
||||
return [p for i, v in enumerate(value) for p in floats(v, f"{path}[{i}]")]
|
||||
return []
|
||||
|
||||
|
||||
async def test_calendar_shows_the_future_with_money_as_strings_and_a_basis_on_every_row(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
r = await income_client.get(f"{PREFIX}/calendar")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert floats(body) == []
|
||||
|
||||
assert body["currency"] == "RUB"
|
||||
assert body["entries"], body
|
||||
for entry in body["entries"]:
|
||||
assert isinstance(entry["amount"], str)
|
||||
assert isinstance(entry["qty"], str)
|
||||
assert entry["basis"] in {"schedule", "announced", "history"}
|
||||
|
||||
by_key = {(e["kind"], e["basis"]): e for e in body["entries"]}
|
||||
# the declared autumn dividend, and next year's payment that only history knows about
|
||||
announced = by_key[("dividend", "announced")]
|
||||
assert announced["amount"] == "696.8000000000"
|
||||
assert announced["ticker"] == "SBER"
|
||||
assert announced["per_unit"] == "34.8400000000"
|
||||
assert ("dividend", "history") in by_key
|
||||
assert by_key[("coupon", "schedule")]["amount"] == "400.0000000000"
|
||||
|
||||
assert set(body["by_basis"]) == {"announced", "history", "schedule"}
|
||||
total = sum(D(v) for v in body["by_basis"].values())
|
||||
assert D(body["total_expected_rub"]) == total
|
||||
|
||||
|
||||
async def test_include_paid_adds_the_history_rows_and_nothing_else(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
today = today_local()
|
||||
window = {"date_from": str(add_months(today, -24)), "date_to": str(add_months(today, 12))}
|
||||
|
||||
without = (await income_client.get(f"{PREFIX}/calendar", params=window)).json()
|
||||
assert {e["basis"] for e in without["entries"]} == {"announced", "history", "schedule"}
|
||||
|
||||
with_paid = (
|
||||
await income_client.get(f"{PREFIX}/calendar", params={**window, "include_paid": "true"})
|
||||
).json()
|
||||
paid = [e for e in with_paid["entries"] if e["basis"] == "paid"]
|
||||
assert len(paid) == 2
|
||||
assert paid[0]["tax_withheld"] == "104.0000000000"
|
||||
# a payment already received is not an expectation: the totals must not move
|
||||
assert with_paid["total_expected_rub"] == without["total_expected_rub"]
|
||||
assert "paid" not in with_paid["by_basis"]
|
||||
|
||||
|
||||
async def test_history_groups_by_month_and_totals_the_tax(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
r = await income_client.get(f"{PREFIX}/history")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert floats(body) == []
|
||||
assert [row["kind"] for row in body["rows"]] == ["dividend", "dividend"]
|
||||
assert {row["payment_count"] for row in body["rows"]} == {1}
|
||||
assert D(body["totals"]["amount_rub"]) == D("1393.60")
|
||||
assert D(body["totals"]["tax_withheld_rub"]) == D("208")
|
||||
|
||||
filtered = (await income_client.get(f"{PREFIX}/history", params={"kind": "coupon"})).json()
|
||||
assert filtered["rows"] == []
|
||||
|
||||
|
||||
async def test_forecast_splits_every_month_by_basis(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
r = await income_client.get(f"{PREFIX}/forecast", params={"months": 12})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert floats(body) == []
|
||||
assert body["months"], body
|
||||
|
||||
bases = {basis for month in body["months"] for basis in month["by_basis"]}
|
||||
assert bases <= {"schedule", "announced", "history"}
|
||||
for month in body["months"]:
|
||||
assert D(month["amount_rub"]) == sum(D(v) for v in month["by_basis"].values())
|
||||
assert D(body["total_rub"]) == sum(D(m["amount_rub"]) for m in body["months"])
|
||||
assert isinstance(body["warnings"], list)
|
||||
|
||||
|
||||
async def test_forecast_rejects_a_horizon_the_table_was_not_built_for(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 0})).status_code == 422
|
||||
assert (await income_client.get(f"{PREFIX}/forecast", params={"months": 37})).status_code == 422
|
||||
|
||||
|
||||
async def test_an_unknown_scope_is_a_404_not_an_empty_calendar(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
r = await income_client.get(f"{PREFIX}/calendar", params={"scope": "account:999"})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_the_endpoints_require_a_token(income_client: AsyncClient, app):
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as anon:
|
||||
assert (await anon.get(f"{PREFIX}/calendar")).status_code == 401
|
||||
|
||||
|
||||
async def test_the_endpoints_are_pure_reads_and_repeat_themselves(
|
||||
income_client: AsyncClient, portfolio: dict[str, int]
|
||||
):
|
||||
first = (await income_client.get(f"{PREFIX}/forecast")).json()
|
||||
second = (await income_client.get(f"{PREFIX}/forecast")).json()
|
||||
assert first == second
|
||||
@@ -0,0 +1,285 @@
|
||||
"""Target weights and rebalancing over HTTP (docs/ai/phase4-contract.md §2).
|
||||
|
||||
The router is mounted here rather than taken from `create_app`: wiring it into
|
||||
`api/app.py` belongs to the phase-4 integration, and these tests should not wait on it.
|
||||
"""
|
||||
|
||||
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.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
AssetClass,
|
||||
EventKind,
|
||||
Instrument,
|
||||
Portfolio,
|
||||
PortfolioAccount,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
PREFIX = "/api/v1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app) -> AsyncIterator[AsyncClient]:
|
||||
from fintracker.api.routers import rebalance
|
||||
|
||||
app.include_router(rebalance.router, prefix=PREFIX)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def portfolio(app) -> dict[str, int]:
|
||||
"""500 SBER (lot 10) at 100, 20 ОФЗ at 1000, 30 000 ₽ left in cash."""
|
||||
t = today_local()
|
||||
bought = t - timedelta(days=40)
|
||||
account = await make_account(
|
||||
name="Брокерский",
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
include_in_net_worth=False,
|
||||
source="tinvest",
|
||||
)
|
||||
sber = await make_instrument(ticker="SBER", name="Сбербанк", asset_class=AssetClass.share)
|
||||
ofz = await make_instrument(ticker="OFZ", name="ОФЗ", asset_class=AssetClass.bond)
|
||||
|
||||
async with get_sessionmaker()() as session:
|
||||
instrument = await session.get(Instrument, sber)
|
||||
assert instrument is not None
|
||||
instrument.lot = 10
|
||||
portfolio = Portfolio(name="Основной")
|
||||
session.add(portfolio)
|
||||
await session.flush()
|
||||
session.add(PortfolioAccount(portfolio_id=portfolio.id, account_id=account))
|
||||
portfolio_id = portfolio.id
|
||||
await session.commit()
|
||||
|
||||
await make_event(bought, account_id=account, kind=EventKind.deposit, amount="100000")
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=sber,
|
||||
quantity="500",
|
||||
price="100",
|
||||
amount="-50000",
|
||||
)
|
||||
await make_event(
|
||||
bought,
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=ofz,
|
||||
quantity="20",
|
||||
price="1000",
|
||||
amount="-20000",
|
||||
)
|
||||
d = bought
|
||||
while d <= t:
|
||||
await make_price(d, instrument_id=sber, close="100")
|
||||
await make_price(d, instrument_id=ofz, close="1000")
|
||||
d += timedelta(days=1)
|
||||
await refresh()
|
||||
return {"portfolio": portfolio_id, "sber": sber, "ofz": ofz}
|
||||
|
||||
|
||||
def targets(*rows: tuple[str, str, str]) -> dict:
|
||||
return {
|
||||
"dimension": "asset_class",
|
||||
"targets": [{"bucket": b, "target_weight": w, "band": band} for b, w, band in rows],
|
||||
}
|
||||
|
||||
|
||||
async def put(client, auth_headers, portfolio_id: int, body: dict):
|
||||
return await client.put(
|
||||
f"{PREFIX}/portfolios/{portfolio_id}/targets", json=body, headers=auth_headers
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- targets
|
||||
|
||||
|
||||
async def test_targets_round_trip_and_report_their_sum(client, auth_headers, portfolio):
|
||||
r = await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert Decimal(body["weights_sum"]) == 1
|
||||
assert [t["bucket"] for t in body["targets"]] == ["bond", "cash", "share"]
|
||||
assert Decimal(body["targets"][0]["target_weight"]) == Decimal("0.2")
|
||||
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/targets", headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == body
|
||||
|
||||
|
||||
async def test_weights_that_do_not_add_up_are_refused_with_the_actual_sum(
|
||||
client, auth_headers, portfolio
|
||||
):
|
||||
r = await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.3", "0.01")),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
body = r.json()
|
||||
assert "0.9" in body["detail"]
|
||||
assert Decimal(body["weights_sum"]) == Decimal("0.9")
|
||||
|
||||
|
||||
async def test_a_set_is_replaced_whole_not_merged(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
r = await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.7", "0.01"), ("cash", "0.3", "0.01")),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert [t["bucket"] for t in r.json()["targets"]] == ["cash", "share"]
|
||||
|
||||
|
||||
async def test_a_duplicated_bucket_is_refused(client, auth_headers, portfolio):
|
||||
r = await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.5", "0.01"), ("share", "0.5", "0.01")),
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert "share" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_an_unknown_dimension_is_refused(client, auth_headers, portfolio):
|
||||
body = targets(("share", "1", "0.01"))
|
||||
body["dimension"] = "mood"
|
||||
r = await put(client, auth_headers, portfolio["portfolio"], body)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_an_unknown_portfolio_is_a_404(client, auth_headers, portfolio):
|
||||
r = await client.get(f"{PREFIX}/portfolios/999/targets", headers=auth_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_targets_need_a_token(client, portfolio):
|
||||
r = await client.get(f"{PREFIX}/portfolios/{portfolio['portfolio']}/targets")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- suggestions
|
||||
|
||||
|
||||
async def test_the_suggestion_respects_the_lot_and_the_cash(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert Decimal(body["total_value_rub"]) == 100000
|
||||
assert Decimal(body["cash_available_rub"]) == 30000
|
||||
|
||||
share = next(b for b in body["buckets"] if b["bucket"] == "share")
|
||||
assert Decimal(share["current_weight"]) == Decimal("0.5")
|
||||
assert Decimal(share["target_weight"]) == Decimal("0.6")
|
||||
assert Decimal(share["drift"]) == Decimal("-0.1")
|
||||
assert share["within_band"] is False
|
||||
trade = share["trades"][0]
|
||||
assert trade["action"] == "buy"
|
||||
assert trade["lot"] == 10
|
||||
assert Decimal(trade["suggested_qty"]) % 10 == 0
|
||||
assert Decimal(trade["suggested_qty"]) == 100
|
||||
assert trade["blocked_by_cash"] is False
|
||||
|
||||
bond = next(b for b in body["buckets"] if b["bucket"] == "bond")
|
||||
assert bond["within_band"] is True
|
||||
assert bond["trades"] == []
|
||||
|
||||
|
||||
async def test_the_what_if_cash_blocks_the_buy(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance",
|
||||
params={"cash_available": "2500"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
trade = next(t for b in r.json()["buckets"] for t in b["trades"] if b["bucket"] == "share")
|
||||
assert Decimal(trade["suggested_qty"]) == 20
|
||||
assert trade["blocked_by_cash"] is True
|
||||
|
||||
|
||||
async def test_a_wide_band_silences_every_suggestion(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.5"), ("bond", "0.2", "0.5"), ("cash", "0.2", "0.5")),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
|
||||
)
|
||||
body = r.json()
|
||||
assert all(b["within_band"] for b in body["buckets"] if b["target_weight"] is not None)
|
||||
assert all(t["suggested_qty"] is None for b in body["buckets"] for t in b["trades"])
|
||||
|
||||
|
||||
async def test_without_targets_there_is_nothing_to_rebalance(client, auth_headers, portfolio):
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert all(b["target_weight"] is None for b in r.json()["buckets"])
|
||||
|
||||
|
||||
async def test_every_money_field_is_a_string(client, auth_headers, portfolio):
|
||||
await put(
|
||||
client,
|
||||
auth_headers,
|
||||
portfolio["portfolio"],
|
||||
targets(("share", "0.6", "0.01"), ("bond", "0.2", "0.01"), ("cash", "0.2", "0.01")),
|
||||
)
|
||||
r = await client.get(
|
||||
f"{PREFIX}/portfolios/{portfolio['portfolio']}/rebalance", headers=auth_headers
|
||||
)
|
||||
body = r.json()
|
||||
for key in ("total_value_rub", "cash_available_rub"):
|
||||
assert isinstance(body[key], str)
|
||||
for b in body["buckets"]:
|
||||
for key in ("current_value_rub", "current_weight", "delta_value_rub"):
|
||||
assert isinstance(b[key], str)
|
||||
for t in b["trades"]:
|
||||
for key in ("suggested_qty", "price", "amount_rub"):
|
||||
assert t[key] is None or isinstance(t[key], str)
|
||||
@@ -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