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:
@@ -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))
|
||||
Reference in New Issue
Block a user