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-локом на отдельном соединении: каждый шаг заменяет свою таблицу целиком, два одновременных прогона затёрли бы друг друга.
86 lines
3.0 KiB
Python
86 lines
3.0 KiB
Python
"""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))
|