fix(#945): indicator-aware macro region + debt data-quality doc #1041
2 changed files with 127 additions and 13 deletions
|
|
@ -59,25 +59,56 @@ def classify_cbr_series(series_id: str | None) -> tuple[str, str] | 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 = "rf",
|
||||
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' (key_rate заполнит PR B).
|
||||
region: 'rf' | 'sverdl' | ... (по умолчанию 'rf').
|
||||
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(
|
||||
"""
|
||||
|
|
@ -98,18 +129,23 @@ def get_macro_series(
|
|||
def get_latest_macro(
|
||||
db: Session,
|
||||
indicator_type: str,
|
||||
region: str = "rf",
|
||||
region: str | None = None,
|
||||
) -> tuple[date, float] | None:
|
||||
"""Последняя (по obs_date) непустая точка ряда или None, если ряда нет.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
indicator_type: тип показателя.
|
||||
region: регион (по умолчанию 'rf').
|
||||
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(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from unittest.mock import MagicMock
|
|||
|
||||
from app.services.site_finder.macro import (
|
||||
_CBR_SERIES_MAP,
|
||||
_canonical_region,
|
||||
classify_cbr_series,
|
||||
get_latest_macro,
|
||||
get_macro_series,
|
||||
|
|
@ -25,10 +26,7 @@ from app.services.site_finder.macro import (
|
|||
|
||||
# Путь к миграции — для теста-консистентности зеркала.
|
||||
_MIGRATION = (
|
||||
pathlib.Path(__file__).resolve().parents[4]
|
||||
/ "data"
|
||||
/ "sql"
|
||||
/ "123_macro_indicator.sql"
|
||||
pathlib.Path(__file__).resolve().parents[4] / "data" / "sql" / "123_macro_indicator.sql"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -94,6 +92,81 @@ class TestClassifyCbrSeries:
|
|||
assert classify_cbr_series("02_99_unknown_series") is None
|
||||
|
||||
|
||||
# ── canonical region resolution (#945 footgun) ────────────────────────────────
|
||||
|
||||
|
||||
class TestCanonicalRegion:
|
||||
"""Indicator-aware дефолт региона: prod-layout = CBR-ипотека под 'sverdl',
|
||||
key_rate / прочее под 'rf' (см. 123_macro_indicator.sql §2)."""
|
||||
|
||||
def test_all_six_mortgage_indicators_resolve_sverdl(self) -> None:
|
||||
# Источник истины — _CBR_SERIES_MAP (зеркало миграции): все 6 mortgage_*.
|
||||
mortgage_types = {itype for itype, _unit in _CBR_SERIES_MAP.values()}
|
||||
assert len(mortgage_types) == 6 # guard: набор не «усох»
|
||||
for itype in mortgage_types:
|
||||
assert _canonical_region(itype) == "sverdl", itype
|
||||
|
||||
def test_key_rate_resolves_rf(self) -> None:
|
||||
assert _canonical_region("key_rate") == "rf"
|
||||
|
||||
def test_unknown_indicator_defaults_rf(self) -> None:
|
||||
# Любой не-ипотечный / неизвестный тип → 'rf' (национальный дефолт).
|
||||
assert _canonical_region("cpi") == "rf"
|
||||
assert _canonical_region("mortgage_rate_primary_domrf") == "rf" # это region='rf'
|
||||
assert _canonical_region("totally_unknown") == "rf"
|
||||
|
||||
|
||||
# ── reader region resolution end-to-end (mock Session) ─────────────────────────
|
||||
|
||||
|
||||
class TestReaderRegionResolution:
|
||||
"""Какой region реально биндится в SQL ридером при/без явного region.
|
||||
|
||||
Покрывает контракт #945: дефолт indicator-aware, явный region — выигрывает.
|
||||
"""
|
||||
|
||||
def _bound_region(self, db: MagicMock) -> str:
|
||||
_args, _kwargs = db.execute.call_args
|
||||
return _args[1]["region"]
|
||||
|
||||
def test_series_mortgage_no_region_binds_sverdl(self) -> None:
|
||||
db = _mock_db([])
|
||||
get_macro_series(db, "mortgage_rate_weighted")
|
||||
assert self._bound_region(db) == "sverdl"
|
||||
|
||||
def test_series_key_rate_no_region_binds_rf(self) -> None:
|
||||
db = _mock_db([])
|
||||
get_macro_series(db, "key_rate")
|
||||
assert self._bound_region(db) == "rf"
|
||||
|
||||
def test_series_explicit_region_overrides_default(self) -> None:
|
||||
# Явный 'rf' для ипотечного типа — caller override выигрывает над 'sverdl'.
|
||||
db = _mock_db([])
|
||||
get_macro_series(db, "mortgage_debt", region="rf")
|
||||
assert self._bound_region(db) == "rf"
|
||||
|
||||
def test_series_existing_sverdl_caller_unchanged(self) -> None:
|
||||
# Регресс на текущего вызывателя (macro_series.py:372 region="sverdl").
|
||||
db = _mock_db([])
|
||||
get_macro_series(db, "mortgage_issued_count", region="sverdl")
|
||||
assert self._bound_region(db) == "sverdl"
|
||||
|
||||
def test_latest_mortgage_no_region_binds_sverdl(self) -> None:
|
||||
db = _mock_db([])
|
||||
get_latest_macro(db, "mortgage_overdue")
|
||||
assert self._bound_region(db) == "sverdl"
|
||||
|
||||
def test_latest_key_rate_no_region_binds_rf(self) -> None:
|
||||
db = _mock_db([])
|
||||
get_latest_macro(db, "key_rate")
|
||||
assert self._bound_region(db) == "rf"
|
||||
|
||||
def test_latest_explicit_region_overrides_default(self) -> None:
|
||||
db = _mock_db([])
|
||||
get_latest_macro(db, "mortgage_term", region="rf")
|
||||
assert self._bound_region(db) == "rf"
|
||||
|
||||
|
||||
# ── консистентность Python-зеркала с SQL-миграцией ────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -162,13 +235,18 @@ class TestGetMacroSeries:
|
|||
db = _mock_db([])
|
||||
assert get_macro_series(db, "key_rate") == []
|
||||
|
||||
def test_default_region_and_params(self) -> None:
|
||||
def test_default_region_is_indicator_aware(self) -> None:
|
||||
"""No region → mortgage_* резолвится в 'sverdl' (его prod-layout), не 'rf'.
|
||||
|
||||
Регресс на #945-footgun: раньше дефолт был жёстко 'rf' → mortgage_*-ряд
|
||||
молча возвращал пусто. Проверяем РЕАЛЬНО забинженный в запрос region.
|
||||
"""
|
||||
db = _mock_db([])
|
||||
get_macro_series(db, "mortgage_debt")
|
||||
_args, kwargs = db.execute.call_args
|
||||
params = _args[1] if len(_args) > 1 else kwargs
|
||||
_args, _kwargs = db.execute.call_args
|
||||
params = _args[1]
|
||||
assert params["itype"] == "mortgage_debt"
|
||||
assert params["region"] == "rf"
|
||||
assert params["region"] == "sverdl"
|
||||
assert params["since"] is None
|
||||
|
||||
def test_since_passed_through(self) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue