feat(backend): каркас — конфиг, движок БД, базовые модели и первая миграция

SQLAlchemy 2 async на asyncpg, Alembic, pydantic-settings. Деньги везде
NUMERIC(24,10) с колонкой валюты рядом (db/base.py), postgres-enum'ы хранят
значения, а не имена, чтобы база читалась так же, как API.

Первая миграция: app_user, account, portfolio, instrument, sync_run, sync_job.
metrics/refresh.py задаёт порядок пересборки metric_* и сериализует параллельные
пересчёты advisory-локом на отдельном соединении: каждый шаг заменяет свою
таблицу целиком, два одновременных прогона затёрли бы друг друга.
This commit is contained in:
Dmitry
2026-09-18 13:43:31 +03:00
parent 220f027652
commit 295438914d
19 changed files with 3406 additions and 0 deletions
@@ -0,0 +1,85 @@
"""Instrument master (plan §1.3).
Identity resolution order: ISIN -> FIGI -> tinvest_uid -> (ticker, board) -> alias.
"""
from __future__ import annotations
import enum
from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base, TimestampMixin, db_enum
class AssetClass(enum.StrEnum):
share = "share"
bond = "bond"
etf = "etf"
fund = "fund"
currency = "currency"
market_index = "index"
deposit = "deposit"
real_estate = "real_estate"
crypto = "crypto"
custom = "custom"
class Instrument(TimestampMixin, Base):
__tablename__ = "instrument"
__table_args__ = (
Index("uq_instrument_isin", "isin", unique=True, postgresql_where=text("isin IS NOT NULL")),
Index("uq_instrument_figi", "figi", unique=True, postgresql_where=text("figi IS NOT NULL")),
Index(
"uq_instrument_tinvest_uid",
"tinvest_uid",
unique=True,
postgresql_where=text("tinvest_uid IS NOT NULL"),
),
Index(
"uq_instrument_ticker_board",
"ticker",
"board",
unique=True,
postgresql_where=text("ticker IS NOT NULL AND board IS NOT NULL"),
),
)
id: Mapped[int] = mapped_column(primary_key=True)
asset_class: Mapped[AssetClass] = mapped_column(db_enum(AssetClass, "asset_class"))
isin: Mapped[str | None] = mapped_column(String(12))
figi: Mapped[str | None] = mapped_column(String(12))
tinvest_uid: Mapped[str | None] = mapped_column(String(64))
ticker: Mapped[str | None] = mapped_column(String(32))
board: Mapped[str | None] = mapped_column(String(16))
"""MOEX board / T-Invest class_code, e.g. TQBR, TQOB."""
exchange: Mapped[str | None] = mapped_column(String(32))
name: Mapped[str] = mapped_column(String(256))
issuer: Mapped[str | None] = mapped_column(String(256))
currency: Mapped[str] = mapped_column(String(3))
lot: Mapped[int] = mapped_column(Integer, default=1)
nominal: Mapped[Decimal | None]
nominal_currency: Mapped[str | None] = mapped_column(String(3))
maturity_date: Mapped[date | None]
sector: Mapped[str | None] = mapped_column(String(64))
country: Mapped[str | None] = mapped_column(String(2))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
meta: Mapped[dict[str, Any] | None]
class InstrumentAlias(Base):
"""Source-specific keys resolving to an instrument, e.g. ('report_vtb', 'NAME:Газпром ао')."""
__tablename__ = "instrument_alias"
__table_args__ = (UniqueConstraint("source", "source_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
instrument_id: Mapped[int] = mapped_column(
ForeignKey("instrument.id", ondelete="CASCADE"), index=True
)
source: Mapped[str] = mapped_column(String(32))
source_key: Mapped[str] = mapped_column(String(256))