feat(accounts): выключатель счёта, ручное создание брокерских счетов, брокеры в капитале
Отключённый счёт выпадает из всех скоупов, капитала и подсказок импорта, события остаются в леджере. Брокерский счёт без баланса попадает в net worth по оценке из леджера. POST /accounts заводит счёт под импорт отчёта, AccountOut отдаёт value_rub. Сверка со снапшотом не считает расхождением позиции счетов, у которых снапшота нет (отчёты Сбера и ВТБ).
This commit is contained in:
@@ -198,3 +198,69 @@ async def test_missing_fx_days_are_named_and_total_stays_computed(app):
|
||||
assert found[0].severity == "warn"
|
||||
assert found[0].count == 3 # every day of the series is affected
|
||||
assert found[0].ref == {"currencies": ["XBT"], "days": 3}
|
||||
|
||||
|
||||
async def _broker(name: str, amount: str = "100000", **kwargs) -> int:
|
||||
from factories import make_event
|
||||
from fintracker.models import AccountKind, EventKind
|
||||
|
||||
account = await make_account(
|
||||
name=name, kind=AccountKind.broker, role=AccountRole.investment, balance=None, **kwargs
|
||||
)
|
||||
await make_event(
|
||||
today_local() - timedelta(days=3), account_id=account, kind=EventKind.deposit, amount=amount
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
async def test_broker_accounts_count_through_their_ledger_valuation(app):
|
||||
t = today_local()
|
||||
await make_account(name="Карта", balance="5000")
|
||||
await make_txn(t - timedelta(days=5), outcome="100")
|
||||
await _broker("Брокер 1", "100000")
|
||||
await _broker("Брокер 2", "50000")
|
||||
await refresh()
|
||||
|
||||
rows = await series()
|
||||
assert rows[t].investment_rub == Decimal("150000")
|
||||
assert rows[t].liquid_rub == Decimal("5000")
|
||||
assert rows[t].total_rub == Decimal("155000")
|
||||
assert rows[t].by_currency["RUB"].startswith("155000")
|
||||
# before the deposits the brokers were worth nothing: only the card is left
|
||||
assert rows[t - timedelta(days=4)].investment_rub == Decimal("0")
|
||||
assert rows[t - timedelta(days=4)].total_rub == Decimal("5000")
|
||||
|
||||
|
||||
async def test_broker_switches_keep_an_account_out_of_net_worth(app):
|
||||
t = today_local()
|
||||
await make_account(name="Карта", balance="1000")
|
||||
await make_txn(t - timedelta(days=5), outcome="10")
|
||||
await _broker("Считается", "10000")
|
||||
await _broker("Не в капитале", "20000", include_in_net_worth=False)
|
||||
await _broker("Отключён", "40000", disabled=True)
|
||||
mirrored = await _broker("Оригинал", "80000")
|
||||
await make_account(name="Зеркало", balance="80000", mirror_of_account_id=mirrored)
|
||||
await refresh()
|
||||
|
||||
row = (await series())[t]
|
||||
# ledger side: «Считается» + «Оригинал»; the mirror and the two switched-off ones stay out
|
||||
assert row.investment_rub == Decimal("90000")
|
||||
assert row.total_rub == Decimal("91000")
|
||||
|
||||
|
||||
async def test_a_broker_account_without_balance_is_not_a_finding(app):
|
||||
await make_account(name="Карта", balance=None)
|
||||
await _broker("Брокер")
|
||||
await refresh()
|
||||
|
||||
named = [f.detail for f in await findings("account_without_balance")]
|
||||
assert len(named) == 1
|
||||
assert "Карта" in named[0]
|
||||
|
||||
|
||||
async def test_net_worth_of_brokers_alone_is_reported(app):
|
||||
await _broker("Единственный", "70000")
|
||||
await refresh()
|
||||
|
||||
rows = await series()
|
||||
assert rows[today_local()].total_rub == Decimal("70000")
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Derived positions against the broker's snapshot — and only where a snapshot exists."""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from factories import make_account, make_event, make_instrument, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import (
|
||||
AccountKind,
|
||||
AccountRole,
|
||||
CashSnapshot,
|
||||
EventKind,
|
||||
MetricDataQuality,
|
||||
PositionSnapshot,
|
||||
)
|
||||
|
||||
D = Decimal
|
||||
AS_OF = datetime(2026, 9, 19, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
async def broker(name: str, *, source: str = "tinvest") -> int:
|
||||
return await make_account(
|
||||
name=name,
|
||||
kind=AccountKind.broker,
|
||||
role=AccountRole.investment,
|
||||
balance=None,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
async def buy(account: int, instrument: int, qty: int) -> None:
|
||||
await make_event(
|
||||
today_local() - timedelta(days=5),
|
||||
account_id=account,
|
||||
kind=EventKind.buy,
|
||||
instrument_id=instrument,
|
||||
quantity=qty,
|
||||
price=100,
|
||||
amount=-100 * qty,
|
||||
)
|
||||
|
||||
|
||||
async def snapshot(account: int, instrument: int | None, qty: str = "0", cash: str = "0") -> None:
|
||||
async with get_sessionmaker()() as session:
|
||||
if instrument is not None:
|
||||
session.add(
|
||||
PositionSnapshot(
|
||||
account_id=account, instrument_id=instrument, as_of=AS_OF, source="tinvest",
|
||||
qty=D(qty), currency="RUB",
|
||||
)
|
||||
) # fmt: skip
|
||||
session.add(
|
||||
CashSnapshot(
|
||||
account_id=account, currency="RUB", as_of=AS_OF, source="tinvest", balance=D(cash)
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def flagged() -> set[int] | None:
|
||||
async with get_sessionmaker()() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
select(MetricDataQuality).where(
|
||||
MetricDataQuality.check_name == "position_vs_snapshot"
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None or row.ref is None:
|
||||
return None
|
||||
return set(row.ref["instruments"])
|
||||
|
||||
|
||||
async def test_matching_snapshot_is_no_finding(app):
|
||||
account = await broker("Т")
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
await buy(account, sber, 10)
|
||||
await snapshot(account, sber, "10")
|
||||
await refresh()
|
||||
|
||||
assert await flagged() is None
|
||||
|
||||
|
||||
async def test_a_position_the_broker_does_not_list_is_flagged(app):
|
||||
account = await broker("Т")
|
||||
sber, gazp = await make_instrument(ticker="SBER"), await make_instrument(ticker="GAZP")
|
||||
await buy(account, sber, 10)
|
||||
await buy(account, gazp, 5)
|
||||
await snapshot(account, sber, "10") # the broker lists SBER only
|
||||
await refresh()
|
||||
|
||||
assert await flagged() == {gazp}
|
||||
|
||||
|
||||
async def test_a_snapshot_that_lists_nothing_still_catches_a_leftover_position(app):
|
||||
"""Everything sold at the broker leaves a cash snapshot and no positions: the ledger
|
||||
still holding a paper is exactly what the check is for."""
|
||||
account = await broker("Т")
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
await buy(account, sber, 10)
|
||||
await snapshot(account, None, cash="500")
|
||||
await refresh()
|
||||
|
||||
assert await flagged() == {sber}
|
||||
|
||||
|
||||
async def test_an_account_fed_by_a_report_has_no_snapshot_and_is_not_flagged(app):
|
||||
"""Sber and VTB send no snapshot; their positions are reconciled against the report."""
|
||||
tinvest = await broker("Т")
|
||||
sber_account = await broker("Сбер", source="report_sber")
|
||||
sber, mtss = await make_instrument(ticker="SBER"), await make_instrument(ticker="MTSS")
|
||||
await buy(tinvest, sber, 10)
|
||||
await snapshot(tinvest, sber, "10")
|
||||
await buy(sber_account, sber, 20)
|
||||
await buy(sber_account, mtss, 3)
|
||||
await refresh()
|
||||
|
||||
assert await flagged() is None
|
||||
@@ -100,3 +100,144 @@ async def test_categories_are_flat_with_parent_ids(client, auth_headers):
|
||||
by_name = {c["name"]: c for c in r.json()}
|
||||
assert by_name["Еда"]["parent_id"] is None
|
||||
assert by_name["Продукты"]["parent_id"] == food
|
||||
|
||||
|
||||
async def test_create_broker_account_for_report_imports(client, auth_headers):
|
||||
r = await client.post(
|
||||
"/api/v1/accounts",
|
||||
json={"name": " ИИС-Сбер ", "broker": "sber", "source_id": " 1234567 "},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
row = r.json()
|
||||
assert row["name"] == "ИИС-Сбер"
|
||||
assert row["kind"] == "broker"
|
||||
assert row["source"] == "report_sber"
|
||||
assert row["source_id"] == "1234567"
|
||||
assert row["broker"] == "sber"
|
||||
assert row["currency"] == "RUB"
|
||||
assert row["role"] == "investment"
|
||||
assert row["primary_event_source"] == "report_sber"
|
||||
assert row["include_in_net_worth"] is True
|
||||
|
||||
listed = (await client.get("/api/v1/accounts", headers=auth_headers)).json()
|
||||
assert [a["id"] for a in listed] == [row["id"]]
|
||||
|
||||
|
||||
async def test_create_account_for_another_broker_and_currency(client, auth_headers):
|
||||
r = await client.post(
|
||||
"/api/v1/accounts",
|
||||
json={"name": "ВТБ", "broker": "vtb", "source_id": "42", "currency": "usd"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert (r.json()["source"], r.json()["currency"]) == ("report_vtb", "USD")
|
||||
|
||||
r = await client.post(
|
||||
"/api/v1/accounts",
|
||||
json={"name": "Прочий", "broker": "other", "source_id": "7"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.json()["source"] == "csv"
|
||||
assert r.json()["primary_event_source"] is None
|
||||
|
||||
|
||||
async def test_create_account_rejects_tinvest_blanks_and_duplicates(client, auth_headers):
|
||||
url = "/api/v1/accounts"
|
||||
body = {"name": "ИИС", "broker": "sber", "source_id": "1"}
|
||||
|
||||
r = await client.post(url, json={**body, "broker": "tinvest"}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
r = await client.post(url, json={**body, "name": " "}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
r = await client.post(url, json={**body, "source_id": " "}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
assert (await client.post(url, json=body, headers=auth_headers)).status_code == 201
|
||||
r = await client.post(url, json={**body, "name": "Другое имя"}, headers=auth_headers)
|
||||
assert r.status_code == 409
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
assert "ИИС" in r.json()["detail"]
|
||||
|
||||
# the same agreement number at another broker is a different account
|
||||
r = await client.post(url, json={**body, "broker": "vtb"}, headers=auth_headers)
|
||||
assert r.status_code == 201
|
||||
|
||||
|
||||
async def test_disabled_flag_is_a_user_switch_that_defaults_off(client, auth_headers):
|
||||
a = await make_account(name="Карта")
|
||||
assert (await client.get("/api/v1/accounts", headers=auth_headers)).json()[0][
|
||||
"disabled"
|
||||
] is False
|
||||
|
||||
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": True}, headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["disabled"] is True
|
||||
|
||||
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": None}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
r = await client.patch(f"/api/v1/accounts/{a}", json={"disabled": False}, headers=auth_headers)
|
||||
assert r.json()["disabled"] is False
|
||||
|
||||
|
||||
async def test_a_disabled_account_leaves_scopes_and_returns_when_switched_on(client, auth_headers):
|
||||
from datetime import date
|
||||
|
||||
from factories import make_event, refresh
|
||||
from fintracker.models import AccountKind, AccountRole, EventKind
|
||||
|
||||
kept = await make_account(
|
||||
name="Оставить", kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
dropped = await make_account(
|
||||
name="Отключить", kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
for account in (kept, dropped):
|
||||
await make_event(date(2026, 1, 5), account_id=account, kind=EventKind.deposit, amount=1000)
|
||||
await refresh()
|
||||
|
||||
async def scopes() -> set[str]:
|
||||
r = await client.get("/api/v1/analytics/scopes", headers=auth_headers)
|
||||
return {s["scope"] for s in r.json()}
|
||||
|
||||
assert {f"account:{kept}", f"account:{dropped}"} <= await scopes()
|
||||
|
||||
await client.patch(f"/api/v1/accounts/{dropped}", json={"disabled": True}, headers=auth_headers)
|
||||
await refresh()
|
||||
now = await scopes()
|
||||
assert f"account:{kept}" in now
|
||||
assert f"account:{dropped}" not in now
|
||||
|
||||
(row,) = [
|
||||
a
|
||||
for a in (await client.get("/api/v1/accounts", headers=auth_headers)).json()
|
||||
if a["id"] == dropped
|
||||
]
|
||||
assert row["disabled"] is True # still listed, so it can be switched back on
|
||||
assert row["value_rub"] is None # no scope, so no valuation any more
|
||||
|
||||
await client.patch(
|
||||
f"/api/v1/accounts/{dropped}", json={"disabled": False}, headers=auth_headers
|
||||
)
|
||||
await refresh()
|
||||
assert f"account:{dropped}" in await scopes()
|
||||
|
||||
|
||||
async def test_broker_account_shows_its_ledger_valuation(client, auth_headers):
|
||||
from datetime import date
|
||||
|
||||
from factories import make_event, refresh
|
||||
from fintracker.models import AccountKind, AccountRole, EventKind
|
||||
|
||||
broker = await make_account(
|
||||
name="Брокер", kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
card = await make_account(name="Карта", balance="500")
|
||||
await make_event(date(2026, 1, 5), account_id=broker, kind=EventKind.deposit, amount=1000)
|
||||
await refresh()
|
||||
|
||||
rows = {a["id"]: a for a in (await client.get("/api/v1/accounts", headers=auth_headers)).json()}
|
||||
assert float(rows[broker]["value_rub"]) == 1000.0
|
||||
assert rows[card]["value_rub"] is None
|
||||
|
||||
@@ -71,6 +71,7 @@ async def make_account(
|
||||
balance_as_of: datetime | None = None,
|
||||
include_in_net_worth: bool = True,
|
||||
archived: bool = False,
|
||||
disabled: bool = False,
|
||||
mirror_of_account_id: int | None = None,
|
||||
source: str = "zenmoney",
|
||||
source_id: str | None = None,
|
||||
@@ -87,6 +88,7 @@ async def make_account(
|
||||
balance_as_of=balance_as_of or datetime.now(UTC),
|
||||
include_in_net_worth=include_in_net_worth,
|
||||
archived=archived,
|
||||
disabled=disabled,
|
||||
mirror_of_account_id=mirror_of_account_id,
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user