All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
Deploy / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / build-backend (push) Successful in 1m31s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m33s
Deploy / deploy (push) Successful in 1m12s
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m31s
Расширяет Росстат-скрейпер ЕМИСС/fedstat-рядом «Среднедушевые денежные доходы населения» (fedstat id=57039, OKATO 65=Свердл, квартальный, руб). Добавляет pure SDMX-ML v1.0 GenericData-парсер (stdlib ElementTree, dev-тестируем на фикстурах) + EMISS-ветку Celery-таски rosstat_macro_sync (open-data + ЕМИСС, per-source guard, source='emiss', frequency параметризован, CAST not ::, SAVEPOINT per-row). income_per_capita проброшен в site_finder/macro (region='sverdl'). ЕМИСС за WAF с dev -> fetch исполняется на проде (verified: httpx POST из gendesign-worker-1 -> 200 + SDMX, парсер извлёк 9 Свердл-строк). ИПЦ (id=31074) / индекс цен СМР НЕ landed: многомерные, дефолт-экспорт data.do без Свердл, нужна dataGrid-выборка (selectedFilterIds + filter-tree AJAX + reCAPTCHA) -- задокументировано. Refs #946.
184 lines
10 KiB
Python
184 lines
10 KiB
Python
"""Thin deterministic reader for the unified macro_indicator series table.
|
||
|
||
ТЗ §8.8 / #945 (EPIC «Унифицированная сущность Макропоказатель»), PR A.
|
||
|
||
Архитектурный принцип: ДЕТЕРМИНИРОВАННО, БЕЗ LLM, без внешних вызовов — это просто
|
||
типизированное чтение временных рядов из таблицы macro_indicator (создаётся и
|
||
заполняется миграцией data/sql/123_macro_indicator.sql).
|
||
|
||
Это то, что будут потреблять последующие эпики: Mortgage Affordability Index,
|
||
макро-коэффициент, key-rate sensitivity. Держим helper МИНИМАЛЬНЫМ.
|
||
|
||
psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) —
|
||
никогда :x::type (парсер psycopg3 игнорирует :name::type → SyntaxError).
|
||
|
||
NB: analytics_queries._current_mortgage_rate НЕ трогаем в этом PR (отдельная задача).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import date
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
# ── Маппинг cbr_mortgage_series.series_id → indicator_type (ЗЕРКАЛО SQL-миграции) ─
|
||
# Это pure-Python зеркало CASE series_id-выражения из 123_macro_indicator.sql. Сам
|
||
# backfill делается set-based в SQL (миграция не импортирует Python) — здесь дублируем
|
||
# логику ТОЛЬКО для unit-тестируемости и для возможного переиспользования из Python.
|
||
# При изменении маппинга в миграции — синхронно правьте здесь (и наоборот).
|
||
#
|
||
# Ключ — устойчивый series_id (НЕ title!): в prod строка «в рублях» живёт именно в
|
||
# series_id, а title общий и её не содержит, поэтому матчим series_id точным равенством.
|
||
# Берём ТОЛЬКО _в_рублях-варианты семейства _mortgage_ (ИЖК, форма 02): маппинг и
|
||
# _в_рублях, и _итого на один indicator_type коллиднул бы по PK (type,region,obs_date).
|
||
# НЕ включены: _итого-варианты, _housing_ (02_06/07/08), _scpa_mortgage_ (02_15-18),
|
||
# _Share_ (02_44) — все они дают None и в backfill не попадают.
|
||
_CBR_SERIES_MAP: dict[str, tuple[str, str]] = {
|
||
"02_13_Rates_mortgage_ставка_в_рублях": ("mortgage_rate_weighted", "%"),
|
||
"02_13_Rates_mortgage_срок_в_руб": ("mortgage_term", "мес"),
|
||
"02_10_Quantity_mortgage_в_рублях": ("mortgage_issued_count", "шт"),
|
||
"02_11_New_loans_mortgage_в_рублях": ("mortgage_issued_volume", "млн руб"),
|
||
"02_14_Debt_mortgage_в_рублях": ("mortgage_debt", "млн руб"),
|
||
"02_14_Debt_mortgage_в_т.ч._просроч._в_рублях": ("mortgage_overdue", "млн руб"),
|
||
}
|
||
|
||
|
||
def classify_cbr_series(series_id: str | None) -> tuple[str, str] | None:
|
||
"""Pure helper: cbr_mortgage_series.series_id → (indicator_type, unit) | None.
|
||
|
||
Детерминированно, без БД. Ключуем по УСТОЙЧИВОМУ series_id (не по title — title
|
||
в prod общий и не содержит «в рублях»). Возвращает None для рядов, которые НЕ
|
||
сопоставляются ни одному чистому макротипу (они сознательно пропускаются в backfill:
|
||
все _итого-варианты, _housing_, _scpa_mortgage_, _Share_ и любой неизвестный ключ).
|
||
|
||
Зеркалит CASE series_id-логику миграции 123_macro_indicator.sql (держать в синхроне).
|
||
"""
|
||
if not series_id:
|
||
return None
|
||
return _CBR_SERIES_MAP.get(series_id)
|
||
|
||
|
||
# ── Canonical region per indicator_type ───────────────────────────────────────────
|
||
# Реальная раскладка данных ЦБ в macro_indicator (проверено на prod):
|
||
# • семейство CBR-ипотеки (формы 02, ИЖК) лежит под region='sverdl' — оно
|
||
# бэкфиллится из cbr_mortgage_series, где единственный регион — 'sverdl'
|
||
# (см. 123_macro_indicator.sql §2: region берётся из источника КАК ЕСТЬ);
|
||
# • key_rate (и прочие национальные ряды) — под region='rf'.
|
||
# Это НЕ опечатка в данных, а фактический layout. Поэтому дефолтный регион ридера
|
||
# должен быть indicator-aware: иначе чтение mortgage_*-ряда без явного region молча
|
||
# вернёт пусто (footgun, #945 follow-up). Множество ипотечных типов берём как
|
||
# values() из _CBR_SERIES_MAP — единый источник правды, синхронный с миграцией.
|
||
_MORTGAGE_INDICATOR_TYPES: frozenset[str] = frozenset(
|
||
itype for itype, _unit in _CBR_SERIES_MAP.values()
|
||
)
|
||
|
||
# Региональные ряды Росстата (#946) кладутся под region='sverdl' (субъект-агрегат) и
|
||
# 'ekb' (г. Екатеринбург), НЕ под 'rf'. Два источника:
|
||
# • open-data (source='rosstat'): population_total (ОКТМО 65 / ЕКБ);
|
||
# • ЕМИСС/fedstat (source='emiss'): income_per_capita (среднедушевые доходы, ОКАТО 65 —
|
||
# только субъект, ЕКБ-среза в ОКАТО-размерности ЕМИСС нет).
|
||
# Дефолтный region таких типов = 'sverdl' (субъект — основной уровень потребления
|
||
# downstream affordability/forecast). Явный region='ekb' читатель задаёт сам.
|
||
# Источник правды по типам — DatasetSpec/EmissIndicatorSpec в rosstat_emiss (синхронить).
|
||
_ROSSTAT_SVERDL_INDICATOR_TYPES: frozenset[str] = frozenset(
|
||
{"population_total", "income_per_capita"}
|
||
)
|
||
|
||
|
||
def _canonical_region(indicator_type: str) -> str:
|
||
"""Канонический region для indicator_type, когда вызыватель не задал явный.
|
||
|
||
Детерминированно, без БД:
|
||
• 6 CBR-ипотечных типов (mortgage_*) → 'sverdl' (так лежат на prod);
|
||
• региональные ряды Росстата (population_total, income_per_capita, #946) → 'sverdl';
|
||
• всё остальное (key_rate и пр.) → 'rf'.
|
||
Источники истины — _CBR_SERIES_MAP (зеркало миграции 123) и _ROSSTAT_SVERDL_INDICATOR_TYPES
|
||
(зеркало DatasetSpec'ов rosstat_emiss).
|
||
"""
|
||
if indicator_type in _MORTGAGE_INDICATOR_TYPES:
|
||
return "sverdl"
|
||
if indicator_type in _ROSSTAT_SVERDL_INDICATOR_TYPES:
|
||
return "sverdl"
|
||
return "rf"
|
||
|
||
|
||
# ── Reader ──────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def get_macro_series(
|
||
db: Session,
|
||
indicator_type: str,
|
||
region: str | None = None,
|
||
since: date | None = None,
|
||
) -> list[tuple[date, float]]:
|
||
"""Весь ряд (obs_date, value) для (indicator_type, region), ASC по obs_date.
|
||
|
||
Args:
|
||
db: SQLAlchemy sync Session.
|
||
indicator_type: напр. 'mortgage_rate_weighted', 'key_rate'.
|
||
region: 'rf' | 'sverdl' | ... Если None — резолвится indicator-aware дефолт
|
||
через _canonical_region (CBR-ипотека → 'sverdl', остальное → 'rf'),
|
||
т.к. на prod эти семейства лежат в РАЗНЫХ регионах. Явный region
|
||
ВСЕГДА выигрывает (вызыватель может переопределить дефолт).
|
||
since: если задано — только obs_date >= since.
|
||
|
||
Returns:
|
||
Список (obs_date, value) по возрастанию даты; строки с NULL value отброшены.
|
||
"""
|
||
if region is None:
|
||
region = _canonical_region(indicator_type)
|
||
rows = db.execute(
|
||
text(
|
||
"""
|
||
SELECT obs_date, value
|
||
FROM macro_indicator
|
||
WHERE indicator_type = CAST(:itype AS text)
|
||
AND region = CAST(:region AS text)
|
||
AND value IS NOT NULL
|
||
AND (CAST(:since AS date) IS NULL OR obs_date >= CAST(:since AS date))
|
||
ORDER BY obs_date ASC
|
||
"""
|
||
),
|
||
{"itype": indicator_type, "region": region, "since": since},
|
||
).all()
|
||
return [(r[0], float(r[1])) for r in rows]
|
||
|
||
|
||
def get_latest_macro(
|
||
db: Session,
|
||
indicator_type: str,
|
||
region: str | None = None,
|
||
) -> tuple[date, float] | None:
|
||
"""Последняя (по obs_date) непустая точка ряда или None, если ряда нет.
|
||
|
||
Args:
|
||
db: SQLAlchemy sync Session.
|
||
indicator_type: тип показателя.
|
||
region: 'rf' | 'sverdl' | ... Если None — резолвится indicator-aware дефолт
|
||
через _canonical_region (CBR-ипотека → 'sverdl', остальное → 'rf'):
|
||
на prod эти семейства лежат в РАЗНЫХ регионах. Явный region ВСЕГДА
|
||
выигрывает (вызыватель может переопределить дефолт).
|
||
|
||
Returns:
|
||
(obs_date, value) самой свежей точки, либо None.
|
||
"""
|
||
if region is None:
|
||
region = _canonical_region(indicator_type)
|
||
row = db.execute(
|
||
text(
|
||
"""
|
||
SELECT obs_date, value
|
||
FROM macro_indicator
|
||
WHERE indicator_type = CAST(:itype AS text)
|
||
AND region = CAST(:region AS text)
|
||
AND value IS NOT NULL
|
||
ORDER BY obs_date DESC
|
||
LIMIT 1
|
||
"""
|
||
),
|
||
{"itype": indicator_type, "region": region},
|
||
).first()
|
||
if row is None:
|
||
return None
|
||
return row[0], float(row[1])
|