feat(db): flow_link, metric_cash_flow_broker и price_coverage
Три таблицы одной миграцией, а не тремя: автогенерация из трёх параллельных веток дала бы три ревизии с общим down_revision, то есть ручную разборку ветвления вместо экономии. price_coverage засевается прямо в миграции из price_daily: она отвечает на вопрос «с какой даты мы УЖЕ спрашивали ISS», и без засева первый же прогон moex перекачал бы историю всех 77 бумаг целиком. flow_link_kind снимается на откате явно. DROP TABLE оставляет тип в базе, и следующий upgrade упал бы на CREATE TYPE — то же, что уже сделано для остальных енумов домена.
This commit is contained in:
@@ -18,12 +18,15 @@ from fintracker.models.ledger import (
|
||||
Event,
|
||||
EventKind,
|
||||
EventStatus,
|
||||
FlowLink,
|
||||
FlowLinkKind,
|
||||
Lot,
|
||||
LotDisposal,
|
||||
)
|
||||
from fintracker.models.metrics import (
|
||||
AllocationDimension,
|
||||
MetricAllocation,
|
||||
MetricCashFlowBroker,
|
||||
MetricCashFlowMonthly,
|
||||
MetricDataQuality,
|
||||
MetricHolding,
|
||||
@@ -41,6 +44,7 @@ from fintracker.models.pricing import (
|
||||
CorporateActionStatus,
|
||||
FxRateDaily,
|
||||
PositionSnapshot,
|
||||
PriceCoverage,
|
||||
PriceDaily,
|
||||
PriceLast,
|
||||
PriceManual,
|
||||
@@ -96,6 +100,8 @@ __all__ = [
|
||||
"EventKind",
|
||||
"EventSource",
|
||||
"EventStatus",
|
||||
"FlowLink",
|
||||
"FlowLinkKind",
|
||||
"FlowType",
|
||||
"FxRateDaily",
|
||||
"Instrument",
|
||||
@@ -105,6 +111,7 @@ __all__ = [
|
||||
"LotDisposal",
|
||||
"Merchant",
|
||||
"MetricAllocation",
|
||||
"MetricCashFlowBroker",
|
||||
"MetricCashFlowMonthly",
|
||||
"MetricDataQuality",
|
||||
"MetricHolding",
|
||||
@@ -117,6 +124,7 @@ __all__ = [
|
||||
"Portfolio",
|
||||
"PortfolioAccount",
|
||||
"PositionSnapshot",
|
||||
"PriceCoverage",
|
||||
"PriceDaily",
|
||||
"PriceLast",
|
||||
"PriceManual",
|
||||
|
||||
@@ -180,3 +180,43 @@ class LotDisposal(Base):
|
||||
holding_days: Mapped[int] = mapped_column(Integer)
|
||||
ldv_eligible: Mapped[bool]
|
||||
"""Held 3+ years on an exchange-traded instrument (art. 219.1 NK)."""
|
||||
|
||||
|
||||
class FlowLinkKind(enum.StrEnum):
|
||||
auto = "auto"
|
||||
"""Produced by `ledger/matching.py`; rebuilt from scratch on every refresh."""
|
||||
manual = "manual"
|
||||
"""Confirmed by the user through the API; never touched by the matcher."""
|
||||
|
||||
|
||||
class FlowLink(TimestampMixin, Base):
|
||||
"""One ZenMoney transfer tied to the broker deposit/withdrawal it actually was (plan §1.6 C).
|
||||
|
||||
Without this pairing the same money is counted twice — once as the balance of the ZenMoney
|
||||
account that mirrors the broker, once as the broker's own cash — and a top-up looks like an
|
||||
expense in the cash flow. The unique constraints on both sides are what make the link a
|
||||
1:1 statement: one transfer, one broker event, never a fan-out.
|
||||
|
||||
The scoring fields are kept because a link is a *guess*: `amount_delta` and `day_gap` are
|
||||
what the reviewer needs in `GET /links/unmatched` to tell a good pairing from a lucky one.
|
||||
"""
|
||||
|
||||
__tablename__ = "flow_link"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
cash_txn_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("cash_txn.id", ondelete="CASCADE"), unique=True
|
||||
)
|
||||
event_id: Mapped[int] = mapped_column(ForeignKey("event.id", ondelete="CASCADE"), unique=True)
|
||||
kind: Mapped[FlowLinkKind] = mapped_column(
|
||||
db_enum(FlowLinkKind, "flow_link_kind"), default=FlowLinkKind.auto
|
||||
)
|
||||
confidence: Mapped[Decimal]
|
||||
"""0..1; 1 means same day and the same kopeck."""
|
||||
amount_delta: Mapped[Decimal]
|
||||
"""|ZenMoney amount - broker amount|, in `currency` — both sides share it by construction."""
|
||||
currency: Mapped[str] = mapped_column(String(3))
|
||||
day_gap: Mapped[int] = mapped_column(Integer)
|
||||
"""Business days between the two dates: money does not reach a broker over a weekend."""
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
"""Why the pair was accepted (which route identified the broker account), for debugging."""
|
||||
|
||||
@@ -48,6 +48,40 @@ class MetricCashFlowMonthly(Base):
|
||||
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class MetricCashFlowBroker(Base):
|
||||
"""Money put into and taken out of the brokerage accounts, per scope and month.
|
||||
|
||||
The sibling of `metric_cash_flow_monthly`, for the other half of the picture: that one
|
||||
answers what the household earned and spent, this one what it moved across the portfolio
|
||||
boundary. The scope key is the same string as everywhere else in the investment metrics
|
||||
(`all`, `account:<id>`, `portfolio:<id>`).
|
||||
|
||||
Deposits and withdrawals are kept apart, both as positive magnitudes, because the screen
|
||||
shows both bars: a month that took 200 000 ₽ in and 200 000 ₽ out is not the same month
|
||||
as one that saw no money at all, and a single signed sum cannot tell them apart.
|
||||
`net_rub = deposits_rub - withdrawals_rub` and equals the month's sum of
|
||||
`metric_portfolio_value_daily.external_flow_rub` for the same scope.
|
||||
|
||||
A month with no flow gets no row at all — the series is sparse on purpose, so the client
|
||||
can tell "nothing happened" from "zero on balance".
|
||||
"""
|
||||
|
||||
__tablename__ = "metric_cash_flow_broker"
|
||||
|
||||
scope: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
month: Mapped[date] = mapped_column(primary_key=True)
|
||||
"""First day of the month."""
|
||||
deposits_rub: Mapped[Decimal]
|
||||
"""Everything that came in, positive: cash deposits and securities transferred in."""
|
||||
withdrawals_rub: Mapped[Decimal]
|
||||
"""Everything that went out, also positive."""
|
||||
net_rub: Mapped[Decimal]
|
||||
"""deposits - withdrawals; negative in a month that took more out than it put in."""
|
||||
event_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
"""Flow events behind the row — the ones that converted; see `metric_data_quality`."""
|
||||
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
|
||||
class MetricSpendingByCategory(Base):
|
||||
__tablename__ = "metric_spending_by_category"
|
||||
__table_args__ = (UniqueConstraint("month", "category_id", postgresql_nulls_not_distinct=True),)
|
||||
|
||||
@@ -166,3 +166,22 @@ class CashSnapshot(Base):
|
||||
source: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||
balance: Mapped[Decimal]
|
||||
blocked: Mapped[Decimal | None]
|
||||
|
||||
|
||||
class PriceCoverage(Base):
|
||||
"""How far back a price source has already been asked for an instrument.
|
||||
|
||||
Without it a backfill has no memory: the sync would either re-read the whole history on
|
||||
every run, or (reading only `min(price_daily.d)`) keep re-asking forever for a stretch
|
||||
the exchange simply has nothing for. `history_from` is the earliest date we *requested*,
|
||||
not the earliest we got.
|
||||
"""
|
||||
|
||||
__tablename__ = "price_coverage"
|
||||
|
||||
instrument_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("instrument.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
source: Mapped[str] = mapped_column(String(16), primary_key=True)
|
||||
history_from: Mapped[date]
|
||||
updated_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
Reference in New Issue
Block a user