feat(api): портфели, ручные события, правка инструментов и обзор по скоупам
CRUD портфелей (/portfolios), ручные события (POST /events, DELETE только для manual), PATCH /instruments/{id}, GET /analytics/overview — карточка на каждый скоуп одним запросом. Холдинги отдают logo_url и logo_color. openapi.json обновлён.
This commit is contained in:
@@ -31,6 +31,7 @@ from fintracker.api.routers import (
|
||||
links,
|
||||
metrics,
|
||||
networth,
|
||||
portfolios,
|
||||
rebalance,
|
||||
rules,
|
||||
sync,
|
||||
@@ -112,6 +113,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(metrics.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED)
|
||||
app.include_router(goals.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED)
|
||||
app.include_router(income.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED)
|
||||
app.include_router(portfolios.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED)
|
||||
app.include_router(rebalance.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED)
|
||||
app.include_router(tax.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED)
|
||||
app.include_router(benchmarks.router, prefix=API_PREFIX, dependencies=_AUTHENTICATED)
|
||||
|
||||
@@ -14,23 +14,28 @@ from typing import Annotated
|
||||
from fastapi import APIRouter, Query
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.schemas.analytics import (
|
||||
AllocationBucket,
|
||||
CashFlowBrokerMonth,
|
||||
HoldingOut,
|
||||
ReturnsOut,
|
||||
ScopeCardOut,
|
||||
ScopeOut,
|
||||
SummaryOut,
|
||||
ValueDay,
|
||||
)
|
||||
from fintracker.api.scopes import DEFAULT_SCOPE, list_scopes, resolve_scope
|
||||
from fintracker.branding import logo_url
|
||||
from fintracker.models import (
|
||||
AllocationDimension,
|
||||
IncomeBasis,
|
||||
Instrument,
|
||||
MetricAllocation,
|
||||
MetricCashFlowBroker,
|
||||
MetricHolding,
|
||||
MetricIncomeCalendar,
|
||||
MetricPortfolioValueDaily,
|
||||
MetricRefreshLog,
|
||||
MetricReturns,
|
||||
@@ -53,6 +58,91 @@ async def scopes(session: SessionDep, _: CurrentUser) -> list[ScopeOut]:
|
||||
return await list_scopes(session)
|
||||
|
||||
|
||||
@router.get("/overview", name="overview")
|
||||
async def overview(session: SessionDep, _: CurrentUser) -> list[ScopeCardOut]:
|
||||
"""One card per scope for the home screen, in a single round trip.
|
||||
|
||||
`all` first, then the portfolios, then the accounts by value. A scope holding nothing at
|
||||
all is left out: a card of zeros is noise, not an answer.
|
||||
"""
|
||||
scope_list = await list_scopes(session)
|
||||
names = {s.scope: s.name for s in scope_list}
|
||||
today = today_local()
|
||||
|
||||
recent: dict[str, list[MetricPortfolioValueDaily]] = {}
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(MetricPortfolioValueDaily)
|
||||
.where(
|
||||
MetricPortfolioValueDaily.scope.in_(names),
|
||||
MetricPortfolioValueDaily.d >= today - timedelta(days=10),
|
||||
)
|
||||
.order_by(MetricPortfolioValueDaily.scope, MetricPortfolioValueDaily.d)
|
||||
)
|
||||
).scalars()
|
||||
for row in rows:
|
||||
recent.setdefault(row.scope, []).append(row)
|
||||
|
||||
xirr = {
|
||||
r.scope: r.xirr
|
||||
for r in (
|
||||
await session.execute(select(MetricReturns).where(MetricReturns.period == "all"))
|
||||
).scalars()
|
||||
}
|
||||
income = {
|
||||
scope: total
|
||||
for scope, total in (
|
||||
await session.execute(
|
||||
select(MetricIncomeCalendar.scope, func.sum(MetricIncomeCalendar.amount_rub))
|
||||
.where(
|
||||
MetricIncomeCalendar.expected_date > today,
|
||||
MetricIncomeCalendar.expected_date <= today + timedelta(days=365),
|
||||
MetricIncomeCalendar.basis != IncomeBasis.paid,
|
||||
)
|
||||
.group_by(MetricIncomeCalendar.scope)
|
||||
)
|
||||
).all()
|
||||
}
|
||||
|
||||
cards: list[ScopeCardOut] = []
|
||||
for scope, name in names.items():
|
||||
days = recent.get(scope, [])
|
||||
if not days:
|
||||
continue
|
||||
last = days[-1]
|
||||
total, invested = Decimal(last.total_rub), Decimal(last.invested_net_rub)
|
||||
if total == 0 and invested == 0:
|
||||
continue
|
||||
pnl = last.pnl_total_rub
|
||||
day_change = None
|
||||
if len(days) >= 2 and pnl is not None and days[-2].pnl_total_rub is not None:
|
||||
day_change = Decimal(pnl) - Decimal(days[-2].pnl_total_rub)
|
||||
before = Decimal(days[-2].total_rub) if len(days) >= 2 else ZERO
|
||||
yearly = Decimal(income.get(scope) or 0)
|
||||
cards.append(
|
||||
ScopeCardOut(
|
||||
scope=scope,
|
||||
name=name,
|
||||
kind=scope.split(":")[0],
|
||||
as_of=last.d,
|
||||
total_rub=total,
|
||||
invested_rub=invested,
|
||||
pnl_rub=pnl,
|
||||
pnl_pct=Decimal(pnl) / invested if pnl is not None and invested > 0 else None,
|
||||
day_change_rub=day_change,
|
||||
day_change_pct=day_change / before
|
||||
if day_change is not None and before > 0
|
||||
else None,
|
||||
xirr=xirr.get(scope),
|
||||
income_year_rub=yearly,
|
||||
income_year_pct=yearly / total if total > 0 else None,
|
||||
)
|
||||
)
|
||||
order = {"all": 0, "portfolio": 1, "account": 2}
|
||||
cards.sort(key=lambda c: (order.get(c.kind, 3), -c.total_rub))
|
||||
return cards
|
||||
|
||||
|
||||
@router.get("/value-series", name="value_series")
|
||||
async def value_series(
|
||||
session: SessionDep,
|
||||
@@ -276,6 +366,8 @@ def _holding(row: MetricHolding, instrument: Instrument) -> HoldingOut:
|
||||
instrument_id=row.instrument_id,
|
||||
ticker=instrument.ticker,
|
||||
name=instrument.name,
|
||||
logo_url=logo_url(instrument.logo_name),
|
||||
logo_color=instrument.logo_color,
|
||||
asset_class=instrument.asset_class.value,
|
||||
board=instrument.board,
|
||||
currency=instrument.currency,
|
||||
|
||||
@@ -1,22 +1,31 @@
|
||||
"""The broker event ledger, read-only for now (plan §4).
|
||||
"""The broker event ledger (plan §4).
|
||||
|
||||
Writing events by hand belongs with the report importer in phase 3; until then this is the
|
||||
screen that answers "where did this number come from", which is what makes a reconciliation
|
||||
finding actionable.
|
||||
The list is the screen that answers "where did this number come from", which is what makes a
|
||||
reconciliation finding actionable. Beside it, a person can enter an event the broker's feeds
|
||||
do not carry (a securities conversion, a trade from before the first report) with
|
||||
`source = manual`; only those can be deleted, everything else returns with the next sync.
|
||||
|
||||
Like `/rules/apply`, the writes do not refresh `metric_*`: call `POST /metrics/refresh`
|
||||
afterwards — lots and every valuation are rebuilt from the ledger there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import uuid
|
||||
from datetime import date, datetime, time
|
||||
from decimal import Decimal
|
||||
from typing import Annotated
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Query, Response, status
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.schemas.analytics import EventOut, EventPage
|
||||
from fintracker.models import Event, EventKind, EventStatus, Instrument
|
||||
from fintracker.api.schemas.events import ManualEventCreate
|
||||
from fintracker.models import Account, AccountKind, Event, EventKind, EventStatus, Instrument
|
||||
from fintracker.models.ledger import EXTERNAL_FLOW_KINDS
|
||||
from fintracker.pricing.fx import FxTable
|
||||
|
||||
@@ -90,6 +99,127 @@ async def list_events(
|
||||
)
|
||||
|
||||
|
||||
MSK = ZoneInfo("Europe/Moscow")
|
||||
ZERO = Decimal(0)
|
||||
|
||||
_TRADES = {EventKind.buy, EventKind.sell}
|
||||
_TRANSFERS = {EventKind.transfer_in, EventKind.transfer_out}
|
||||
_INCOME = {EventKind.dividend, EventKind.coupon}
|
||||
_CASH_IN = {EventKind.interest, EventKind.tax_refund, EventKind.deposit}
|
||||
_CASH_OUT = {EventKind.withdrawal, EventKind.commission, EventKind.tax}
|
||||
MANUAL_KINDS = _TRADES | _TRANSFERS | _INCOME | _CASH_IN | _CASH_OUT
|
||||
"""What a person can enter. Splits, amortisations, repayments and FX legs carry several
|
||||
rows tied together (`group_id`) and are not something to type by hand."""
|
||||
|
||||
|
||||
def _bad(message: str) -> Problem:
|
||||
return Problem(status.HTTP_422_UNPROCESSABLE_ENTITY, "Unprocessable Entity", message)
|
||||
|
||||
|
||||
def _signed(body: ManualEventCreate) -> tuple[Decimal | None, Decimal]:
|
||||
"""`(quantity, amount)` as the ledger stores them: quantity by position effect, amount by
|
||||
cash effect (see `models/ledger.py`)."""
|
||||
kind = body.kind
|
||||
quantity, price = body.quantity, body.price
|
||||
fee, accrued = body.fee or ZERO, body.accrued_interest or ZERO
|
||||
if (quantity is not None and quantity <= 0) or (body.amount is not None and body.amount < 0):
|
||||
raise _bad("quantity и amount вводятся положительными: знак задаёт вид события")
|
||||
if (price is not None and price < 0) or fee < 0 or accrued < 0:
|
||||
raise _bad("price, fee и accrued_interest не могут быть отрицательными")
|
||||
|
||||
if kind in _TRADES:
|
||||
if quantity is None:
|
||||
raise _bad("Для сделки нужно количество")
|
||||
if body.amount is not None:
|
||||
amount = body.amount
|
||||
elif price is not None:
|
||||
gross = quantity * price
|
||||
amount = gross + accrued + fee if kind == EventKind.buy else gross + accrued - fee
|
||||
else:
|
||||
raise _bad("Для сделки нужна цена или итоговая сумма")
|
||||
return (quantity, -amount) if kind == EventKind.buy else (-quantity, amount)
|
||||
if kind in _TRANSFERS:
|
||||
if quantity is None:
|
||||
raise _bad("Для перевода бумаг нужно количество")
|
||||
return (quantity if kind == EventKind.transfer_in else -quantity), ZERO
|
||||
if body.amount is None or body.amount == 0:
|
||||
raise _bad("Нужна сумма больше нуля")
|
||||
return None, (-body.amount if kind in _CASH_OUT else body.amount)
|
||||
|
||||
|
||||
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
|
||||
async def create_event(body: ManualEventCreate, session: SessionDep, _: CurrentUser) -> EventOut:
|
||||
"""Enter an event by hand. It is `confirmed` at once: it is the user's own statement, so
|
||||
it is never shadowed by the account's primary feed."""
|
||||
if body.kind not in MANUAL_KINDS:
|
||||
allowed = ", ".join(sorted(k.value for k in MANUAL_KINDS))
|
||||
raise _bad(f"Вид {body.kind.value!r} вручную не вводится; можно: {allowed}")
|
||||
account = await session.get(Account, body.account_id)
|
||||
if account is None or account.kind != AccountKind.broker:
|
||||
raise Problem(
|
||||
status.HTTP_400_BAD_REQUEST, "Bad request", "Нужен существующий брокерский счёт"
|
||||
)
|
||||
|
||||
instrument = None
|
||||
if body.instrument_id is not None:
|
||||
instrument = await session.get(Instrument, body.instrument_id)
|
||||
if instrument is None:
|
||||
raise Problem(
|
||||
status.HTTP_400_BAD_REQUEST, "Bad request", f"Нет инструмента #{body.instrument_id}"
|
||||
)
|
||||
needs_instrument = body.kind in _TRADES | _TRANSFERS | _INCOME
|
||||
if needs_instrument and instrument is None:
|
||||
raise _bad("Для этого вида события нужен инструмент")
|
||||
if not needs_instrument and instrument is not None and body.kind != EventKind.interest:
|
||||
raise _bad("Денежное событие не привязывается к инструменту")
|
||||
|
||||
quantity, amount = _signed(body)
|
||||
currency = (body.currency or (instrument.currency if instrument else account.currency)).upper()
|
||||
key = uuid.uuid4().hex
|
||||
event = Event(
|
||||
account_id=account.id,
|
||||
instrument_id=instrument.id if instrument else None,
|
||||
kind=body.kind,
|
||||
status=EventStatus.confirmed,
|
||||
ts=datetime.combine(body.trade_date, time(12, 0), tzinfo=MSK),
|
||||
trade_date=body.trade_date,
|
||||
quantity=quantity,
|
||||
price=body.price,
|
||||
price_currency=currency if body.price is not None else None,
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
fee=body.fee,
|
||||
fee_currency=currency if body.fee is not None else None,
|
||||
accrued_interest=body.accrued_interest,
|
||||
source="manual",
|
||||
source_id=key,
|
||||
dedupe_key=f"manual:{key}",
|
||||
description=body.description,
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
await session.refresh(event)
|
||||
return event_out(event, instrument.ticker if instrument else None, await FxTable.load(session))
|
||||
|
||||
|
||||
@router.delete("/{event_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_event(event_id: int, session: SessionDep, _: CurrentUser) -> Response:
|
||||
"""Delete an event that was entered by hand. Broker events are evidence, not ours to erase:
|
||||
the next sync or import would bring them straight back."""
|
||||
event = await session.get(Event, event_id)
|
||||
if event is None:
|
||||
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Нет события #{event_id}")
|
||||
if event.source != "manual":
|
||||
raise Problem(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Conflict",
|
||||
"Удалять можно только события, введённые вручную; остальные вернёт синк",
|
||||
)
|
||||
await session.delete(event)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
def _joined[T: tuple[object, ...]](stmt: Select[T]) -> Select[T]:
|
||||
"""Left join the instrument: deposits and fees have none, and must still be listed."""
|
||||
return stmt.outerjoin(Instrument, Instrument.id == Event.instrument_id)
|
||||
@@ -115,6 +245,7 @@ def event_out(event: Event, ticker: str | None, fx: FxTable) -> EventOut:
|
||||
tax=event.tax,
|
||||
accrued_interest=event.accrued_interest,
|
||||
description=event.description,
|
||||
source=event.source,
|
||||
external_flow=is_external_flow(event),
|
||||
)
|
||||
|
||||
|
||||
@@ -20,12 +20,14 @@ from fintracker.api.routers.events import events_of_instrument
|
||||
from fintracker.api.schemas.analytics import (
|
||||
InstrumentDetail,
|
||||
InstrumentOut,
|
||||
InstrumentPatch,
|
||||
LotOut,
|
||||
PriceManualIn,
|
||||
PriceManualOut,
|
||||
PricePoint,
|
||||
)
|
||||
from fintracker.api.scopes import DEFAULT_SCOPE, resolve_scope
|
||||
from fintracker.branding import logo_url
|
||||
from fintracker.models import AssetClass, Instrument, Lot, PriceDaily, PriceManual
|
||||
|
||||
router = APIRouter(prefix="/instruments", tags=["instruments"])
|
||||
@@ -146,6 +148,65 @@ async def get_instrument(
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{instrument_id}", name="patch")
|
||||
async def patch_instrument(
|
||||
instrument_id: int, body: InstrumentPatch, session: SessionDep, _: CurrentUser
|
||||
) -> InstrumentOut:
|
||||
"""Correct an instrument by hand: name, board, lot, asset class, sector.
|
||||
|
||||
The lot only rounds the quantities the rebalancing suggests; the asset class decides the
|
||||
bucket it lands in. Neither is refreshed here — call `POST /metrics/refresh` after, same
|
||||
as `/rules/apply`.
|
||||
"""
|
||||
instrument = await session.get(Instrument, instrument_id)
|
||||
if instrument is None:
|
||||
raise Problem(404, "Not Found", f"Нет инструмента #{instrument_id}")
|
||||
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
for field in ("name", "asset_class", "lot"):
|
||||
if field in changes and changes[field] is None:
|
||||
raise Problem(400, "Bad request", f"{field} must not be null")
|
||||
if "name" in changes:
|
||||
changes["name"] = changes["name"].strip()
|
||||
if not changes["name"]:
|
||||
raise Problem(400, "Bad request", "name must not be empty")
|
||||
if "asset_class" in changes:
|
||||
try:
|
||||
changes["asset_class"] = AssetClass(changes["asset_class"])
|
||||
except ValueError:
|
||||
known = ", ".join(a.value for a in AssetClass)
|
||||
raise Problem(
|
||||
422,
|
||||
"Unprocessable Entity",
|
||||
f"Неизвестный класс актива {changes['asset_class']!r}; есть: {known}",
|
||||
) from None
|
||||
for field in ("board", "sector"):
|
||||
if field in changes and changes[field] is not None:
|
||||
changes[field] = changes[field].strip() or None
|
||||
|
||||
board = changes.get("board", instrument.board)
|
||||
if "board" in changes and board is not None and instrument.ticker is not None:
|
||||
clash = await session.scalar(
|
||||
select(Instrument.id).where(
|
||||
Instrument.ticker == instrument.ticker,
|
||||
Instrument.board == board,
|
||||
Instrument.id != instrument_id,
|
||||
)
|
||||
)
|
||||
if clash is not None:
|
||||
raise Problem(
|
||||
409,
|
||||
"Conflict",
|
||||
f"{instrument.ticker} на доске {board} уже есть: инструмент #{clash}",
|
||||
)
|
||||
|
||||
for field, value in changes.items():
|
||||
setattr(instrument, field, value)
|
||||
await session.commit()
|
||||
await session.refresh(instrument)
|
||||
return _instrument(instrument)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{instrument_id}/prices", name="set_manual_price", status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@@ -199,6 +260,8 @@ def _instrument(row: Instrument) -> InstrumentOut:
|
||||
board=row.board,
|
||||
exchange=row.exchange,
|
||||
name=row.name,
|
||||
logo_url=logo_url(row.logo_name),
|
||||
logo_color=row.logo_color,
|
||||
issuer=row.issuer,
|
||||
currency=row.currency,
|
||||
lot=row.lot,
|
||||
|
||||
@@ -54,6 +54,7 @@ async def net_worth_breakdown(session: SessionDep, _: CurrentUser) -> NetWorthBr
|
||||
.where(
|
||||
Account.include_in_net_worth.is_(True),
|
||||
Account.archived.is_(False),
|
||||
Account.disabled.is_(False),
|
||||
Account.mirror_of_account_id.is_(None),
|
||||
Account.balance.is_not(None),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Portfolios: CRUD and the account set (docs/ai/architecture.md §portfolio).
|
||||
|
||||
Like `/rules/apply`, these writes do not refresh `metric_*`: call `POST /metrics/refresh`
|
||||
afterwards so the portfolio's value history follows the new account set. Targets and the
|
||||
rebalancing suggestion (`routers/rebalance.py`) are computed per request and need no refresh.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Response, status
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from fintracker.api.deps import CurrentUser, SessionDep
|
||||
from fintracker.api.errors import Problem
|
||||
from fintracker.api.schemas.portfolios import (
|
||||
PortfolioAccountsIn,
|
||||
PortfolioCreate,
|
||||
PortfolioOut,
|
||||
PortfolioPatch,
|
||||
)
|
||||
from fintracker.models import Account, Portfolio, PortfolioAccount
|
||||
|
||||
router = APIRouter(prefix="/portfolios", tags=["portfolios"])
|
||||
|
||||
|
||||
async def _portfolio(session: SessionDep, portfolio_id: int) -> Portfolio:
|
||||
portfolio = await session.get(Portfolio, portfolio_id)
|
||||
if portfolio is None:
|
||||
raise Problem(status.HTTP_404_NOT_FOUND, "Not found", f"Нет портфеля с id {portfolio_id}")
|
||||
return portfolio
|
||||
|
||||
|
||||
async def _check_name(session: SessionDep, name: str, *, exclude: int | None = None) -> None:
|
||||
stmt = select(Portfolio.id).where(Portfolio.name == name)
|
||||
if exclude is not None:
|
||||
stmt = stmt.where(Portfolio.id != exclude)
|
||||
if (await session.execute(stmt)).scalar_one_or_none() is not None:
|
||||
raise Problem(status.HTTP_409_CONFLICT, "Conflict", f"Портфель «{name}» уже есть")
|
||||
|
||||
|
||||
async def _check_accounts(session: SessionDep, account_ids: list[int]) -> set[int]:
|
||||
wanted = set(account_ids)
|
||||
found = set(
|
||||
(await session.execute(select(Account.id).where(Account.id.in_(wanted)))).scalars().all()
|
||||
)
|
||||
if missing := sorted(wanted - found):
|
||||
raise Problem(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Bad request",
|
||||
"Нет счетов с id " + ", ".join(map(str, missing)),
|
||||
)
|
||||
return wanted
|
||||
|
||||
|
||||
async def _account_ids(session: SessionDep, portfolio_ids: list[int]) -> dict[int, list[int]]:
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(PortfolioAccount.portfolio_id, PortfolioAccount.account_id).where(
|
||||
PortfolioAccount.portfolio_id.in_(portfolio_ids)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
out: dict[int, list[int]] = defaultdict(list)
|
||||
for r in rows:
|
||||
out[r.portfolio_id].append(r.account_id)
|
||||
return {pid: sorted(ids) for pid, ids in out.items()}
|
||||
|
||||
|
||||
async def _out(session: SessionDep, portfolios: list[Portfolio]) -> list[PortfolioOut]:
|
||||
accounts = await _account_ids(session, [p.id for p in portfolios])
|
||||
return [
|
||||
PortfolioOut(
|
||||
id=p.id,
|
||||
name=p.name,
|
||||
base_currency=p.base_currency,
|
||||
account_ids=accounts.get(p.id, []),
|
||||
)
|
||||
for p in portfolios
|
||||
]
|
||||
|
||||
|
||||
@router.get("", name="list")
|
||||
async def list_portfolios(session: SessionDep, _: CurrentUser) -> list[PortfolioOut]:
|
||||
rows = (await session.execute(select(Portfolio).order_by(Portfolio.name))).scalars().all()
|
||||
return await _out(session, list(rows))
|
||||
|
||||
|
||||
@router.post("", name="create", status_code=status.HTTP_201_CREATED)
|
||||
async def create_portfolio(
|
||||
body: PortfolioCreate, session: SessionDep, _: CurrentUser
|
||||
) -> PortfolioOut:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "name must not be empty")
|
||||
await _check_name(session, name)
|
||||
account_ids = await _check_accounts(session, body.account_ids)
|
||||
portfolio = Portfolio(name=name)
|
||||
session.add(portfolio)
|
||||
await session.flush()
|
||||
session.add_all(PortfolioAccount(portfolio_id=portfolio.id, account_id=a) for a in account_ids)
|
||||
await session.commit()
|
||||
await session.refresh(portfolio)
|
||||
return (await _out(session, [portfolio]))[0]
|
||||
|
||||
|
||||
@router.patch("/{portfolio_id}", name="patch")
|
||||
async def patch_portfolio(
|
||||
portfolio_id: int, body: PortfolioPatch, session: SessionDep, _: CurrentUser
|
||||
) -> PortfolioOut:
|
||||
portfolio = await _portfolio(session, portfolio_id)
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise Problem(status.HTTP_400_BAD_REQUEST, "Bad request", "name must not be empty")
|
||||
await _check_name(session, name, exclude=portfolio_id)
|
||||
portfolio.name = name
|
||||
await session.commit()
|
||||
await session.refresh(portfolio)
|
||||
return (await _out(session, [portfolio]))[0]
|
||||
|
||||
|
||||
@router.put("/{portfolio_id}/accounts", name="set_accounts")
|
||||
async def set_portfolio_accounts(
|
||||
portfolio_id: int, body: PortfolioAccountsIn, session: SessionDep, _: CurrentUser
|
||||
) -> PortfolioOut:
|
||||
portfolio = await _portfolio(session, portfolio_id)
|
||||
account_ids = await _check_accounts(session, body.account_ids)
|
||||
await session.execute(
|
||||
delete(PortfolioAccount).where(PortfolioAccount.portfolio_id == portfolio_id)
|
||||
)
|
||||
session.add_all(PortfolioAccount(portfolio_id=portfolio_id, account_id=a) for a in account_ids)
|
||||
await session.commit()
|
||||
await session.refresh(portfolio)
|
||||
return (await _out(session, [portfolio]))[0]
|
||||
|
||||
|
||||
@router.delete("/{portfolio_id}", name="delete", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_portfolio(portfolio_id: int, session: SessionDep, _: CurrentUser) -> Response:
|
||||
portfolio = await _portfolio(session, portfolio_id)
|
||||
await session.delete(portfolio)
|
||||
await session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from fintracker.api.schemas.common import Money, MoneyOpt
|
||||
from fintracker.models import AllocationDimension, EventKind, EventStatus
|
||||
@@ -23,6 +23,31 @@ class ScopeOut(BaseModel):
|
||||
account_ids: list[int]
|
||||
|
||||
|
||||
class ScopeCardOut(BaseModel):
|
||||
"""One tile of the home screen: a scope with what a glance needs, from the metric tables."""
|
||||
|
||||
scope: str
|
||||
name: str
|
||||
kind: str
|
||||
"""all | portfolio | account"""
|
||||
as_of: date | None
|
||||
total_rub: Money
|
||||
invested_rub: Money
|
||||
"""Net money put in (deposits less withdrawals)."""
|
||||
pnl_rub: MoneyOpt
|
||||
pnl_pct: MoneyOpt
|
||||
"""A share of `invested_rub`, not a percent: `0.015` is 1.5 %. Null when nothing is invested."""
|
||||
day_change_rub: MoneyOpt
|
||||
day_change_pct: MoneyOpt
|
||||
"""Change of the result over the last day — flows in or out do not count as a gain."""
|
||||
xirr: MoneyOpt
|
||||
"""Annualised money-weighted return since the start; null when the flows admit no answer."""
|
||||
income_year_rub: Money
|
||||
"""Dividends and coupons expected in the next 12 months (announced, scheduled, projected)."""
|
||||
income_year_pct: MoneyOpt
|
||||
"""`income_year_rub` as a share of the current value."""
|
||||
|
||||
|
||||
class ValueDay(BaseModel):
|
||||
d: date
|
||||
market_value_rub: Money
|
||||
@@ -43,6 +68,9 @@ class HoldingOut(BaseModel):
|
||||
instrument_id: int
|
||||
ticker: str | None
|
||||
name: str
|
||||
logo_url: str | None = None
|
||||
logo_color: str | None = None
|
||||
"""Issuer logo (a public URL) and its brand colour; both null when the broker has none."""
|
||||
asset_class: str
|
||||
"""Stable key: share | bond | etf | fund | currency | index | deposit | …"""
|
||||
board: str | None
|
||||
@@ -150,6 +178,8 @@ class EventOut(BaseModel):
|
||||
tax: MoneyOpt
|
||||
accrued_interest: MoneyOpt
|
||||
description: str | None
|
||||
source: str
|
||||
"""tinvest | report_sber | report_vtb | csv | manual — only `manual` events can be deleted."""
|
||||
external_flow: bool
|
||||
"""True when this event moved money across the portfolio boundary (XIRR reads these)."""
|
||||
|
||||
@@ -206,6 +236,8 @@ class InstrumentOut(BaseModel):
|
||||
board: str | None
|
||||
exchange: str | None
|
||||
name: str
|
||||
logo_url: str | None = None
|
||||
logo_color: str | None = None
|
||||
issuer: str | None
|
||||
currency: str
|
||||
lot: int
|
||||
@@ -217,6 +249,20 @@ class InstrumentOut(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class InstrumentPatch(BaseModel):
|
||||
"""The fields a person may correct by hand. Unset fields are left alone; an explicit `null`
|
||||
clears the nullable ones (`board`, `sector`)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=256)
|
||||
asset_class: str | None = None
|
||||
"""Stable key, as in `InstrumentOut`."""
|
||||
board: str | None = Field(default=None, max_length=16)
|
||||
lot: int | None = Field(default=None, ge=1)
|
||||
sector: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class InstrumentDetail(BaseModel):
|
||||
instrument: InstrumentOut
|
||||
holding: HoldingOut | None
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Wire shapes for events entered by hand (plan §1.4: `source = manual`).
|
||||
|
||||
The caller states what happened in the units a person reads off a broker statement — a
|
||||
positive quantity, a positive price, a positive amount. The signs the ledger stores
|
||||
(`quantity` by position effect, `amount` by cash effect) are derived from `kind` server-side,
|
||||
so a form can never save a purchase that adds cash.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from fintracker.api.schemas.common import MoneyOpt
|
||||
from fintracker.models import EventKind
|
||||
|
||||
|
||||
class ManualEventCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account_id: int
|
||||
kind: EventKind
|
||||
trade_date: date
|
||||
instrument_id: int | None = None
|
||||
quantity: MoneyOpt = None
|
||||
"""Units, always positive: the sign comes from `kind`."""
|
||||
price: MoneyOpt = None
|
||||
"""Per unit, in `currency`."""
|
||||
amount: MoneyOpt = None
|
||||
"""What actually moved on the account, positive. For a trade it may be left out and is then
|
||||
quantity × price ± accrued interest ± fee."""
|
||||
currency: str | None = Field(default=None, min_length=3, max_length=3)
|
||||
"""Defaults to the instrument's currency, or the account's for a cash event."""
|
||||
fee: MoneyOpt = None
|
||||
"""Already part of `amount`; kept for the record."""
|
||||
accrued_interest: MoneyOpt = None
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Wire shapes for portfolios: a named set of accounts (docs/ai/architecture.md §portfolio)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PortfolioOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
base_currency: str
|
||||
account_ids: list[int]
|
||||
|
||||
|
||||
class PortfolioCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
account_ids: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PortfolioPatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class PortfolioAccountsIn(BaseModel):
|
||||
"""The complete set: accounts not listed are removed from the portfolio."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account_ids: list[int]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Issuer logos, as T-Invest publishes them (kept out of `sources/`: the API needs the URL,
|
||||
not the whole source registry).
|
||||
|
||||
Every instrument the API describes carries a `brand` block: the name of a logo file and the
|
||||
brand's own colour. The pictures live on T-Bank's public CDN, need no key and answer with
|
||||
`access-control-allow-origin: *`, so a browser client can load them straight from there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LOGO_HOST = "https://invest-brands.cdn-tinkoff.ru"
|
||||
LOGO_SIZE = 160
|
||||
"""The CDN serves 160, 320 and 640 px squares; a list row never needs more than the first."""
|
||||
|
||||
|
||||
def logo_url(logo_name: str | None) -> str | None:
|
||||
"""`sber.png` -> the URL of its 160 px picture; None when the instrument has no logo."""
|
||||
if not logo_name:
|
||||
return None
|
||||
stem = logo_name.rsplit(".", 1)[0]
|
||||
return f"{LOGO_HOST}/{stem}x{LOGO_SIZE}.png"
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Events entered by hand: derived signs, validation, and deletion of only the manual ones."""
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from factories import make_account, make_event, make_instrument, refresh
|
||||
from fintracker.models import AccountKind, AccountRole, EventKind
|
||||
|
||||
URL = "/api/v1/events"
|
||||
D = Decimal
|
||||
DAY = "2026-03-10"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def broker(app) -> int:
|
||||
return await make_account(
|
||||
name="Брокер", kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
|
||||
|
||||
async def post(client, headers, **body):
|
||||
return await client.post(URL, json=body, headers=headers)
|
||||
|
||||
|
||||
async def test_buy_adds_the_position_and_takes_cash_including_the_fee(client, auth_headers, broker):
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
r = await post(
|
||||
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
|
||||
instrument_id=sber, quantity="10", price="100", fee="5",
|
||||
) # fmt: skip
|
||||
assert r.status_code == 201, r.text
|
||||
e = r.json()
|
||||
assert D(e["quantity"]) == 10
|
||||
assert D(e["amount"]) == -1005
|
||||
assert (e["status"], e["ticker"], e["currency"]) == ("confirmed", "SBER", "RUB")
|
||||
assert e["source"] == "manual"
|
||||
assert D(e["fee"]) == 5
|
||||
|
||||
|
||||
async def test_sell_removes_from_the_position_and_brings_cash_less_the_fee(
|
||||
client, auth_headers, broker
|
||||
):
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
r = await post(
|
||||
client, auth_headers, account_id=broker, kind="sell", trade_date=DAY,
|
||||
instrument_id=sber, quantity="4", price="120", fee="2",
|
||||
) # fmt: skip
|
||||
e = r.json()
|
||||
assert D(e["quantity"]) == -4
|
||||
assert D(e["amount"]) == 478 # 4 × 120 − 2
|
||||
|
||||
|
||||
async def test_a_bond_purchase_adds_accrued_interest(client, auth_headers, broker):
|
||||
bond = await make_instrument(ticker="SU26207", name="ОФЗ")
|
||||
r = await post(
|
||||
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
|
||||
instrument_id=bond, quantity="50", price="967.94", accrued_interest="804",
|
||||
) # fmt: skip
|
||||
assert D(r.json()["amount"]) == D("-49201.00") # 50 × 967.94 + 804
|
||||
|
||||
|
||||
async def test_an_explicit_total_wins_over_the_computed_one(client, auth_headers, broker):
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
r = await post(
|
||||
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
|
||||
instrument_id=sber, quantity="10", amount="1007.31",
|
||||
) # fmt: skip
|
||||
assert r.status_code == 201
|
||||
assert D(r.json()["amount"]) == D("-1007.31")
|
||||
|
||||
|
||||
async def test_cash_events_are_signed_by_their_kind(client, auth_headers, broker):
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
expected = {
|
||||
"deposit": 500,
|
||||
"withdrawal": -500,
|
||||
"commission": -500,
|
||||
"tax": -500,
|
||||
"tax_refund": 500,
|
||||
}
|
||||
for kind, amount in expected.items():
|
||||
r = await post(
|
||||
client, auth_headers, account_id=broker, kind=kind, trade_date=DAY, amount="500"
|
||||
)
|
||||
assert r.status_code == 201, (kind, r.text)
|
||||
assert D(r.json()["amount"]) == amount, kind
|
||||
|
||||
r = await post(
|
||||
client, auth_headers, account_id=broker, kind="dividend", trade_date=DAY,
|
||||
instrument_id=sber, amount="87.5",
|
||||
) # fmt: skip
|
||||
assert D(r.json()["amount"]) == D("87.5")
|
||||
assert r.json()["quantity"] is None
|
||||
|
||||
|
||||
async def test_a_securities_transfer_moves_the_position_without_cash(client, auth_headers, broker):
|
||||
five = await make_instrument(ticker="FIVE")
|
||||
x5 = await make_instrument(ticker="X5")
|
||||
out = await post(
|
||||
client, auth_headers, account_id=broker, kind="transfer_out", trade_date=DAY,
|
||||
instrument_id=five, quantity="3",
|
||||
) # fmt: skip
|
||||
into = await post(
|
||||
client, auth_headers, account_id=broker, kind="transfer_in", trade_date=DAY,
|
||||
instrument_id=x5, quantity="3",
|
||||
) # fmt: skip
|
||||
assert (D(out.json()["quantity"]), D(out.json()["amount"])) == (-3, 0)
|
||||
assert (D(into.json()["quantity"]), D(into.json()["amount"])) == (3, 0)
|
||||
|
||||
|
||||
async def test_a_manual_purchase_opens_a_lot_and_a_deposit_is_a_flow(client, auth_headers, broker):
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
await post(
|
||||
client, auth_headers, account_id=broker, kind="deposit", trade_date=DAY, amount="2000"
|
||||
)
|
||||
await post(
|
||||
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
|
||||
instrument_id=sber, quantity="10", price="100",
|
||||
) # fmt: skip
|
||||
await refresh()
|
||||
|
||||
detail = (await client.get(f"/api/v1/instruments/{sber}", headers=auth_headers)).json()
|
||||
assert [D(lot["qty_remaining"]) for lot in detail["lots"]] == [10]
|
||||
listed = (await client.get(URL, params={"account_id": broker}, headers=auth_headers)).json()
|
||||
flows = [e for e in listed["items"] if e["external_flow"]]
|
||||
assert [e["kind"] for e in flows] == ["deposit"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("body", "status"),
|
||||
[
|
||||
({"kind": "buy", "quantity": "-1", "price": "1"}, 422), # sign comes from the kind
|
||||
({"kind": "buy", "price": "1"}, 422), # no quantity
|
||||
({"kind": "buy", "quantity": "1"}, 422), # neither price nor total
|
||||
({"kind": "deposit"}, 422), # no amount
|
||||
({"kind": "deposit", "amount": "0"}, 422),
|
||||
({"kind": "deposit", "amount": "-5"}, 422),
|
||||
({"kind": "split", "quantity": "2"}, 422), # not typed by hand
|
||||
({"kind": "fx_exchange", "amount": "5"}, 422),
|
||||
({"kind": "transfer_in", "quantity": "1"}, 422), # a transfer needs an instrument
|
||||
({"kind": "dividend", "amount": "5"}, 422), # so does a dividend
|
||||
],
|
||||
)
|
||||
async def test_invalid_events_are_refused(client, auth_headers, broker, body, status):
|
||||
r = await post(client, auth_headers, account_id=broker, trade_date=DAY, **body)
|
||||
assert r.status_code == status, r.text
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
assert (await client.get(URL, headers=auth_headers)).json()["total"] == 0
|
||||
|
||||
|
||||
async def test_account_and_instrument_must_exist_and_fit(client, auth_headers, broker):
|
||||
sber = await make_instrument(ticker="SBER")
|
||||
card = await make_account(name="Карта") # a ZenMoney account has no ledger
|
||||
|
||||
r = await post(
|
||||
client, auth_headers, account_id=card, kind="deposit", trade_date=DAY, amount="5"
|
||||
)
|
||||
assert r.status_code == 400
|
||||
r = await post(
|
||||
client, auth_headers, account_id=99999, kind="deposit", trade_date=DAY, amount="5"
|
||||
)
|
||||
assert r.status_code == 400
|
||||
r = await post(
|
||||
client, auth_headers, account_id=broker, kind="buy", trade_date=DAY,
|
||||
instrument_id=99999, quantity="1", price="1",
|
||||
) # fmt: skip
|
||||
assert r.status_code == 400
|
||||
r = await post(
|
||||
client, auth_headers, account_id=broker, kind="deposit", trade_date=DAY,
|
||||
instrument_id=sber, amount="5",
|
||||
) # fmt: skip
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_only_manual_events_can_be_deleted(client, auth_headers, broker):
|
||||
broker_event = await make_event(
|
||||
date(2026, 3, 1), account_id=broker, kind=EventKind.deposit, amount=100
|
||||
)
|
||||
mine = (
|
||||
await post(
|
||||
client, auth_headers, account_id=broker, kind="deposit", trade_date=DAY, amount="5"
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
assert (await client.delete(f"{URL}/{mine}", headers=auth_headers)).status_code == 204
|
||||
r = await client.delete(f"{URL}/{broker_event}", headers=auth_headers)
|
||||
assert r.status_code == 409
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
assert (await client.delete(f"{URL}/{mine}", headers=auth_headers)).status_code == 404
|
||||
|
||||
remaining = (await client.get(URL, headers=auth_headers)).json()["items"]
|
||||
assert [e["id"] for e in remaining] == [broker_event]
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Hand corrections to an instrument over HTTP."""
|
||||
|
||||
from factories import make_instrument
|
||||
|
||||
|
||||
def url(instrument_id: int) -> str:
|
||||
return f"/api/v1/instruments/{instrument_id}"
|
||||
|
||||
|
||||
async def test_patch_lot_board_name_class_and_sector(client, auth_headers):
|
||||
iid = await make_instrument(ticker="MSNG", board="TQBR")
|
||||
|
||||
r = await client.patch(
|
||||
url(iid),
|
||||
json={
|
||||
"lot": 1000,
|
||||
"name": " Мосэнерго ",
|
||||
"asset_class": "bond",
|
||||
"sector": "Энергетика",
|
||||
"board": "TQTF",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
row = r.json()
|
||||
assert (row["lot"], row["name"], row["asset_class"]) == (1000, "Мосэнерго", "bond")
|
||||
assert (row["sector"], row["board"]) == ("Энергетика", "TQTF")
|
||||
|
||||
detail = (await client.get(url(iid), headers=auth_headers)).json()["instrument"]
|
||||
assert detail["lot"] == 1000
|
||||
|
||||
|
||||
async def test_unset_fields_are_left_alone_and_null_clears_board(client, auth_headers):
|
||||
iid = await make_instrument(ticker="AFLT", board="TQBR", name="Аэрофлот")
|
||||
|
||||
r = await client.patch(url(iid), json={"lot": 10}, headers=auth_headers)
|
||||
assert (r.json()["name"], r.json()["board"], r.json()["lot"]) == ("Аэрофлот", "TQBR", 10)
|
||||
|
||||
r = await client.patch(url(iid), json={"board": None}, headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["board"] is None
|
||||
assert r.json()["lot"] == 10
|
||||
|
||||
|
||||
async def test_invalid_values_are_refused(client, auth_headers):
|
||||
iid = await make_instrument(ticker="PHOR")
|
||||
|
||||
for body in ({"lot": 0}, {"lot": -5}, {"name": ""}, {"unknown_field": 1}):
|
||||
r = await client.patch(url(iid), json=body, headers=auth_headers)
|
||||
assert r.status_code == 422, body
|
||||
|
||||
for body in ({"lot": None}, {"name": None}, {"asset_class": None}, {"name": " "}):
|
||||
r = await client.patch(url(iid), json=body, headers=auth_headers)
|
||||
assert r.status_code == 400, body
|
||||
|
||||
r = await client.patch(url(iid), json={"asset_class": "stock"}, headers=auth_headers)
|
||||
assert r.status_code == 422
|
||||
assert "share" in r.json()["detail"]
|
||||
|
||||
|
||||
async def test_moving_to_a_taken_ticker_and_board_is_a_conflict(client, auth_headers):
|
||||
await make_instrument(ticker="SBER", board="TQBR")
|
||||
other = await make_instrument(ticker="SBER", board="SMAL")
|
||||
|
||||
r = await client.patch(url(other), json={"board": "TQBR"}, headers=auth_headers)
|
||||
assert r.status_code == 409
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
|
||||
r = await client.patch(url(other), json={"board": "TQBR", "lot": 10}, headers=auth_headers)
|
||||
assert r.status_code == 409 # nothing was half-applied
|
||||
assert (await client.get(url(other), headers=auth_headers)).json()["instrument"]["lot"] == 1
|
||||
|
||||
|
||||
async def test_missing_instrument_is_404(client, auth_headers):
|
||||
r = await client.patch(url(999), json={"lot": 2}, headers=auth_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_instrument_and_holding_expose_a_public_logo_url(client, auth_headers):
|
||||
from datetime import date
|
||||
|
||||
from factories import make_account, make_event, refresh
|
||||
from fintracker.db import get_sessionmaker
|
||||
from fintracker.models import AccountKind, AccountRole, EventKind, Instrument
|
||||
|
||||
iid = await make_instrument(ticker="SBER")
|
||||
async with get_sessionmaker()() as session:
|
||||
row = await session.get(Instrument, iid)
|
||||
assert row is not None
|
||||
row.logo_name, row.logo_color = "sber.png", "#21A038"
|
||||
await session.commit()
|
||||
account = await make_account(
|
||||
name="Т", kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
await make_event(
|
||||
date(2026, 3, 1), account_id=account, kind=EventKind.buy, instrument_id=iid,
|
||||
quantity=1, price=100, amount=-100,
|
||||
) # fmt: skip
|
||||
await refresh()
|
||||
|
||||
card = (await client.get(url(iid), headers=auth_headers)).json()
|
||||
assert card["instrument"]["logo_url"] == "https://invest-brands.cdn-tinkoff.ru/sberx160.png"
|
||||
assert card["instrument"]["logo_color"] == "#21A038"
|
||||
assert card["holding"]["logo_url"] == card["instrument"]["logo_url"]
|
||||
|
||||
plain = await make_instrument(ticker="NOLOGO")
|
||||
assert (await client.get(url(plain), headers=auth_headers)).json()["instrument"][
|
||||
"logo_url"
|
||||
] is None
|
||||
@@ -0,0 +1,87 @@
|
||||
"""One card per scope for the home screen."""
|
||||
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from factories import make_account, make_event, make_instrument, make_price, refresh
|
||||
from fintracker.analytics import today_local
|
||||
from fintracker.models import AccountKind, AccountRole, EventKind
|
||||
|
||||
URL = "/api/v1/analytics/overview"
|
||||
|
||||
|
||||
async def _broker(name: str) -> int:
|
||||
return await make_account(
|
||||
name=name, kind=AccountKind.broker, role=AccountRole.investment, balance=None
|
||||
)
|
||||
|
||||
|
||||
async def _invest(account: int, *, today_price: str) -> None:
|
||||
"""10 000 ₽ in, 100 shares at 100; the price is flat until today, when it is `today_price`."""
|
||||
t = today_local()
|
||||
bought = t - timedelta(days=5)
|
||||
sber = await make_instrument(ticker=f"T{account}")
|
||||
await make_event(bought, account_id=account, kind=EventKind.deposit, amount=10000)
|
||||
await make_event(
|
||||
bought, account_id=account, kind=EventKind.buy, instrument_id=sber,
|
||||
quantity=100, price=100, amount=-10000,
|
||||
) # fmt: skip
|
||||
d = bought
|
||||
while d < t:
|
||||
await make_price(d, instrument_id=sber, close="100")
|
||||
d += timedelta(days=1)
|
||||
await make_price(t, instrument_id=sber, close=today_price)
|
||||
|
||||
|
||||
async def test_cards_carry_value_result_and_the_change_over_the_last_day(client, auth_headers):
|
||||
account = await _broker("Брокер")
|
||||
await _invest(account, today_price="105")
|
||||
await refresh()
|
||||
|
||||
r = await client.get(URL, headers=auth_headers)
|
||||
assert r.status_code == 200, r.text
|
||||
cards = {c["scope"]: c for c in r.json()}
|
||||
card = cards[f"account:{account}"]
|
||||
assert card["name"] == "Брокер"
|
||||
assert card["kind"] == "account"
|
||||
assert Decimal(card["total_rub"]) == 10500
|
||||
assert Decimal(card["invested_rub"]) == 10000
|
||||
assert Decimal(card["pnl_rub"]) == 500
|
||||
assert Decimal(card["pnl_pct"]) == Decimal("0.05")
|
||||
assert Decimal(card["day_change_rub"]) == 500
|
||||
assert Decimal(card["day_change_pct"]) == Decimal("500") / Decimal("10000")
|
||||
assert Decimal(card["income_year_rub"]) == 0
|
||||
|
||||
|
||||
async def test_all_comes_first_then_portfolios_then_accounts_by_value(client, auth_headers):
|
||||
small, big = await _broker("Малый"), await _broker("Большой")
|
||||
await _invest(small, today_price="100")
|
||||
await _invest(big, today_price="130")
|
||||
created = await client.post(
|
||||
"/api/v1/portfolios", json={"name": "Мой", "account_ids": [small]}, headers=auth_headers
|
||||
)
|
||||
assert created.status_code == 201
|
||||
await refresh()
|
||||
|
||||
cards = (await client.get(URL, headers=auth_headers)).json()
|
||||
assert [c["kind"] for c in cards] == ["all", "portfolio", "account", "account"]
|
||||
assert [c["name"] for c in cards[2:]] == ["Большой", "Малый"]
|
||||
assert Decimal(cards[0]["total_rub"]) == Decimal(cards[2]["total_rub"]) + Decimal(
|
||||
cards[3]["total_rub"]
|
||||
)
|
||||
|
||||
|
||||
async def test_an_account_switched_off_has_no_card(client, auth_headers):
|
||||
kept, off = await _broker("Оставить"), await _broker("Отключить")
|
||||
await _invest(kept, today_price="100")
|
||||
await _invest(off, today_price="100")
|
||||
await client.patch(f"/api/v1/accounts/{off}", json={"disabled": True}, headers=auth_headers)
|
||||
await refresh()
|
||||
|
||||
names = [c["name"] for c in (await client.get(URL, headers=auth_headers)).json()]
|
||||
assert "Оставить" in names
|
||||
assert "Отключить" not in names
|
||||
|
||||
|
||||
async def test_nothing_built_yet_is_an_empty_list(client, auth_headers):
|
||||
assert (await client.get(URL, headers=auth_headers)).json() == []
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Portfolio CRUD and the account set over HTTP."""
|
||||
|
||||
from factories import make_account
|
||||
|
||||
URL = "/api/v1/portfolios"
|
||||
|
||||
|
||||
async def test_create_list_and_replace_accounts(client, auth_headers):
|
||||
a = await make_account(name="Брокер 1")
|
||||
b = await make_account(name="Брокер 2")
|
||||
|
||||
r = await client.post(
|
||||
URL, json={"name": " Основной ", "account_ids": [b, a]}, headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
created = r.json()
|
||||
assert created["name"] == "Основной"
|
||||
assert created["base_currency"] == "RUB"
|
||||
assert created["account_ids"] == sorted([a, b])
|
||||
|
||||
r = await client.get(URL, headers=auth_headers)
|
||||
assert [p["id"] for p in r.json()] == [created["id"]]
|
||||
|
||||
r = await client.put(
|
||||
f"{URL}/{created['id']}/accounts", json={"account_ids": [b]}, headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["account_ids"] == [b]
|
||||
|
||||
r = await client.put(
|
||||
f"{URL}/{created['id']}/accounts", json={"account_ids": []}, headers=auth_headers
|
||||
)
|
||||
assert r.json()["account_ids"] == []
|
||||
|
||||
|
||||
async def test_create_without_accounts_is_allowed(client, auth_headers):
|
||||
r = await client.post(URL, json={"name": "Пустой"}, headers=auth_headers)
|
||||
assert r.status_code == 201
|
||||
assert r.json()["account_ids"] == []
|
||||
|
||||
|
||||
async def test_duplicate_name_is_a_conflict(client, auth_headers):
|
||||
await client.post(URL, json={"name": "Основной"}, headers=auth_headers)
|
||||
r = await client.post(URL, json={"name": "Основной"}, headers=auth_headers)
|
||||
assert r.status_code == 409
|
||||
assert r.headers["content-type"].startswith("application/problem+json")
|
||||
|
||||
|
||||
async def test_rename_and_rename_conflict(client, auth_headers):
|
||||
first = (await client.post(URL, json={"name": "Один"}, headers=auth_headers)).json()
|
||||
await client.post(URL, json={"name": "Два"}, headers=auth_headers)
|
||||
|
||||
r = await client.patch(
|
||||
f"{URL}/{first['id']}", json={"name": "Один и один"}, headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["name"] == "Один и один"
|
||||
|
||||
r = await client.patch(f"{URL}/{first['id']}", json={"name": "Два"}, headers=auth_headers)
|
||||
assert r.status_code == 409
|
||||
|
||||
r = await client.patch(
|
||||
f"{URL}/{first['id']}", json={"name": "Один и один"}, headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 200 # keeping its own name is not a conflict
|
||||
|
||||
|
||||
async def test_blank_name_is_rejected(client, auth_headers):
|
||||
r = await client.post(URL, json={"name": " "}, headers=auth_headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
async def test_unknown_account_is_rejected_and_nothing_is_saved(client, auth_headers):
|
||||
real = await make_account(name="Брокер")
|
||||
r = await client.post(
|
||||
URL, json={"name": "Х", "account_ids": [real, 99999]}, headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "99999" in r.json()["detail"]
|
||||
assert (await client.get(URL, headers=auth_headers)).json() == []
|
||||
|
||||
created = (
|
||||
await client.post(URL, json={"name": "Х", "account_ids": [real]}, headers=auth_headers)
|
||||
).json()
|
||||
r = await client.put(
|
||||
f"{URL}/{created['id']}/accounts", json={"account_ids": [99999]}, headers=auth_headers
|
||||
)
|
||||
assert r.status_code == 400
|
||||
listed = (await client.get(URL, headers=auth_headers)).json()
|
||||
assert listed[0]["account_ids"] == [real]
|
||||
|
||||
|
||||
async def test_delete_removes_the_portfolio_but_not_its_accounts(client, auth_headers):
|
||||
a = await make_account(name="Брокер")
|
||||
created = (
|
||||
await client.post(URL, json={"name": "Х", "account_ids": [a]}, headers=auth_headers)
|
||||
).json()
|
||||
|
||||
r = await client.delete(f"{URL}/{created['id']}", headers=auth_headers)
|
||||
assert r.status_code == 204
|
||||
assert (await client.get(URL, headers=auth_headers)).json() == []
|
||||
accounts = (await client.get("/api/v1/accounts", headers=auth_headers)).json()
|
||||
assert [x["id"] for x in accounts] == [a]
|
||||
|
||||
|
||||
async def test_missing_portfolio_is_404(client, auth_headers):
|
||||
assert (
|
||||
await client.patch(f"{URL}/999", json={"name": "Х"}, headers=auth_headers)
|
||||
).status_code == 404
|
||||
assert (
|
||||
await client.put(f"{URL}/999/accounts", json={"account_ids": []}, headers=auth_headers)
|
||||
).status_code == 404
|
||||
assert (await client.delete(f"{URL}/999", headers=auth_headers)).status_code == 404
|
||||
+1048
-13
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user