All checks were successful
CI / changes (pull_request) Successful in 11s
CI Trade-In / changes (pull_request) Successful in 11s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m1s
CI / backend-tests (pull_request) Successful in 16m4s
One layer's caught DB error left the shared §22 report Session in Postgres's "aborted transaction" state with no rollback, silently emptying every later layer on the same report. Wrap each failure point in db.begin_nested() (SAVEPOINT): orchestrator._safe_call, macro_series _query_key_rate_monthly / _query_inflation_monthly / _query_mortgage_monthly (per indicator), special_indices._run (per index), and sales_series _query_source_a / _query_source_b — so a sibling builder's failure no longer cascades. Also close the RELEASE-SAVEPOINT trap: inner helpers that swallowed a db.execute failure without their own savepoint left the tx aborted, so the outer builder's savepoint failed at RELEASE (illegal in an aborted tx) and poisoning still cascaded. Add savepoints at the inner catch sites — special_indices._query_parcel_centroid and district_resolver._admin_names / resolve_objective_districts. Refs #2464 (cluster A session-poisoning).
438 lines
24 KiB
Python
438 lines
24 KiB
Python
"""Monthly макро-ряд + детерминированный классификатор режима ключевой ставки.
|
||
|
||
#951 (Site Finder v2 / «GG-форсайт» ТЗ §9.5/§9.6), EPIC 7 «Чувствительность к
|
||
ключевой ставке», PR B (#951b). Это **data-independent фундамент**: monthly
|
||
макро-таймсерия, классификатор тренда ставки (down/up/stable), лаговые помощники
|
||
и маркер «зашумлённого» окна. Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM.
|
||
|
||
Потребители (later PRs, НЕ этот): §9.6 key-rate sensitivity, §9.5 макро-коэффициент.
|
||
|
||
Источник данных — таблица macro_indicator (миграция data/sql/123_macro_indicator.sql),
|
||
читается через тонкий reader app.services.site_finder.macro (переиспользуем):
|
||
• key_rate — region 'rf', ДНЕВНОЙ ряд, source 'cbr' (backfill PR B
|
||
#945, 2019-01-01 → сегодня). Ресэмплим daily → monthly.
|
||
• inflation_yoy — region 'rf', УЖЕ месячный (obs_date = 1-е число), source
|
||
'cbr' (ИПЦ YoY, #946 — залит cbr_macro_sync через CBR
|
||
UniDbQuery). Берём как есть. Потребитель — §9.5 канал
|
||
inflation (macro_coefficient), теперь BACKED, не degraded.
|
||
• mortgage_* — region 'sverdl', УЖЕ месячные (obs_date нормализован к
|
||
1-му числу через make_date(...,1)). Берём как есть.
|
||
(income НЕ backfilled — на него не опираемся.)
|
||
|
||
Соглашение по дате: каждый MonthlyMacro.month — ПЕРВОЕ число месяца (YYYY-MM-01).
|
||
Это зеркалит нормализацию mortgage-рядов в backfill (make_date(year, month, 1)),
|
||
поэтому join «месяц-в-месяц» сводится к равенству obs_date без date_trunc.
|
||
|
||
Graceful-on-thin-data (КРИТИЧНО, дух market_metrics.py): пустая таблица / сбой БД
|
||
→ [] (НЕ crash). Месяцы без точки key_rate → carry-forward последнего известного
|
||
значения; до самой первой известной точки carry невозможен → key_rate=None.
|
||
|
||
psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) —
|
||
никогда :x::type (парсер psycopg3 даёт SyntaxError на :name::type).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from datetime import date
|
||
from typing import Any, Literal
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.services.forecast_request_cache import cached
|
||
from app.services.site_finder.macro import get_macro_series
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
Regime = Literal["down", "up", "stable"]
|
||
|
||
# ── Named-константы ───────────────────────────────────────────────────────────
|
||
|
||
# Глубина окна по умолчанию для get_monthly_macro (месяцев назад от текущего).
|
||
# 48 мес ≈ 4 года: покрывает полный цикл ставки ЦБ (мягкая 2020 → жёсткая 2022 →
|
||
# смягчение → новый подъём 2023-2024) — достаточно для лаговых регрессий §9.6.
|
||
_DEFAULT_MONTHS_BACK: int = 48
|
||
|
||
# Порог режима ставки: изменение key_rate ≥ этого числа п.п. за окно считается
|
||
# трендом (down/up); меньше по модулю → 'stable' (шум/удержание). 0.25 п.п. —
|
||
# минимальный «значимый» шаг решения ЦБ (совет директоров двигает ставку шагами
|
||
# кратными 0.25-0.50 п.п.; <0.25 п.п. за месяц = фактически плато).
|
||
_REGIME_EPS_PP: float = 0.25
|
||
|
||
# Окно (в месяцах) для оценки локального тренда ставки в classify_regime: сравниваем
|
||
# текущую ставку со ставкой _REGIME_WINDOW_MONTHS назад. 1 = «месяц-к-месяцу».
|
||
# Берём 1: решения ЦБ дискретны и крупны, лишнее сглаживание размывает точку разворота.
|
||
_REGIME_WINDOW_MONTHS: int = 1
|
||
|
||
# ── Confounder-даты (hardcoded — с обоснованием КАЖДОЙ, см. sql.md «нужен why») ──
|
||
# Окна, ПЕРЕСЕКАЮЩИЕ любую из этих дат, зашумлены: продажи в них двигала не ставка,
|
||
# а разовый политический/эпид-шок → downstream (§9.6) понижает по ним confidence.
|
||
# Формат: (date, краткое «почему этот разрыв конфаундит rate↔sales»).
|
||
_CONFOUNDER_DATES: tuple[tuple[date, str], ...] = (
|
||
(
|
||
date(2020, 3, 1),
|
||
"COVID-19 lockdown + запуск льготной ипотеки 6.5%: обвал, затем всплеск "
|
||
"спроса, не связанный с динамикой ключевой ставки",
|
||
),
|
||
(
|
||
date(2022, 2, 1),
|
||
"Геополитический шок 02.2022 + экстренный подъём ставки ЦБ до 20%: "
|
||
"паническая динамика сделок, разрыв обычной связи rate↔sales",
|
||
),
|
||
(
|
||
date(2024, 7, 1),
|
||
"Сворачивание массовой льготной ипотеки с 01.07.2024: ажиотажный «забег» "
|
||
"в июне + провал спроса после — продажи двигала отмена льготы, не ставка",
|
||
),
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class MonthlyMacro:
|
||
"""Одна месячная точка макро-ряда (ТЗ §9.5/§9.6).
|
||
|
||
month — ПЕРВОЕ число месяца (YYYY-MM-01). Любое поле = None при отсутствии
|
||
данных за этот месяц (никогда 0-как-заглушка).
|
||
"""
|
||
|
||
month: date
|
||
key_rate: float | None # ставка ЦБ, действовавшая на конец месяца (% годовых)
|
||
mortgage_rate_weighted: float | None # средневзвешенная ИЖК-ставка, %
|
||
mortgage_issued_count: float | None # выдано ИЖК-кредитов, шт
|
||
mortgage_issued_volume: float | None # объём выданных ИЖК, млн руб
|
||
mortgage_debt: float | None # задолженность по ИЖК, млн руб
|
||
mortgage_overdue: float | None # просроченная задолженность по ИЖК, млн руб
|
||
# #946: инфляция «% г/г» (ИПЦ YoY), region 'rf'. Поле с дефолтом None и в КОНЦЕ —
|
||
# чтобы существующие kwargs-конструкторы (тесты/демо), не знающие о поле, не падали.
|
||
inflation_yoy: float | None = None
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
return {
|
||
"month": self.month.isoformat(),
|
||
"key_rate": _round_or_none(self.key_rate, 2),
|
||
"inflation_yoy": _round_or_none(self.inflation_yoy, 2),
|
||
"mortgage_rate_weighted": _round_or_none(self.mortgage_rate_weighted, 2),
|
||
"mortgage_issued_count": _round_or_none(self.mortgage_issued_count, 0),
|
||
"mortgage_issued_volume": _round_or_none(self.mortgage_issued_volume, 1),
|
||
"mortgage_debt": _round_or_none(self.mortgage_debt, 1),
|
||
"mortgage_overdue": _round_or_none(self.mortgage_overdue, 1),
|
||
}
|
||
|
||
|
||
def _round_or_none(value: float | None, digits: int) -> float | None:
|
||
return round(value, digits) if value is not None else None
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# Pure-логика — без БД, полностью юнит-тестируемо.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
# Месячные mortgage-поля, которые join-им из macro_indicator (region 'sverdl').
|
||
# indicator_type → имя поля MonthlyMacro. mortgage_term намеренно НЕ тянем
|
||
# (downstream §9.6/§9.5 он не нужен; держим MonthlyMacro узким).
|
||
_MORTGAGE_FIELDS: tuple[tuple[str, str], ...] = (
|
||
("mortgage_rate_weighted", "mortgage_rate_weighted"),
|
||
("mortgage_issued_count", "mortgage_issued_count"),
|
||
("mortgage_issued_volume", "mortgage_issued_volume"),
|
||
("mortgage_debt", "mortgage_debt"),
|
||
("mortgage_overdue", "mortgage_overdue"),
|
||
)
|
||
|
||
|
||
def _month_start(d: date) -> date:
|
||
"""Нормализовать дату к 1-му числу её месяца (YYYY-MM-01)."""
|
||
return date(d.year, d.month, 1)
|
||
|
||
|
||
def _carry_forward(values: list[float | None]) -> list[float | None]:
|
||
"""Заполнить None-дыры последним известным значением (LOCF).
|
||
|
||
Дыры ДО первого известного значения остаются None (carry невозможен).
|
||
Чистая функция: не мутирует вход.
|
||
"""
|
||
out: list[float | None] = []
|
||
last: float | None = None
|
||
for v in values:
|
||
if v is not None:
|
||
last = v
|
||
out.append(last)
|
||
return out
|
||
|
||
|
||
def classify_regime(
|
||
rates: list[float | None],
|
||
*,
|
||
eps_pp: float = _REGIME_EPS_PP,
|
||
window_months: int = _REGIME_WINDOW_MONTHS,
|
||
) -> list[Regime]:
|
||
"""Классифицировать локальный тренд ключевой ставки по месяцам.
|
||
|
||
Для каждого месяца i сравниваем ставку с ней же window_months назад:
|
||
• Δ ≤ -eps_pp → 'down' (смягчение ДКП)
|
||
• Δ ≥ +eps_pp → 'up' (ужесточение ДКП)
|
||
• |Δ| < eps_pp → 'stable' (плато/удержание; шум < шага решения ЦБ)
|
||
|
||
Δ берётся на carry-forward версии ряда (LOCF), поэтому одиночные None-дыры
|
||
не рвут тренд. Если на момент i нет ни текущего значения, ни базы для
|
||
сравнения (оба None — типично в самом начале до первой точки) → 'stable'
|
||
(нейтрально, не выдумываем тренд). PURE, без БД.
|
||
|
||
Args:
|
||
rates: ставка по месяцам (хронологически ASC), None = нет данных.
|
||
eps_pp: порог значимого изменения в п.п. (по умолчанию _REGIME_EPS_PP).
|
||
window_months: за сколько месяцев назад берём базу сравнения (≥1).
|
||
|
||
Returns:
|
||
Список того же размера, что rates: 'down' | 'up' | 'stable' на месяц.
|
||
"""
|
||
w = max(1, window_months)
|
||
filled = _carry_forward(rates)
|
||
out: list[Regime] = []
|
||
for i in range(len(filled)):
|
||
cur = filled[i]
|
||
prev = filled[i - w] if i - w >= 0 else None
|
||
if cur is None or prev is None:
|
||
out.append("stable")
|
||
continue
|
||
delta = cur - prev
|
||
if delta <= -eps_pp:
|
||
out.append("down")
|
||
elif delta >= eps_pp:
|
||
out.append("up")
|
||
else:
|
||
out.append("stable")
|
||
return out
|
||
|
||
|
||
def macro_at_lag(
|
||
series: list[MonthlyMacro], target_month: date, lag_months: int
|
||
) -> MonthlyMacro | None:
|
||
"""Вернуть точку макро-ряда на (target_month − lag_months) или None.
|
||
|
||
Лаги, используемые downstream §9.6: 0/1/2/3/6. target_month нормализуется к
|
||
1-му числу. Если такого месяца нет в series (дыра / выход за диапазон) → None.
|
||
PURE, без БД.
|
||
|
||
Args:
|
||
series: список MonthlyMacro (порядок не важен — ищем по равенству month).
|
||
target_month: целевой месяц (нормализуется к YYYY-MM-01).
|
||
lag_months: на сколько месяцев назад сместиться (≥0).
|
||
|
||
Returns:
|
||
MonthlyMacro искомого месяца либо None.
|
||
"""
|
||
anchor = _month_start(target_month)
|
||
wanted = _shift_months(anchor, -lag_months)
|
||
for m in series:
|
||
if m.month == wanted:
|
||
return m
|
||
return None
|
||
|
||
|
||
def _shift_months(d: date, delta: int) -> date:
|
||
"""Сдвинуть дату-1-е-число на delta месяцев (delta может быть отрицательным)."""
|
||
base = _month_start(d)
|
||
total = (base.year * 12 + (base.month - 1)) + delta
|
||
year, month0 = divmod(total, 12)
|
||
return date(year, month0 + 1, 1)
|
||
|
||
|
||
def is_confounded_window(start: date, end: date) -> bool:
|
||
"""True, если окно [start, end] пересекает известную дату-конфаундер.
|
||
|
||
Эти даты (см. _CONFOUNDER_DATES) — разовые шоки, разрывающие обычную связь
|
||
«ключевая ставка ↔ продажи»; downstream §9.6 по таким окнам понижает
|
||
confidence. Сравнение по 1-му числу месяца (даты-конфаундеры — начала
|
||
месяцев). start>end → нормализуем (меняем местами). PURE, без БД.
|
||
|
||
Args:
|
||
start: начало окна.
|
||
end: конец окна (включительно).
|
||
|
||
Returns:
|
||
True если хотя бы одна confounder-дата попадает в [start, end].
|
||
"""
|
||
lo, hi = (start, end) if start <= end else (end, start)
|
||
lo_m = _month_start(lo)
|
||
hi_m = _month_start(hi)
|
||
return any(lo_m <= cdate <= hi_m for cdate, _why in _CONFOUNDER_DATES)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# DB-reader — тонкий, graceful. Pure-логика выше тестируется без него.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
|
||
# Ресэмпл ДНЕВНОГО key_rate (region 'rf') → monthly методом last-known-value
|
||
# (ставка, действовавшая на КОНЕЦ месяца). DISTINCT ON (date_trunc('month', ...))
|
||
# + ORDER BY ... obs_date DESC оставляет по одной — самой поздней — точке месяца.
|
||
# Делаем в SQL, а не в Python: иначе пришлось бы тянуть ~1.9k дневных строк в
|
||
# процесс; DISTINCT ON решает last-value за один set-based проход. Месяц,
|
||
# нормализованный к 1-му числу, сразу join-ится к mortgage-рядам.
|
||
_KEY_RATE_MONTHLY_SQL = text(
|
||
"""
|
||
SELECT DISTINCT ON (date_trunc('month', obs_date))
|
||
CAST(date_trunc('month', obs_date) AS date) AS month,
|
||
value
|
||
FROM macro_indicator
|
||
WHERE indicator_type = CAST(:itype AS text)
|
||
AND region = CAST(:region AS text)
|
||
AND value IS NOT NULL
|
||
AND obs_date >= CAST(:since AS date)
|
||
ORDER BY date_trunc('month', obs_date), obs_date DESC
|
||
"""
|
||
)
|
||
|
||
|
||
@cached(lambda db, *, months_back=_DEFAULT_MONTHS_BACK: months_back, label="get_monthly_macro")
|
||
def get_monthly_macro(
|
||
db: Session, *, months_back: int = _DEFAULT_MONTHS_BACK
|
||
) -> list[MonthlyMacro]:
|
||
"""Собрать monthly макро-ряд за последние months_back месяцев.
|
||
|
||
key_rate (region 'rf', дневной) ресэмплится daily→monthly как last-known-value
|
||
(ставка на конец месяца). Месячные mortgage-ряды (region 'sverdl') берутся как
|
||
есть и join-ятся по году-месяцу. Дыры key_rate между известными точками
|
||
заполняются carry-forward (LOCF); до первой известной точки key_rate=None.
|
||
|
||
Ряд строится по непрерывной сетке месяцев [start .. текущий] (а НЕ только по
|
||
месяцам, где есть данные), чтобы лаги/режим имели регулярный шаг. Месяц без
|
||
ЛЮБЫХ данных всё равно присутствует (все поля None для него — кроме carry key_rate).
|
||
|
||
Graceful: при сбое БД или пустой таблице key_rate сетка месяцев всё равно
|
||
возвращается, но с None-полями (НЕ crash). Пустой список [] — только если
|
||
сама сетка пуста (months_back < 0).
|
||
|
||
Args:
|
||
db: SQLAlchemy sync Session.
|
||
months_back: глубина ряда в месяцах (по умолчанию _DEFAULT_MONTHS_BACK).
|
||
|
||
Returns:
|
||
Список MonthlyMacro по возрастанию month (по непрерывной сетке);
|
||
[] только при пустой сетке (months_back < 0).
|
||
"""
|
||
# month-bucketing в локальной tz сервера (single-region, как и весь codebase)
|
||
today = date.today()
|
||
start = _shift_months(today, -max(0, months_back))
|
||
grid = _month_grid(start, _month_start(today))
|
||
if not grid:
|
||
return []
|
||
|
||
key_rate_by_month = _query_key_rate_monthly(db, since=start)
|
||
inflation_by_month = _query_inflation_monthly(db, since=start)
|
||
mortgage_by_field = _query_mortgage_monthly(db, since=start)
|
||
|
||
# key_rate: разворачиваем на сетку + carry-forward по сетке (LOCF).
|
||
raw_rates: list[float | None] = [key_rate_by_month.get(m) for m in grid]
|
||
filled_rates = _carry_forward(raw_rates)
|
||
|
||
# inflation_yoy: тоже carry-forward (медленный месячный ряд; последняя известная
|
||
# точка = текущий режим, как и у ставки). До первой точки → None.
|
||
raw_infl: list[float | None] = [inflation_by_month.get(m) for m in grid]
|
||
filled_infl = _carry_forward(raw_infl)
|
||
|
||
rows: list[MonthlyMacro] = []
|
||
for idx, month in enumerate(grid):
|
||
kwargs: dict[str, float | None] = {
|
||
field: mortgage_by_field.get(field, {}).get(month) for _itype, field in _MORTGAGE_FIELDS
|
||
}
|
||
rows.append(
|
||
MonthlyMacro(
|
||
month=month,
|
||
key_rate=filled_rates[idx],
|
||
inflation_yoy=filled_infl[idx],
|
||
**kwargs,
|
||
)
|
||
)
|
||
|
||
logger.info(
|
||
"get_monthly_macro: months_back=%d grid=%d key_rate_pts=%d inflation_pts=%d",
|
||
months_back,
|
||
len(grid),
|
||
len(key_rate_by_month),
|
||
len(inflation_by_month),
|
||
)
|
||
return rows
|
||
|
||
|
||
def _month_grid(start: date, end: date) -> list[date]:
|
||
"""Непрерывный список 1-х чисел месяцев от start до end включительно (ASC)."""
|
||
lo = _month_start(start)
|
||
hi = _month_start(end)
|
||
if lo > hi:
|
||
return []
|
||
out: list[date] = []
|
||
cur = lo
|
||
while cur <= hi:
|
||
out.append(cur)
|
||
cur = _shift_months(cur, 1)
|
||
return out
|
||
|
||
|
||
def _query_key_rate_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||
"""Ресэмпл дневного key_rate (region 'rf') → {month1st: value}. Graceful → {}.
|
||
|
||
SAVEPOINT (#2464 cluster A finding #2): `db` — общая §22-сессия отчёта; при сбое
|
||
этого запроса БЕЗ SAVEPOINT транзакция Postgres остаётся aborted и следующие
|
||
запросы get_monthly_macro (inflation, mortgage) + все ПОСЛЕДУЮЩИЕ §9.x-слои на
|
||
той же сессии тоже падают. `with db.begin_nested():` откатывает ТОЛЬКО этот
|
||
SAVEPOINT (ROLLBACK TO SAVEPOINT), внешняя транзакция остаётся рабочей.
|
||
"""
|
||
try:
|
||
with db.begin_nested():
|
||
rows = db.execute(
|
||
_KEY_RATE_MONTHLY_SQL,
|
||
{"itype": "key_rate", "region": "rf", "since": since},
|
||
).all()
|
||
except Exception:
|
||
logger.exception("get_monthly_macro: key_rate query failed")
|
||
return {}
|
||
return {r[0]: float(r[1]) for r in rows if r[0] is not None and r[1] is not None}
|
||
|
||
|
||
def _query_inflation_monthly(db: Session, *, since: date) -> dict[date, float]:
|
||
"""Месячный inflation_yoy (region 'rf', ИПЦ YoY) → {month1st: value}. Graceful → {}.
|
||
|
||
Ряд УЖЕ месячный (obs_date = 1-е число, залит cbr_macro_sync) — берём как есть
|
||
через reuse get_macro_series (свой SQL не пишем). _month_start — страховка.
|
||
Сбой/пустой ряд → {} (НЕ crash), inflation_yoy тогда None по всей сетке.
|
||
|
||
SAVEPOINT (#2464 cluster A finding #2): см. `_query_key_rate_monthly` — та же
|
||
общая §22-сессия, тот же риск отравления транзакции для последующих запросов
|
||
(mortgage-поля + §9.x-слои). `with db.begin_nested():` изолирует сбой в SAVEPOINT.
|
||
"""
|
||
try:
|
||
with db.begin_nested():
|
||
series = get_macro_series(db, "inflation_yoy", region="rf", since=since)
|
||
except Exception:
|
||
logger.exception("get_monthly_macro: inflation_yoy query failed")
|
||
return {}
|
||
return {_month_start(d): float(v) for d, v in series}
|
||
|
||
|
||
def _query_mortgage_monthly(db: Session, *, since: date) -> dict[str, dict[date, float]]:
|
||
"""Месячные mortgage-ряды (region 'sverdl') через reuse get_macro_series.
|
||
|
||
Возвращает {field: {month1st: value}}. obs_date уже нормализован к 1-му числу
|
||
в backfill, но _month_start применяем повторно (страховка). Сбой одного ряда
|
||
не валит остальные (graceful: пустой подсловарь).
|
||
|
||
SAVEPOINT (#2464 cluster A finding #2): все 5 полей читаются на ОДНОЙ `db`-Session
|
||
(get_monthly_macro вызывается внутри общей §22-сессии отчёта). Без SAVEPOINT сбой
|
||
ОДНОГО поля оставляет транзакцию Postgres aborted — каждое СЛЕДУЮЩЕЕ поле в этом
|
||
же цикле тоже падает (хотя его данные были бы доступны), а `except` здесь молча
|
||
отдаёт [] по каждому, маскируя каскад под «нормальную» построчную деградацию.
|
||
`with db.begin_nested():` — SAVEPOINT на КАЖДОЕ поле: сбой откатывает только его
|
||
SAVEPOINT (ROLLBACK TO SAVEPOINT), сессия остаётся рабочей для следующего поля.
|
||
"""
|
||
out: dict[str, dict[date, float]] = {}
|
||
for indicator_type, field in _MORTGAGE_FIELDS:
|
||
try:
|
||
with db.begin_nested():
|
||
series = get_macro_series(db, indicator_type, region="sverdl", since=since)
|
||
except Exception:
|
||
logger.exception("get_monthly_macro: mortgage series %s failed", indicator_type)
|
||
series = []
|
||
out[field] = {_month_start(d): float(v) for d, v in series}
|
||
return out
|