gendesign/backend/app/services/site_finder/macro.py
Light1YT 3945d54e3b
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m53s
Deploy / build-worker (push) Successful in 3m19s
Deploy / deploy (push) Successful in 1m10s
fix(#945): indicator-aware default region in macro reader + document debt data-quality
FOLLOW-UP (Leha: not a bug, follow-up).

get_macro_series / get_latest_macro defaulted region='rf', but CBR mortgage_*
series live under 'sverdl' in macro_indicator (key_rate under 'rf'). Only caller
passes region explicitly so no active leak, but a future caller omitting region
would silently get None — latent footgun. region now str|None; when None →
_canonical_region (mortgage set sourced from _CBR_SERIES_MAP, single source of
truth). Explicit region always wins → existing callers unchanged. Region-binding
tests added. 159 tests pass.

Documented (NOT code-fixed — upstream data-quality): mortgage_debt /
mortgage_overdue stay empty in macro_indicator because cbr_mortgage_series.period
holds value-like garbage (e.g. '10054588.0') for the Debt series — corruption
from the upstream CBR-XLSX scraper that built domrf.db (imported verbatim by
44_import_anton_db.py), NOT an ingest bug. 123_macro_indicator.sql correctly
skips unparseable periods. Needs a separate scraper re-ingest (idempotent
backfill once period is valid). Refs #945
2026-06-04 11:49:41 +05:00

165 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()
)
def _canonical_region(indicator_type: str) -> str:
"""Канонический region для indicator_type, когда вызыватель не задал явный.
Детерминированно, без БД. 6 CBR-ипотечных типов (mortgage_*) → 'sverdl'
(так они лежат в macro_indicator на prod); всё остальное (key_rate и пр.) → 'rf'.
Источник истины по ипотечному множеству — _CBR_SERIES_MAP (зеркало миграции).
"""
return "sverdl" if indicator_type in _MORTGAGE_INDICATOR_TYPES else "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])