gendesign/backend/app/services/forecasting/sales_series.py
Light1YT 681a922d99
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-worker (push) Successful in 2m32s
Deploy / deploy (push) Successful in 1m7s
feat(forecast): resolve admin district -> micro set in §9.x market/supply/sales filters
/analyze passes the official ЕКБ admin district (ekb_districts polygon, e.g.
'Кировский'), but objective_lots/corpus_room_month store informal micro-districts
('Втузгородок','ЖБИ') -> admin name matched 0 rows -> silent empty forecast.

Add resolve_objective_districts() (site_finder/district_resolver.py) mapping an
admin name to its clean micros via ekb_district_alias (note IS NULL), with
None -> EKB-wide fallback and raw-micro pass-through. Wire into the objective_lots
district filters of market_metrics (§9.2 stock+sales), supply_layers L1 (§9.3),
and sales_series Sources A+B (crm shares the micro vocab, prod-verified),
switching the scalar filter to psycopg3-safe = ANY(CAST(:districts AS text[])).
supply_layers L2/L3 keep the admin name (domrf_kn_objects.district_name is admin vocab).

Prod: Кировский/Ленинский/Орджоникидзевский obj_count 0 -> 32/64/31.
Tests mutation-verified non-vacuous. 192 module tests pass; ruff clean. Refs #969 #949.
2026-06-05 07:03:37 +05:00

622 lines
32 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.

"""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 ₽/м² (нижний сегмент / окраины)
# комфорт : 120k160k ₽/м² (массовое тело рынка)
# бизнес : 160k220k ₽/м² (верх-средний)
# премиум : ≥ 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