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:
Dmitry
2026-09-19 10:42:50 +03:00
parent ff3b76871d
commit 15f5812ea4
42 changed files with 10607 additions and 3 deletions
+183
View File
@@ -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
+320
View File
@@ -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
+285
View File
@@ -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)
+144
View File
@@ -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)