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
+136
View File
@@ -0,0 +1,136 @@
"""ORM models. Importing this package registers every table on `Base.metadata`."""
from fintracker.models.accounts import (
Account,
AccountKind,
AccountLink,
AccountRole,
Broker,
EventSource,
Portfolio,
PortfolioAccount,
)
from fintracker.models.auth import AppUser, RefreshToken
from fintracker.models.instruments import AssetClass, Instrument, InstrumentAlias
from fintracker.models.ledger import (
EXTERNAL_FLOW_KINDS,
POSITION_KINDS,
Event,
EventKind,
EventStatus,
Lot,
LotDisposal,
)
from fintracker.models.metrics import (
MetricCashFlowMonthly,
MetricDataQuality,
MetricHolding,
MetricNetWorthDaily,
MetricPortfolioValueDaily,
MetricRefreshLog,
MetricReturns,
MetricRunway,
MetricSpendingByCategory,
)
from fintracker.models.pricing import (
CashSnapshot,
CorporateAction,
CorporateActionKind,
CorporateActionStatus,
FxRateDaily,
PositionSnapshot,
PriceDaily,
PriceLast,
PriceManual,
RawCbrRate,
)
from fintracker.models.sync import (
JobStatus,
RunStatus,
SourceCredential,
SyncJob,
SyncRun,
SyncState,
)
from fintracker.models.tinvest import (
RawTinvestEvent,
RawTinvestInstrument,
RawTinvestOperation,
RawTinvestSnapshot,
)
from fintracker.models.zenmoney import (
CashTxn,
CashTxnTag,
Category,
FlowType,
Merchant,
RawZenmoneyDeletion,
RawZenmoneyEntity,
Rule,
RuleKind,
RuleMatchType,
Trip,
)
__all__ = [
"EXTERNAL_FLOW_KINDS",
"POSITION_KINDS",
"Account",
"AccountKind",
"AccountLink",
"AccountRole",
"AppUser",
"AssetClass",
"Broker",
"CashSnapshot",
"CashTxn",
"CashTxnTag",
"Category",
"CorporateAction",
"CorporateActionKind",
"CorporateActionStatus",
"Event",
"EventKind",
"EventSource",
"EventStatus",
"FlowType",
"FxRateDaily",
"Instrument",
"InstrumentAlias",
"JobStatus",
"Lot",
"LotDisposal",
"Merchant",
"MetricCashFlowMonthly",
"MetricDataQuality",
"MetricHolding",
"MetricNetWorthDaily",
"MetricPortfolioValueDaily",
"MetricRefreshLog",
"MetricReturns",
"MetricRunway",
"MetricSpendingByCategory",
"Portfolio",
"PortfolioAccount",
"PositionSnapshot",
"PriceDaily",
"PriceLast",
"PriceManual",
"RawCbrRate",
"RawTinvestEvent",
"RawTinvestInstrument",
"RawTinvestOperation",
"RawTinvestSnapshot",
"RawZenmoneyDeletion",
"RawZenmoneyEntity",
"RefreshToken",
"Rule",
"RuleKind",
"RuleMatchType",
"RunStatus",
"SourceCredential",
"SyncJob",
"SyncRun",
"SyncState",
"Trip",
]
+116
View File
@@ -0,0 +1,116 @@
"""Accounts, portfolios and the explicit ZenMoney <-> broker account mapping (plan §1.2)."""
from __future__ import annotations
import enum
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import Boolean, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base, TimestampMixin, db_enum
class AccountKind(enum.StrEnum):
zm_cash = "zm_cash"
zm_card = "zm_card"
zm_checking = "zm_checking"
zm_deposit = "zm_deposit"
zm_loan = "zm_loan"
zm_emoney = "zm_emoney"
zm_debt = "zm_debt"
broker = "broker"
manual_asset = "manual_asset"
class AccountRole(enum.StrEnum):
liquid = "liquid"
savings = "savings"
investment = "investment"
debt = "debt"
class Broker(enum.StrEnum):
tinvest = "tinvest"
sber = "sber"
vtb = "vtb"
other = "other"
class EventSource(enum.StrEnum):
"""Which feed is the truth for an account's ledger; others become `shadow` events."""
tinvest_api = "tinvest_api"
report_sber = "report_sber"
report_vtb = "report_vtb"
manual = "manual"
class Account(TimestampMixin, Base):
__tablename__ = "account"
__table_args__ = (UniqueConstraint("source", "source_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
kind: Mapped[AccountKind] = mapped_column(db_enum(AccountKind, "account_kind"))
source: Mapped[str] = mapped_column(String(32))
"""zenmoney | tinvest | report_sber | report_vtb | manual"""
source_id: Mapped[str] = mapped_column(String(128))
broker: Mapped[Broker | None] = mapped_column(db_enum(Broker, "broker"))
name: Mapped[str] = mapped_column(String(256))
currency: Mapped[str] = mapped_column(String(3))
include_in_net_worth: Mapped[bool] = mapped_column(Boolean, default=True)
role: Mapped[AccountRole] = mapped_column(db_enum(AccountRole, "account_role"))
mirror_of_account_id: Mapped[int | None] = mapped_column(
ForeignKey("account.id", ondelete="SET NULL")
)
"""ZenMoney account that merely mirrors a broker account (excluded from net worth)."""
primary_event_source: Mapped[EventSource | None] = mapped_column(
db_enum(EventSource, "event_source")
)
opened_at: Mapped[date | None]
archived: Mapped[bool] = mapped_column(Boolean, default=False)
deposit_terms: Mapped[dict[str, Any] | None]
"""ZenMoney deposit/loan terms: percent, startDate, endDateOffset, capitalization…"""
balance: Mapped[Decimal | None]
"""Current balance as the source reports it (ZenMoney `balance`); native currency."""
start_balance: Mapped[Decimal | None]
credit_limit: Mapped[Decimal | None]
balance_as_of: Mapped[datetime | None]
"""When `balance` was observed (set by the source sync). Informational only: the
net-worth walk in `analytics/networth.py` treats `balance` as the balance of TODAY
(`today_local()`) and steps back by transaction DATES, not by this timestamp."""
class Portfolio(TimestampMixin, Base):
__tablename__ = "portfolio"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128), unique=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False)
base_currency: Mapped[str] = mapped_column(String(3), default="RUB")
class PortfolioAccount(Base):
__tablename__ = "portfolio_account"
portfolio_id: Mapped[int] = mapped_column(
ForeignKey("portfolio.id", ondelete="CASCADE"), primary_key=True
)
account_id: Mapped[int] = mapped_column(
ForeignKey("account.id", ondelete="CASCADE"), primary_key=True
)
class AccountLink(Base):
"""Explicit map used by the ZenMoney-transfer <-> broker-deposit matcher."""
__tablename__ = "account_link"
zm_account_id: Mapped[int] = mapped_column(
ForeignKey("account.id", ondelete="CASCADE"), primary_key=True
)
broker_account_id: Mapped[int] = mapped_column(
ForeignKey("account.id", ondelete="CASCADE"), unique=True
)
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base
class AppUser(Base):
"""The single user of this deployment. Kept as a table so a second one is not a rewrite."""
__tablename__ = "app_user"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True)
password_hash: Mapped[str] = mapped_column(String(512))
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
class RefreshToken(Base):
"""Refresh tokens are stored hashed so logout/revocation is real."""
__tablename__ = "refresh_token"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[int] = mapped_column(ForeignKey("app_user.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), unique=True)
expires_at: Mapped[datetime]
revoked_at: Mapped[datetime | None]
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
@@ -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))
+83
View File
@@ -0,0 +1,83 @@
"""Sync bookkeeping: cursors, run log, manual job queue, per-source credentials."""
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from fintracker.db.base import Base, db_enum
class RunStatus(enum.StrEnum):
running = "running"
ok = "ok"
error = "error"
class JobStatus(enum.StrEnum):
queued = "queued"
running = "running"
done = "done"
error = "error"
class SyncState(Base):
"""One row per source: where the incremental sync left off."""
__tablename__ = "sync_state"
source: Mapped[str] = mapped_column(String(64), primary_key=True)
cursor: Mapped[str | None] = mapped_column(Text)
last_run_at: Mapped[datetime | None]
last_success_at: Mapped[datetime | None]
class SyncRun(Base):
__tablename__ = "sync_run"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
source: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[RunStatus] = mapped_column(db_enum(RunStatus, "run_status"))
triggered_by: Mapped[str] = mapped_column(String(32))
"""schedule | manual | cli"""
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
finished_at: Mapped[datetime | None]
cursor_before: Mapped[str | None] = mapped_column(Text)
cursor_after: Mapped[str | None] = mapped_column(Text)
counts: Mapped[dict[str, Any] | None]
warnings: Mapped[list[Any] | None]
error: Mapped[str | None] = mapped_column(Text)
class SyncJob(Base):
"""Manual trigger queue: API inserts, worker picks up (plan §2.4)."""
__tablename__ = "sync_job"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
source: Mapped[str] = mapped_column(String(64), index=True)
status: Mapped[JobStatus] = mapped_column(db_enum(JobStatus, "job_status"), index=True)
requested_at: Mapped[datetime] = mapped_column(server_default=func.now())
started_at: Mapped[datetime | None]
finished_at: Mapped[datetime | None]
run_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("sync_run.id", ondelete="SET NULL"))
error: Mapped[str | None] = mapped_column(Text)
class SourceCredential(Base):
"""Rotating credentials a source manages itself (e.g. ZenMoney refresh token).
Static tokens still come from the environment; this is for what the worker must
be able to update unattended.
"""
__tablename__ = "source_credential"
source: Mapped[str] = mapped_column(String(64), primary_key=True)
payload: Mapped[dict[str, Any]]
updated_at: Mapped[datetime] = mapped_column(server_default=func.now(), onupdate=func.now())