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"
|
||||
Reference in New Issue
Block a user