Add backend/app/services/forecasting/sales_series.py — deterministic monthly sold-units (+area+avg price) time series per SegmentSpec, the Y-axis foundation for the §9.6 key-rate regression. Two sources with distinct authority: objective_corpus_room_month (class/district aggregates, full ДДУ+ДКП, count- weighted price ×1000 тыс→₽) and objective_lots (room×area×price segmentation, GROUP BY date_trunc registration_date; survivorship caveat documented). Pure DB-free helpers price_bucket_of / room_area_bucket_of / log_diff / fill_month_grid, unit-tested. In-SQL CASE bucketing shares the same threshold constants as the helpers (bound params) → no Python↔SQL drift. Months with no sales → units=0 (real zero), area/price → None. Graceful empty/thin → low confidence, zero-filled grid. Mirrors macro_series.py (PR2). room_bucket semantics are source-specific (documented on SegmentSpec). 62 tests, ruff clean.
610 lines
31 KiB
Python
610 lines
31 KiB
Python
"""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
|
||
|
||
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(:district AS text) IS NULL OR crm.district = CAST(:district 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(:district AS text) IS NULL
|
||
OR ol.district = CAST(:district 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,
|
||
)
|
||
params = {
|
||
"since": since,
|
||
"cls": spec.obj_class,
|
||
"district": spec.district,
|
||
"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.
|
||
"""
|
||
params = {
|
||
"since": since,
|
||
"premise_kind": premise_kind,
|
||
"cls": spec.obj_class,
|
||
"district": spec.district,
|
||
"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
|