POST /metrics/refresh ставит задачу в sync_job (source=METRICS_JOB) и отвечает 202, пересчёт делает worker; GET /metrics/status отдаёт refreshing и consistent. metric_refresh_log хранит failed_step и step_timings. Источники с needs="tinvest_token" не попадают в расписание без токена, tinvest/moex добавлены в default_schedule. Воркер трогает heartbeat-файл для healthcheck.
484 lines
22 KiB
Python
484 lines
22 KiB
Python
"""Precomputed metric tables served verbatim by the API (plan §3). Every table is rebuilt
|
|
wholesale inside one transaction by metrics/refresh.py; nothing else writes here."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import enum
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
|
|
from fintracker.db.base import Base, db_enum
|
|
|
|
|
|
class MetricNetWorthDaily(Base):
|
|
__tablename__ = "metric_net_worth_daily"
|
|
|
|
d: Mapped[date] = mapped_column(primary_key=True)
|
|
total_rub: Mapped[Decimal]
|
|
liquid_rub: Mapped[Decimal]
|
|
savings_rub: Mapped[Decimal]
|
|
investment_rub: Mapped[Decimal]
|
|
debt_rub: Mapped[Decimal]
|
|
"""Negative or zero: loans and credit-card debt."""
|
|
by_currency: Mapped[dict[str, Any] | None]
|
|
"""{ccy: native amount} across all accounts, before conversion."""
|
|
missing_fx_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricCashFlowMonthly(Base):
|
|
__tablename__ = "metric_cash_flow_monthly"
|
|
|
|
month: Mapped[date] = mapped_column(primary_key=True)
|
|
"""First day of the month."""
|
|
income_rub: Mapped[Decimal]
|
|
expense_rub: Mapped[Decimal]
|
|
"""All consumption incl. one-offs; excludes transfers and savings."""
|
|
baseline_rub: Mapped[Decimal]
|
|
"""expense minus one-offs — what runway divides by."""
|
|
one_off_rub: Mapped[Decimal]
|
|
savings_transfer_rub: Mapped[Decimal]
|
|
savings_rate: Mapped[Decimal | None]
|
|
"""(income - expense) / income, NULL when income == 0."""
|
|
txn_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
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),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
month: Mapped[date] = mapped_column(index=True)
|
|
category_id: Mapped[int | None] = mapped_column(ForeignKey("category.id", ondelete="CASCADE"))
|
|
"""NULL = uncategorised."""
|
|
root_category_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("category.id", ondelete="CASCADE")
|
|
)
|
|
amount_rub: Mapped[Decimal]
|
|
txn_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricRunway(Base):
|
|
__tablename__ = "metric_runway"
|
|
|
|
as_of: Mapped[date] = mapped_column(primary_key=True)
|
|
liquid_reserve_rub: Mapped[Decimal]
|
|
avg_baseline_3m_rub: Mapped[Decimal]
|
|
runway_months: Mapped[Decimal | None]
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricDataQuality(Base):
|
|
"""One row per finding; the whole table is replaced on refresh."""
|
|
|
|
__tablename__ = "metric_data_quality"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
check_name: Mapped[str] = mapped_column(String(64), index=True)
|
|
severity: Mapped[str] = mapped_column(String(8))
|
|
"""info | warn | error"""
|
|
detail: Mapped[str] = mapped_column(Text)
|
|
count: Mapped[int] = mapped_column(Integer, default=1)
|
|
ref: Mapped[dict[str, Any] | None]
|
|
"""Pointers for the UI: {"cash_txn_id": …} / {"rule_id": …} / {"ccy": …}."""
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricRefreshLog(Base):
|
|
"""When metrics were last rebuilt and why; the API exposes it as `as_of`."""
|
|
|
|
__tablename__ = "metric_refresh_log"
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
started_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
finished_at: Mapped[datetime | None]
|
|
trigger: Mapped[str] = mapped_column(String(32))
|
|
"""sync:<source> | manual | cli"""
|
|
error: Mapped[str | None] = mapped_column(Text)
|
|
failed_step: Mapped[str | None] = mapped_column(String(64))
|
|
"""Name of the step that raised. Steps before it committed and the ones after it did not
|
|
run, so the metric tables then come from two different runs."""
|
|
step_timings: Mapped[dict[str, Any] | None]
|
|
"""Seconds per step that completed, in run order."""
|
|
|
|
|
|
class MetricPortfolioValueDaily(Base):
|
|
"""Daily value of a scope: what the position was worth, in RUB, on every calendar day.
|
|
|
|
A scope is a set of accounts, named by a string so the table serves all of them at once:
|
|
`all`, `account:<id>`, `portfolio:<id>`. Sums are RUB at the rate of THAT day, so a
|
|
foreign-currency holding moves with the rate even on a day it did not trade.
|
|
|
|
Instruments whose price or rate is missing are left out of the sums and counted in
|
|
`missing_price_count` / `missing_fx_count` instead of being valued at zero: the chart
|
|
needs a number, the counters say how much of one it is. A price older than
|
|
`valuation.STALE_AFTER_DAYS` is still used, but counted as stale.
|
|
"""
|
|
|
|
__tablename__ = "metric_portfolio_value_daily"
|
|
|
|
scope: Mapped[str] = mapped_column(String(32), primary_key=True)
|
|
d: Mapped[date] = mapped_column(primary_key=True)
|
|
market_value_rub: Mapped[Decimal]
|
|
"""Securities at close; bonds include their accrued interest."""
|
|
accrued_interest_rub: Mapped[Decimal]
|
|
"""The НКД part of `market_value_rub`, broken out."""
|
|
cash_rub: Mapped[Decimal]
|
|
total_rub: Mapped[Decimal]
|
|
external_flow_rub: Mapped[Decimal]
|
|
"""Net contribution on this day: + into the portfolio, - out of it."""
|
|
unvalued_flow_rub: Mapped[Decimal]
|
|
"""Cash that crossed into (negative) or out of (positive) a position with no price.
|
|
It is not an external flow — it never left the portfolio — but for a time-weighted
|
|
return it behaves like one, because the paper it bought is absent from `market_value_rub`."""
|
|
invested_net_rub: Mapped[Decimal]
|
|
"""Cumulative external flow up to and including this day."""
|
|
pnl_total_rub: Mapped[Decimal | None]
|
|
"""total - invested_net: everything made so far (realised, unrealised and income).
|
|
NULL on a day where a price or a rate was missing, since the total is then incomplete."""
|
|
stale_price_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
missing_price_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
missing_fx_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricHolding(Base):
|
|
"""Current position per (scope, instrument): what it is worth and what it cost.
|
|
|
|
Everything RUB-denominated is NULL — never zero — when the price or the rate for it is
|
|
missing, which is what `price_status` names. `qty` is signed: a short position is
|
|
negative, exactly as `lot.qty_remaining` stores it.
|
|
"""
|
|
|
|
__tablename__ = "metric_holding"
|
|
__table_args__ = (UniqueConstraint("scope", "instrument_id"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
scope: Mapped[str] = mapped_column(String(32), index=True)
|
|
instrument_id: Mapped[int] = mapped_column(ForeignKey("instrument.id", ondelete="CASCADE"))
|
|
qty: Mapped[Decimal]
|
|
avg_cost: Mapped[Decimal | None]
|
|
"""Weighted cost per unit across the open lots, in `cost_currency`."""
|
|
cost_currency: Mapped[str | None] = mapped_column(String(3))
|
|
cost_total_rub: Mapped[Decimal | None]
|
|
market_price: Mapped[Decimal | None]
|
|
price_currency: Mapped[str | None] = mapped_column(String(3))
|
|
price_date: Mapped[date | None]
|
|
price_status: Mapped[str] = mapped_column(String(8), default="ok")
|
|
"""ok | stale | missing — `missing` is why the value columns are NULL."""
|
|
value_native: Mapped[Decimal | None]
|
|
value_rub: Mapped[Decimal | None]
|
|
accrued_interest_rub: Mapped[Decimal | None]
|
|
unrealized_pnl_native: Mapped[Decimal | None]
|
|
unrealized_pnl_rub: Mapped[Decimal | None]
|
|
realized_pnl_rub: Mapped[Decimal | None]
|
|
"""Cumulative over all disposals of this instrument in the scope."""
|
|
income_rub: Mapped[Decimal | None]
|
|
"""Cumulative dividends, coupons and amortisation received, net of tax."""
|
|
weight: Mapped[Decimal | None]
|
|
"""Share of the scope's valued market value; NULL when this holding has no value."""
|
|
xirr: Mapped[Decimal | None]
|
|
"""Money-weighted return of this instrument alone, filled by analytics/returns.py."""
|
|
first_buy_date: Mapped[date | None]
|
|
days_held: Mapped[int | None]
|
|
ldv_eligible_qty: Mapped[Decimal]
|
|
"""Quantity held 3+ years on an exchange-traded instrument (art. 219.1 NK)."""
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricReturns(Base):
|
|
"""XIRR and TWR per (scope, period). Rates are fractions: 0.1 means 10 %."""
|
|
|
|
__tablename__ = "metric_returns"
|
|
__table_args__ = (UniqueConstraint("scope", "period"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
scope: Mapped[str] = mapped_column(String(32), index=True)
|
|
period: Mapped[str] = mapped_column(String(8))
|
|
"""1m | 3m | 6m | ytd | 1y | 3y | all"""
|
|
date_from: Mapped[date]
|
|
date_to: Mapped[date]
|
|
value_start_rub: Mapped[Decimal]
|
|
value_end_rub: Mapped[Decimal]
|
|
external_flow_rub: Mapped[Decimal]
|
|
"""Net contribution over the period."""
|
|
abs_pnl_rub: Mapped[Decimal]
|
|
"""end - start - net contribution: the money actually made."""
|
|
xirr: Mapped[Decimal | None]
|
|
"""Annualised money-weighted return; NULL when the flows admit no solution."""
|
|
twr: Mapped[Decimal | None]
|
|
"""Cumulative time-weighted return over the period, not annualised."""
|
|
twr_annualized: Mapped[Decimal | None]
|
|
"""TWR scaled to a year; NULL for periods shorter than one."""
|
|
twr_days_skipped: Mapped[int] = mapped_column(Integer, default=0)
|
|
"""Days left out of the chain because the portfolio could not be valued in full on them.
|
|
Non-zero means `twr` covers only part of the period."""
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class AllocationDimension(enum.StrEnum):
|
|
"""How a portfolio can be sliced. Every dimension covers the SAME total — securities
|
|
plus cash — so the weights of any one of them add up to 1 and the charts agree."""
|
|
|
|
asset_class = "asset_class"
|
|
sector = "sector"
|
|
country = "country"
|
|
currency = "currency"
|
|
|
|
|
|
class MetricAllocation(Base):
|
|
"""Portfolio split per (scope, dimension, bucket). Target weights arrive in phase 4.
|
|
|
|
`bucket` is a stable key, not a label: an asset class as stored, a sector or country as
|
|
the source spells it, a currency code, plus two literals — `cash` for money and `unknown`
|
|
for an instrument whose attribute nobody filled in. The client decides how to say those
|
|
in Russian; inventing a label here would bake one language into the data.
|
|
"""
|
|
|
|
__tablename__ = "metric_allocation"
|
|
__table_args__ = (UniqueConstraint("scope", "dimension", "bucket"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
scope: Mapped[str] = mapped_column(String(32), index=True)
|
|
dimension: Mapped[AllocationDimension] = mapped_column(
|
|
db_enum(AllocationDimension, "allocation_dimension")
|
|
)
|
|
bucket: Mapped[str] = mapped_column(String(64))
|
|
value_rub: Mapped[Decimal]
|
|
weight: Mapped[Decimal]
|
|
"""Share of the scope's valued total; the weights of one dimension add up to 1."""
|
|
holding_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
"""Instruments in this bucket; 0 for the cash bucket."""
|
|
target_weight: Mapped[Decimal | None]
|
|
"""From `portfolio_target`; NULL when the user set no target for this bucket."""
|
|
drift: Mapped[Decimal | None]
|
|
"""`weight - target_weight` in percentage points of the whole, NULL without a target."""
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class IncomeBasis(enum.StrEnum):
|
|
"""Where a calendar entry's number comes from — the honesty column.
|
|
|
|
A bond's future coupon is arithmetic on a published schedule; an announced dividend is a
|
|
fact the issuer stated; a projected dividend is an extrapolation from history and can be
|
|
wrong by any amount. Mixing the three into one «expected income» figure is the fastest
|
|
way to make a forecast that nobody can audit.
|
|
"""
|
|
|
|
schedule = "schedule"
|
|
"""Bond coupon from the payment schedule and the nominal in force on that date."""
|
|
announced = "announced"
|
|
"""Declared by the issuer: a `corporate_action` with a future record date."""
|
|
history = "history"
|
|
"""Extrapolated from the last 24 months — a guess, labelled as one."""
|
|
paid = "paid"
|
|
"""Already received; the entry is history, not a forecast."""
|
|
|
|
|
|
class MetricIncomeCalendar(Base):
|
|
"""Dividends and coupons, past and expected, per scope and instrument (plan §3).
|
|
|
|
Rows are per payment, not per month, because the screen the user actually wants answers
|
|
«what lands, and when» — and because an amount that turns out wrong has to be traceable
|
|
to the single assumption that produced it (`basis`, `per_unit`, `qty`).
|
|
"""
|
|
|
|
__tablename__ = "metric_income_calendar"
|
|
__table_args__ = (UniqueConstraint("scope", "instrument_id", "kind", "expected_date", "basis"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
scope: Mapped[str] = mapped_column(String(32), index=True)
|
|
instrument_id: Mapped[int] = mapped_column(ForeignKey("instrument.id", ondelete="CASCADE"))
|
|
kind: Mapped[str] = mapped_column(String(16))
|
|
"""dividend | coupon | amortization | repayment — the `corporate_action` kinds that pay."""
|
|
expected_date: Mapped[date] = mapped_column(index=True)
|
|
record_date: Mapped[date | None]
|
|
qty: Mapped[Decimal]
|
|
"""Position held when the payment is expected; 0 once the position is gone."""
|
|
per_unit: Mapped[Decimal | None]
|
|
amount: Mapped[Decimal]
|
|
currency: Mapped[str] = mapped_column(String(3))
|
|
amount_rub: Mapped[Decimal | None]
|
|
"""NULL when the date has no FX rate — never a substituted number."""
|
|
basis: Mapped[IncomeBasis] = mapped_column(db_enum(IncomeBasis, "income_basis"))
|
|
tax_withheld: Mapped[Decimal | None]
|
|
"""What the broker already held back, for payments that happened."""
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricIncomeMonthly(Base):
|
|
"""Income actually received, per scope, month and kind — the history half of the screen."""
|
|
|
|
__tablename__ = "metric_income_monthly"
|
|
__table_args__ = (UniqueConstraint("scope", "month", "kind", "currency"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
scope: Mapped[str] = mapped_column(String(32), index=True)
|
|
month: Mapped[date]
|
|
"""First day of the month."""
|
|
kind: Mapped[str] = mapped_column(String(16))
|
|
currency: Mapped[str] = mapped_column(String(3))
|
|
amount: Mapped[Decimal]
|
|
amount_rub: Mapped[Decimal | None]
|
|
tax_withheld: Mapped[Decimal]
|
|
payment_count: Mapped[int] = mapped_column(Integer, default=0)
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricBenchmarkReturns(Base):
|
|
"""A benchmark's TWR over the same period and date grid as `metric_returns`.
|
|
|
|
Kept beside the portfolio's own returns rather than inside them: the set of benchmarks is
|
|
a user's choice, and a column per index would mean a migration per index. Comparing
|
|
requires the same grid — a benchmark measured over a different set of days is not a
|
|
comparison, so `date_from`/`date_to` are stored and checked.
|
|
"""
|
|
|
|
__tablename__ = "metric_benchmark_returns"
|
|
__table_args__ = (UniqueConstraint("scope", "period", "benchmark_id"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
scope: Mapped[str] = mapped_column(String(32), index=True)
|
|
period: Mapped[str] = mapped_column(String(8))
|
|
benchmark_id: Mapped[int] = mapped_column(ForeignKey("benchmark.id", ondelete="CASCADE"))
|
|
date_from: Mapped[date]
|
|
date_to: Mapped[date]
|
|
twr: Mapped[Decimal | None]
|
|
twr_annualized: Mapped[Decimal | None]
|
|
days_skipped: Mapped[int] = mapped_column(Integer, default=0)
|
|
"""Days the index had no quote for. Non-zero means the comparison is not like-for-like."""
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricRebalance(Base):
|
|
"""What to buy or sell to reach the target weights (plan §3).
|
|
|
|
`suggested_qty` respects the lot size and the cash on hand, because a recommendation that
|
|
cannot be executed is not a recommendation. It is whole lots, never a fractional share.
|
|
"""
|
|
|
|
__tablename__ = "metric_rebalance"
|
|
__table_args__ = (UniqueConstraint("portfolio_id", "dimension", "bucket", "instrument_id"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
portfolio_id: Mapped[int] = mapped_column(ForeignKey("portfolio.id", ondelete="CASCADE"))
|
|
dimension: Mapped[AllocationDimension] = mapped_column(
|
|
db_enum(AllocationDimension, "allocation_dimension")
|
|
)
|
|
bucket: Mapped[str] = mapped_column(String(64))
|
|
instrument_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("instrument.id", ondelete="CASCADE")
|
|
)
|
|
"""NULL on the bucket's own summary row; set on each proposed trade inside it."""
|
|
current_value_rub: Mapped[Decimal]
|
|
current_weight: Mapped[Decimal]
|
|
target_weight: Mapped[Decimal | None]
|
|
delta_value_rub: Mapped[Decimal]
|
|
"""Positive: buy this much. Negative: sell."""
|
|
suggested_qty: Mapped[Decimal | None]
|
|
"""Whole lots; NULL when the instrument has no usable price."""
|
|
lot: Mapped[int | None] = mapped_column(Integer)
|
|
price: Mapped[Decimal | None]
|
|
price_currency: Mapped[str | None] = mapped_column(String(3))
|
|
within_band: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
"""True when the drift is inside `portfolio_target.band` — shown, but not acted on."""
|
|
blocked_by_cash: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
"""A buy the available cash does not cover; the quantity is what the cash does allow."""
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricTaxYear(Base):
|
|
"""The tax picture per calendar year and account (plan §3, ст. 214.1 / 219.1 НК).
|
|
|
|
Estimated, and labelled estimated everywhere it surfaces: the broker is the tax agent and
|
|
the authoritative number is its own certificate. This exists to make that certificate
|
|
checkable and to show the cost of selling a position before the three-year mark.
|
|
"""
|
|
|
|
__tablename__ = "metric_tax_year"
|
|
__table_args__ = (UniqueConstraint("year", "account_id"),)
|
|
|
|
id: Mapped[int] = mapped_column(primary_key=True)
|
|
year: Mapped[int] = mapped_column(Integer, index=True)
|
|
account_id: Mapped[int] = mapped_column(ForeignKey("account.id", ondelete="CASCADE"))
|
|
dividends_gross_rub: Mapped[Decimal]
|
|
coupons_gross_rub: Mapped[Decimal]
|
|
tax_withheld_rub: Mapped[Decimal]
|
|
"""What the broker already took — the difference between gross and what arrived."""
|
|
realized_gain_rub: Mapped[Decimal]
|
|
"""FIFO result in roubles, each leg at the CBR rate of its own date (currency revaluation
|
|
is part of the base, plan §7 вопрос 4)."""
|
|
realized_loss_rub: Mapped[Decimal]
|
|
ldv_exempt_rub: Mapped[Decimal]
|
|
"""Gain on lots held 3+ years, exempt under art. 219.1 — an estimate, see the docstring."""
|
|
taxable_base_rub: Mapped[Decimal]
|
|
estimated_tax_rub: Mapped[Decimal]
|
|
tax_rate: Mapped[Decimal]
|
|
"""The rate applied, so a changed rate is visible rather than baked into the number."""
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
|
|
|
|
|
class MetricGoalProgress(Base):
|
|
"""Where a goal stands and when it is projected to be met."""
|
|
|
|
__tablename__ = "metric_goal_progress"
|
|
|
|
goal_id: Mapped[int] = mapped_column(
|
|
ForeignKey("goal.id", ondelete="CASCADE"), primary_key=True
|
|
)
|
|
as_of: Mapped[date]
|
|
current_value_rub: Mapped[Decimal]
|
|
target_amount_rub: Mapped[Decimal]
|
|
progress: Mapped[Decimal]
|
|
"""current / target, clamped to [0, …]; can exceed 1 for a goal already met."""
|
|
projected_date: Mapped[date | None]
|
|
"""NULL when the trend does not reach the target (flat or falling), never a far-future
|
|
date pretending to be an answer."""
|
|
basis: Mapped[str] = mapped_column(String(16))
|
|
"""xirr | contribution | none — which assumption produced `projected_date`."""
|
|
assumed_rate: Mapped[Decimal | None]
|
|
monthly_needed_rub: Mapped[Decimal | None]
|
|
"""Contribution required to hit `target_date`; NULL when the goal has no deadline."""
|
|
on_track: Mapped[bool | None]
|
|
computed_at: Mapped[datetime] = mapped_column(server_default=func.now())
|