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:
Dmitry
2026-09-19 22:13:55 +03:00
parent 75933993b2
commit f1f336f94f
15 changed files with 2129 additions and 21 deletions
+2
View File
@@ -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,
+138 -7
View File
@@ -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]
+21
View File
@@ -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"
+194
View File
@@ -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]
+109
View File
@@ -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
+87
View File
@@ -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() == []
+113
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff