"""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)