"""Monthly ряд продаж (sold units + area + avg price) по сегменту — Y-ось §9.6. #951 (Site Finder v2 / «GG-форсайт» ТЗ §9.6), EPIC 7 «Чувствительность к ключевой ставке», sub-PR 1 (#951c). Это **data-independent фундамент** Y-оси (зависимая переменная) для регрессии §9.6 «продажи ↔ ключевая ставка» (later PR3). Парный к PR2 (macro_series.py — X-ось/regressor). Всё ДЕТЕРМИНИРОВАННО, БЕЗ LLM. Что строим: для SegmentSpec (любой subset class/room_bucket/district/price_bucket) — непрерывный MONTHLY ряд (units, area_m2, avg_price_per_m2) из ОДНОГО из двух источников Объектива, в frozen-dataclass SalesSeries. Два источника (см. data/sql/68_schema_objective.sql), у каждого своя зона авторитетности: • Source A `objective_corpus_room_month` (monthly aggregate, long-формат месяц×корпус×room_bucket). Колонки сделок: deals_total_count / deals_total_vol_m2 / deals_total_avg_price_thousand_rub_per_m2. class — Title-case ("Комфорт"). АВТОРИТЕТЕН для агрегатов класс/район (полные помесячные сделки ДДУ+ДКП, не зависит от текущего листинга). • Source B `objective_lots` (per-lot, даты сделок). COUNT(*) + SUM(area_pd) + взвешенная цена, GROUP BY date_trunc('month', registration_date). class — lowercase ("комфорт") → нормализуем регистр при матчинге. АВТОРИТЕТЕН для тонкой сегментации room×area×price (Source A не несёт price-bucket / точную площадь лота). КАВЕАТ ВЫЖИВАЕМОСТИ: objective_lots — это последний UPSERT- снапшот per-lot; видны лишь лоты, до сих пор присутствующие в выгрузке. Лот, проданный и затем выпавший из листинга, в исторических месяцах недоучтён → Source B занижает старые месяцы (survivorship bias). Для агрегатов класс/ район поэтому предпочтителен Source A; Source B — когда нужна price/area- сегментация, которую A не даёт. Соглашение по дате: каждый месяц ряда — ПЕРВОЕ число (YYYY-MM-01). Source B нормализует registration_date через date_trunc('month', …); Source A's report_month уже 1-е число. Переиспользуем _month_start/_shift_months/_month_grid из macro_series (PR2) — не дублируем сеточную логику. КРИТИЧНО (units=0 vs None): месяц без сделок → units=0 (НАСТОЯЩИЙ ноль: «честно продали 0» — это данные, а не пропуск; downstream-регрессии нужен непрерывный ряд счётчиков). Площадь/цена в таком месяце → None (среднее по пустому множеству не определено — НЕ 0). Это зеркалит дух market_metrics: «нет данных ≠ 0». Graceful-on-thin-data: пустая таблица / сбой БД → ряд по сетке месяцев с units=0, area/price=None, confidence='low' (НЕ crash). psycopg v3 / SQLAlchemy text: bind-параметры ВСЕГДА через CAST(:x AS type) — никогда :x::type (парсер psycopg3 даёт SyntaxError на :name::type). """ from __future__ import annotations import logging import math from dataclasses import dataclass from datetime import date from typing import Any, Literal from sqlalchemy import text from sqlalchemy.orm import Session # Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в # macro_series, хоть и не в __all__ пакета. from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months from app.services.site_finder.district_resolver import resolve_objective_districts logger = logging.getLogger(__name__) SalesSource = Literal["corpus_room_month", "objective_lots"] Confidence = Literal["high", "medium", "low"] # ── Named-константы ─────────────────────────────────────────────────────────── # Глубина ряда по умолчанию (месяцев назад). 48 мес ≈ 4 года — зеркалит # _DEFAULT_MONTHS_BACK макро-ряда (PR2): регрессия §9.6 join-ит sales↔macro # месяц-в-месяц, окна должны совпадать по длине. _DEFAULT_MONTHS_BACK: int = 48 # Пороги confidence по числу НЕнулевых месяцев (месяцев с реальными сделками). # Регрессия §9.6 (sales ~ key_rate + лаги) требует достаточной вариации Y: # • ≥24 ненулевых мес → 'high' (≥2 года сделок — устойчивый фит лагов) # • ≥12 ненулевых мес → 'medium' (≥1 год — фит возможен, но шумнее) # • иначе → 'low' (тонкий сегмент — регрессии не доверять) # Считаем именно НЕнулевые: длинный хвост zero-месяцев (мёртвый сегмент) не # добавляет информации регрессии, поэтому в порог не идёт. _CONF_HIGH_MIN_NONZERO_MONTHS: int = 24 _CONF_MEDIUM_MIN_NONZERO_MONTHS: int = 12 # ── EKB price-bands (₽/м²) ──────────────────────────────────────────────────── # Фиксированные пороги цены за м² (рубли) для price_bucket_of. Источник — рынок # новостроек ЕКБ 2024-2025 (objective_lots.price_per_m2_rub): здоровое тело # распределения ~110-220 тыс ₽/м². Берём 4 band'а квартильного духа с КРУГЛЫМИ # порогами (стабильнее «плавающих» квантилей — детерминированный bucket, не # зависящий от выборки): # эконом : < 120 000 ₽/м² (нижний сегмент / окраины) # комфорт : 120k–160k ₽/м² (массовое тело рынка) # бизнес : 160k–220k ₽/м² (верх-средний) # премиум : ≥ 220 000 ₽/м² (премиальные ЖК / центр) # Границы ВКЛючительны слева (lo ≤ x < hi); подобрано под §9.6 price-сегментацию, # а НЕ под маркетинговый класс ЖК (потому ключи нейтральные, не путать с `class`). _PRICE_BAND_ECONOMY_MAX: float = 120_000.0 _PRICE_BAND_COMFORT_MAX: float = 160_000.0 _PRICE_BAND_BUSINESS_MAX: float = 220_000.0 PRICE_BUCKET_ECONOMY: str = "эконом" PRICE_BUCKET_COMFORT: str = "комфорт" PRICE_BUCKET_BUSINESS: str = "бизнес" PRICE_BUCKET_PREMIUM: str = "премиум" PRICE_BUCKET_UNKNOWN: str = "unknown" # room×area bucket-ключи — зеркало analytics_queries._BUCKET_PRETTY (§9.6 ждёт # «Z m²»-подписи). rooms_int — primary axis (mirror _elasticity_per_bucket_coef: # 0/студия→Студии, 1→1-к, 2→2-к, 3→3-к, 4|5+→80+). area_pd — вторичная страховка # для пограничных случаев (большие 1-2-к по площади уезжают в 80+). ROOM_AREA_BUCKET_STUDIO: str = "Студии 15-30" ROOM_AREA_BUCKET_1K: str = "1-к 30-45" ROOM_AREA_BUCKET_2K: str = "2-к 45-60" ROOM_AREA_BUCKET_3K: str = "3-к 60-80" ROOM_AREA_BUCKET_LARGE: str = "80+ м²" ROOM_AREA_BUCKET_UNKNOWN: str = "unknown" # Площадь (м²), с которой лот считается «80+» независимо от комнатности — # зеркалит верхний bucket _BUCKET_PRETTY ('5-80+ м²' / area_per_unit >= 80). _LARGE_AREA_THRESHOLD_M2: float = 80.0 @dataclass(frozen=True) class SegmentSpec: """Спецификация сегмента — любой subset осей; None = агрегировать по оси. obj_class передаётся в «человеческом» регистре; SQL нормализует регистр под источник (Source A Title-case / Source B lowercase), поэтому здесь регистр не важен. price_bucket — ключ из price_bucket_of (только Source B; для Source A игнорируется — у месячного агрегата нет per-lot цены). ВАЖНО (room_bucket source-specific!): семантика зависит от source: • Source A (corpus_room_month): СЫРОЙ Objective room_bucket — "студия" | "1" | "2" | "3" | "4" | "5+". • Source B (objective_lots): метка из room_area_bucket_of — напр. "2-к 45-60", "80+ м²". Передача A-ключа в Source B (или наоборот) → тихий пустой ряд (0 строк). Для агрегатов класс/район бери Source A; для room×area×price — Source B. """ obj_class: str | None = None room_bucket: str | None = None district: str | None = None price_bucket: str | None = None def as_dict(self) -> dict[str, str | None]: return { "obj_class": self.obj_class, "room_bucket": self.room_bucket, "district": self.district, "price_bucket": self.price_bucket, } @dataclass(frozen=True) class SalesSeries: """Monthly ряд продаж по сегменту (ТЗ §9.6, Y-ось регрессии). Все списки выровнены по индексу months (одной длины). units[i] — НАСТОЯЩИЙ счётчик (0 = честный ноль продаж, не пропуск). area_m2[i]/avg_price_per_m2[i] — None в месяцах без сделок (среднее по пустому не определено). """ months: list[date] units: list[int] area_m2: list[float | None] avg_price_per_m2: list[float | None] n_months: int source: SalesSource segment: dict[str, str | None] confidence: Confidence def as_dict(self) -> dict[str, Any]: return { "months": [m.isoformat() for m in self.months], "units": list(self.units), "area_m2": [_round_or_none(a, 1) for a in self.area_m2], "avg_price_per_m2": [_round_or_none(p, 0) for p in self.avg_price_per_m2], "n_months": self.n_months, "source": self.source, "segment": dict(self.segment), "confidence": self.confidence, } def _round_or_none(value: float | None, digits: int) -> float | None: return round(value, digits) if value is not None else None # ────────────────────────────────────────────────────────────────────────────── # Pure-логика — без БД, полностью юнит-тестируемо. # ────────────────────────────────────────────────────────────────────────────── def price_bucket_of(price_per_m2: float | int | None) -> str: """Отнести цену за м² (₽) к фиксированному EKB price-band'у. Детерминированные круглые пороги (_PRICE_BAND_*): эконом < 120k ≤ комфорт < 160k ≤ бизнес < 220k ≤ премиум. None / непозитивная цена → 'unknown' (нет осмысленного band'а — не подмешиваем в реальные сегменты). PURE. Args: price_per_m2: цена за квадратный метр в рублях. Returns: Один из PRICE_BUCKET_* ключей. """ if price_per_m2 is None: return PRICE_BUCKET_UNKNOWN p = float(price_per_m2) if p <= 0: return PRICE_BUCKET_UNKNOWN if p < _PRICE_BAND_ECONOMY_MAX: return PRICE_BUCKET_ECONOMY if p < _PRICE_BAND_COMFORT_MAX: return PRICE_BUCKET_COMFORT if p < _PRICE_BAND_BUSINESS_MAX: return PRICE_BUCKET_BUSINESS return PRICE_BUCKET_PREMIUM def room_area_bucket_of(rooms_int: int | None, area_pd: float | int | None) -> str: """Отнести лот к room×area bucket'у (зеркало _BUCKET_PRETTY / §9.6 «Z m²»). Primary axis — rooms_int (mirror _elasticity_per_bucket_coef): 0/студия→Студии, 1→1-к, 2→2-к, 3→3-к, 4+→80+. Вторичная страховка: лот площадью ≥ 80 м² уезжает в верхний bucket '80+ м²' независимо от комнатности (так же, как _BUCKET_PRETTY бакетит area_per_unit ≥ 80 в '5-80+ м²'). rooms_int=None И area=None → 'unknown'; если комнат нет, но площадь известна — решаем по площади (≥80 → большой, иначе unknown: без комнатности тонкий формат не определить). PURE. Args: rooms_int: число комнат (objective: 0=студия), либо None. area_pd: площадь лота, м² (для верхней границы), либо None. Returns: Один из ROOM_AREA_BUCKET_* ключей. """ area = float(area_pd) if area_pd is not None else None if area is not None and area >= _LARGE_AREA_THRESHOLD_M2: return ROOM_AREA_BUCKET_LARGE if rooms_int is None: return ROOM_AREA_BUCKET_UNKNOWN if rooms_int <= 0: return ROOM_AREA_BUCKET_STUDIO if rooms_int == 1: return ROOM_AREA_BUCKET_1K if rooms_int == 2: return ROOM_AREA_BUCKET_2K if rooms_int == 3: return ROOM_AREA_BUCKET_3K return ROOM_AREA_BUCKET_LARGE def log_diff(series: list[float | int | None]) -> list[float | None]: """Δln ряда для регрессии §9.6: out[t] = ln(x_t) − ln(x_{t-1}). Логарифм-разность стационаризует ряд продаж (рост в %). Правила None: • out[0] = None ВСЕГДА (нет предыдущей точки). • если x_t или x_{t-1} равны None или ≤ 0 → out[t] = None (ln(0)/ln(neg) = −inf/undefined; 0 продаж — валидное наблюдение для уровня, но не для Δln, поэтому помечаем пропуском, а не −inf, чтобы не отравить регрессию). Длина выхода = длине входа. PURE. Args: series: ряд значений (обычно units или area), None/0 допустимы. Returns: Список Δln той же длины; [0] и любые undefined-точки = None. """ out: list[float | None] = [] for i, cur in enumerate(series): if i == 0: out.append(None) continue prev = series[i - 1] if cur is None or prev is None: out.append(None) continue cur_f = float(cur) prev_f = float(prev) if cur_f <= 0 or prev_f <= 0: out.append(None) continue out.append(math.log(cur_f) - math.log(prev_f)) return out def fill_month_grid( by_month: dict[date, tuple[int, float | None, float | None]], grid: list[date], ) -> tuple[list[int], list[float | None], list[float | None]]: """Разложить разрежённые помесячные точки на непрерывную сетку. Каждая точка by_month[month] = (units, area_m2, avg_price). Месяц сетки, которого нет в by_month → (0, None, None): units=0 — НАСТОЯЩИЙ ноль («продали 0»), area/price=None (нет сделок → среднего нет). Ключи нормализуются к 1-му числу (страховка). PURE, не мутирует вход. Args: by_month: {month1st: (units, area_m2, avg_price_per_m2)}. grid: непрерывный ASC-список 1-х чисел месяцев (из _month_grid). Returns: (units, area_m2, avg_price_per_m2) — три списка длины len(grid). """ normalised = {_month_start(m): v for m, v in by_month.items()} units: list[int] = [] area: list[float | None] = [] price: list[float | None] = [] for month in grid: point = normalised.get(month) if point is None: units.append(0) area.append(None) price.append(None) else: u, a, p = point units.append(int(u)) area.append(a) price.append(p) return units, area, price def _confidence(units: list[int]) -> Confidence: """Confidence по числу НЕнулевых месяцев (см. _CONF_* пороги).""" nonzero = sum(1 for u in units if u > 0) if nonzero >= _CONF_HIGH_MIN_NONZERO_MONTHS: return "high" if nonzero >= _CONF_MEDIUM_MIN_NONZERO_MONTHS: return "medium" return "low" # ────────────────────────────────────────────────────────────────────────────── # SQL — Source A (objective_corpus_room_month, monthly aggregate) # ────────────────────────────────────────────────────────────────────────────── # Source A: суммируем помесячные сделки (ДДУ+ДКП = deals_total_*) по report_month. # Авторитетен для агрегатов класс/район. Фильтры spec опциональны через # CAST(:x AS text) IS NULL OR col = :x. class — Title-case в этой таблице, но # матчим через LOWER(...)=LOWER(...) (регистронезависимо — устойчиво к вариациям # регистра spec). Площадь — SUM(deals_total_vol_m2); цена — count-weighted AVG # уже усреднённой колонки (sql.md: AVG по pre-aggregated строкам неверен → # SUM(avg*cnt)/NULLIF(SUM(cnt),0)). Цена в таблице в тыс.₽/м² → ×1000 к ₽/м² # (единый масштаб с Source B price_per_m2_rub). _SOURCE_A_SQL = text( """ SELECT crm.report_month AS month, SUM(crm.deals_total_count) AS units, SUM(crm.deals_total_vol_m2) AS area_m2, SUM( crm.deals_total_avg_price_thousand_rub_per_m2 * crm.deals_total_count ) / NULLIF(SUM(crm.deals_total_count), 0) * 1000.0 AS avg_price_per_m2 FROM objective_corpus_room_month crm WHERE crm.report_month >= CAST(:since AS date) AND (CAST(:cls AS text) IS NULL OR LOWER(crm.class) = LOWER(CAST(:cls AS text))) AND ( CAST(:has_district AS boolean) IS FALSE OR crm.district = ANY(CAST(:districts AS text[])) ) AND ( CAST(:room_bucket AS text) IS NULL OR crm.room_bucket = CAST(:room_bucket AS text) ) GROUP BY crm.report_month """ ) # ────────────────────────────────────────────────────────────────────────────── # SQL — Source B (objective_lots, per-lot deal dates) # ────────────────────────────────────────────────────────────────────────────── # Source B: COUNT(*) проданных лотов по месяцу РЕГИСТРАЦИИ сделки # (date_trunc('month', registration_date) — есть индекс objective_lots_reg_idx). # Авторитетен для room×area×price-сегментации (несёт area_pd / price_per_m2_rub # per-lot, чего Source A не даёт). class — lowercase в этой таблице → матчим # LOWER(...)=LOWER(...). room×area bucket и price bucket считаем В SQL теми же # порогами, что pure-helpers (держим логику зеркальной — пороги ниже = константы # _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*; при правке менять в ОБОИХ местах). # Цена — средневзвешенная по площади: SUM(price*area)/NULLIF(SUM(area),0) # (корректнее простого AVG по лотам разной площади). # КАВЕАТ ВЫЖИВАЕМОСТИ задокументирован в module docstring. _SOURCE_B_SQL = text( """ WITH sold AS ( SELECT CAST(date_trunc('month', ol.registration_date) AS date) AS month, ol.area_pd AS area_pd, ol.price_per_m2_rub AS price_per_m2, CASE WHEN ol.area_pd >= CAST(:large_area AS numeric) THEN CAST(:b_large AS text) WHEN ol.rooms_int IS NULL THEN CAST(:b_unknown AS text) WHEN ol.rooms_int <= 0 THEN CAST(:b_studio AS text) WHEN ol.rooms_int = 1 THEN CAST(:b_1k AS text) WHEN ol.rooms_int = 2 THEN CAST(:b_2k AS text) WHEN ol.rooms_int = 3 THEN CAST(:b_3k AS text) ELSE CAST(:b_large AS text) END AS room_area_bucket, CASE WHEN ol.price_per_m2_rub IS NULL OR ol.price_per_m2_rub <= 0 THEN CAST(:p_unknown AS text) WHEN ol.price_per_m2_rub < CAST(:p_economy_max AS numeric) THEN CAST(:p_economy AS text) WHEN ol.price_per_m2_rub < CAST(:p_comfort_max AS numeric) THEN CAST(:p_comfort AS text) WHEN ol.price_per_m2_rub < CAST(:p_business_max AS numeric) THEN CAST(:p_business AS text) ELSE CAST(:p_premium AS text) END AS price_bucket FROM objective_lots ol WHERE ol.premise_kind = CAST(:premise_kind AS text) AND ol.registration_date IS NOT NULL AND ol.registration_date >= CAST(:since AS date) AND (CAST(:cls AS text) IS NULL OR LOWER(ol.class) = LOWER(CAST(:cls AS text))) AND ( CAST(:has_district AS boolean) IS FALSE OR ol.district = ANY(CAST(:districts AS text[])) ) ) SELECT month, COUNT(*) AS units, SUM(area_pd) AS area_m2, SUM(price_per_m2 * area_pd) / NULLIF(SUM(area_pd), 0) AS avg_price_per_m2 FROM sold WHERE (CAST(:room_bucket AS text) IS NULL OR room_area_bucket = CAST(:room_bucket AS text)) AND (CAST(:price_bucket AS text) IS NULL OR price_bucket = CAST(:price_bucket AS text)) GROUP BY month """ ) # premise_kind по умолчанию для Source B (жилые квартиры — единственный сегмент, # по которому считаем продажи §9.6; зеркалит market_metrics default). _DEFAULT_PREMISE_KIND: str = "квартира" def build_sales_series( db: Session, *, spec: SegmentSpec, source: SalesSource, months_back: int = _DEFAULT_MONTHS_BACK, premise_kind: str = _DEFAULT_PREMISE_KIND, ) -> SalesSeries: """Собрать monthly ряд продаж по сегменту из выбранного источника. Ряд строится по НЕПРЕРЫВНОЙ сетке месяцев [start .. текущий] (а не только по месяцам со сделками): месяц без продаж → units=0 (настоящий ноль), area/price = None. Это даёт регрессии §9.6 регулярный шаг по времени. Source выбирает зону авторитетности (см. module docstring): • 'corpus_room_month' — агрегаты класс/район (полные сделки ДДУ+ДКП). • 'objective_lots' — тонкая room×area×price-сегментация (но survivorship bias на старых месяцах — каведат в module docstring). Graceful: при сбое БД / пустых данных возвращается ряд по сетке с units=0, area/price=None, confidence='low' (НЕ crash). Пустой ряд (months=[]) — только если сетка пуста (months_back < 0). Args: db: SQLAlchemy sync Session. spec: сегмент (любой subset осей; None-поля = агрегировать по оси). source: 'corpus_room_month' | 'objective_lots'. months_back: глубина ряда (по умолчанию _DEFAULT_MONTHS_BACK). premise_kind: тип помещения для Source B (по умолчанию 'квартира'). Returns: SalesSeries (всегда; [] months только при пустой сетке). """ today = date.today() start = _shift_months(today, -max(0, months_back)) grid = _month_grid(start, _month_start(today)) segment = spec.as_dict() if not grid: return SalesSeries( months=[], units=[], area_m2=[], avg_price_per_m2=[], n_months=0, source=source, segment=segment, confidence="low", ) if source == "corpus_room_month": by_month = _query_source_a(db, spec=spec, since=start) else: by_month = _query_source_b(db, spec=spec, since=start, premise_kind=premise_kind) units, area_m2, avg_price = fill_month_grid(by_month, grid) confidence = _confidence(units) logger.info( "build_sales_series: source=%s months=%d nonzero=%d " "class=%s room=%s district=%s price=%s confidence=%s", source, len(grid), sum(1 for u in units if u > 0), spec.obj_class, spec.room_bucket, spec.district, spec.price_bucket, confidence, ) return SalesSeries( months=grid, units=units, area_m2=area_m2, avg_price_per_m2=avg_price, n_months=len(grid), source=source, segment=segment, confidence=confidence, ) def _query_source_a( db: Session, *, spec: SegmentSpec, since: date ) -> dict[date, tuple[int, float | None, float | None]]: """Source A (corpus_room_month) → {month1st: (units, area, avg_price)}. Graceful → {} при сбое/пустых данных. price_bucket в spec для Source A игнорируется (агрегат не несёт per-lot цены) — фиксируется логом. """ if spec.price_bucket is not None: logger.info( "build_sales_series: price_bucket=%s ignored for source=corpus_room_month " "(monthly aggregate carries no per-lot price)", spec.price_bucket, ) # district (админ-имя ЕКБ) → набор informal микро (crm.district хранит микро — # тот же вокабуляр, что objective_lots; verified на prod). None → EKB-wide. micros = resolve_objective_districts(db, spec.district) params = { "since": since, "cls": spec.obj_class, "has_district": micros is not None, "districts": micros if micros is not None else [], "room_bucket": spec.room_bucket, } try: rows = db.execute(_SOURCE_A_SQL, params).mappings().all() except Exception: logger.exception("build_sales_series: source A query failed") return {} return _rows_to_by_month(rows) def _query_source_b( db: Session, *, spec: SegmentSpec, since: date, premise_kind: str ) -> dict[date, tuple[int, float | None, float | None]]: """Source B (objective_lots) → {month1st: (units, area, avg_price)}. Graceful → {} при сбое/пустых данных. Передаёт bucket-пороги/-метки в SQL (зеркало pure-helpers), чтобы room×area / price сегментация считалась тем же правилом и в БД, и в Python. """ # district (админ-имя ЕКБ) → набор informal микро (objective_lots хранит микро). # None → EKB-wide (без district-фильтра). micros = resolve_objective_districts(db, spec.district) params = { "since": since, "premise_kind": premise_kind, "cls": spec.obj_class, "has_district": micros is not None, "districts": micros if micros is not None else [], "room_bucket": spec.room_bucket, "price_bucket": spec.price_bucket, # bucket-пороги (зеркало _LARGE_AREA_THRESHOLD_M2 / _PRICE_BAND_*). "large_area": _LARGE_AREA_THRESHOLD_M2, "p_economy_max": _PRICE_BAND_ECONOMY_MAX, "p_comfort_max": _PRICE_BAND_COMFORT_MAX, "p_business_max": _PRICE_BAND_BUSINESS_MAX, # bucket-метки (зеркало ROOM_AREA_BUCKET_* / PRICE_BUCKET_*). "b_studio": ROOM_AREA_BUCKET_STUDIO, "b_1k": ROOM_AREA_BUCKET_1K, "b_2k": ROOM_AREA_BUCKET_2K, "b_3k": ROOM_AREA_BUCKET_3K, "b_large": ROOM_AREA_BUCKET_LARGE, "b_unknown": ROOM_AREA_BUCKET_UNKNOWN, "p_economy": PRICE_BUCKET_ECONOMY, "p_comfort": PRICE_BUCKET_COMFORT, "p_business": PRICE_BUCKET_BUSINESS, "p_premium": PRICE_BUCKET_PREMIUM, "p_unknown": PRICE_BUCKET_UNKNOWN, } try: rows = db.execute(_SOURCE_B_SQL, params).mappings().all() except Exception: logger.exception("build_sales_series: source B query failed") return {} return _rows_to_by_month(rows) def _rows_to_by_month( rows: list[Any], ) -> dict[date, tuple[int, float | None, float | None]]: """Свести строки (month, units, area_m2, avg_price_per_m2) в {month1st: tuple}. Площадь/цена → None если NULL (нет сделок с известным значением). units приводим к int (≥0). Строки с month IS NULL пропускаем (защита от мусора). """ out: dict[date, tuple[int, float | None, float | None]] = {} for r in rows: month = r["month"] if month is None: continue units = int(r["units"] or 0) area = float(r["area_m2"]) if r["area_m2"] is not None else None price = float(r["avg_price_per_m2"]) if r["avg_price_per_m2"] is not None else None out[_month_start(month)] = (units, area, price) return out