fix(forecast): посегментный+дедуплицированный индекс дефицита (#1959)

Корень «−1.00 везде» (эпик #1953): compute_demand_supply_forecast брал
district-wide unit_velocity (847.5/мес, ВСЕ классы/комнаты) как спрос и
весь district-сток (~63k доступных) как предложение для КАЖДОЙ ячейки
what_to_build → один и тот же ratio во всех ячейках → все deficit_index
прижаты к −1.0. Плюс objective_lots — append-per-snapshot (~2.9× инфляция
строк), что симметрично раздувало обе базы → даже сегментация без дедупа
осталась бы вырожденной.

Фикс (blast radius — ТОЛЬКО forecast/deficit calc; platform-wide dedup = #1964):
- market_metrics.compute_market_metrics: +obj_class/+room_bucket (+cache key).
  _STOCK_SQL и _SALES_WINDOW_SQL дедуплят до ПОСЛЕДНЕГО снапшота на физлот
  (DISTINCT ON project_name,corpus_name,section,floor,lot_number ORDER BY …
  snapshot_date DESC,id DESC), затем агрегируют. Class-фильтр (LOWER=LOWER,
  class lowercase) + room-bucket (Source-B room_area-вокабуляр, зеркало
  sales_series.room_area_bucket_of → what_to_build фильтрует без перевода).
  ROLLUP/GROUPING сохранён; confidence считается на дедуплицированных counts.
- demand_supply_forecast: base_pace и open-сток теперь ПОСЕГМЕНТНЫЕ
  (market_metrics(obj_class,room_bucket)). При заданном сегменте L2/L3
  (hidden/future) ИСКЛЮЧЕНЫ из баланса — они класс/формат-агностичны, иначе
  двоились бы по всем ячейкам. +_market_room_bucket VOCAB-мост (валидирующий
  pass-through Source-B меток; неизвестное → None = без фильтра, не тихий 0-rows).
- what_to_build/_DEFAULT_CLASSES и recommendation Economy-маппинг: «эконом»→
  «стандарт» (в objective_lots эконома НЕТ, стандарт=483k → раньше ячейка
  матчила 0 строк и молча выпадала).
- report_assembler honesty-guard: если ВСЯ сетка прижата к ±1.0
  (degenerate-fallback) — не эмитим «строить»/«избегать», показываем
  «недостаточно гранулярных данных для посегментного вывода».
- data/sql/173_objective_lots_physflat_idx.sql: partial index под DISTINCT ON
  (Index Only Scan + Unique, без Sort на 1.75M строк; idempotent, BEGIN/COMMIT).

Prod-verify (parcel 66:41:0205010:287, Железнодорожный, h=24): ячейки
ДИФФЕРЕНЦИРУЮТ (12 measured, 7 distinct) вместо all −1.0; MOI комфорт/студия
38.5 vs стандарт/студия 244.3 (точное совпадение с ожидаемым).

Тесты: регрессия «ячейки различаются (не all −1.0)» + vocab-translation +
honesty-guard + посегментное предложение. ruff clean; no :name::type.
This commit is contained in:
Light1YT 2026-06-27 22:50:58 +05:00
parent 3cd1605c5c
commit 41804ed70e
10 changed files with 520 additions and 42 deletions

View file

@ -73,7 +73,14 @@ from app.services.forecasting.demand_normalization import compute_demand_normali
from app.services.forecasting.macro_coefficient import compute_macro_coefficient
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
from app.services.forecasting.regression import compute_rate_regime_sensitivity
from app.services.forecasting.sales_series import SegmentSpec
from app.services.forecasting.sales_series import (
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
ROOM_AREA_BUCKET_STUDIO,
SegmentSpec,
)
from app.services.site_finder.competitors import get_competitors
from app.services.site_finder.future_supply import compute_future_supply_pressure
from app.services.site_finder.market_metrics import compute_market_metrics
@ -121,6 +128,51 @@ _CONFIDENCE_CAP: Confidence = "medium"
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
# #1959 VOCAB-мост (load-bearing): SegmentSpec.room_bucket приходит из what_to_build
# в Source-B room_area-вокабуляре ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/
# "3-к 60-80"/"80+ м²"). compute_market_metrics (после #1959) принимает ИМЕННО этот
# вокабуляр напрямую (его room CASE = зеркало sales_series.room_area_bucket_of). Эта
# white-list страхует от чужих/неизвестных меток: распознанные пробрасываем как есть,
# неизвестные (или None) → None = «без room-фильтра» (district-wide темп, но НЕ тихий
# 0-rows из-за опечатки в вокабуляре). Меняешь сетку room_buckets — обнови white-list.
_FORECAST_ROOM_BUCKETS: frozenset[str] = frozenset(
{
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
}
)
def _market_room_bucket(room_bucket: str | None) -> str | None:
"""Перевести SegmentSpec.room_bucket в room-фильтр compute_market_metrics. PURE.
Вокабуляр уже выровнен (#1959): market_metrics принимает Source-B room_area-метки
напрямую. Эта функция VALIDATING pass-through: распознанная Source-B метка
возвращается КАК ЕСТЬ; None / неизвестная метка None (= «без room-фильтра»,
district-wide темп). Без этой проверки опечатка/чужой вокабуляр дали бы тихий
0-rows фильтр метрики None ячейка молча выпадала бы из ранкинга. PURE.
Args:
room_bucket: room_bucket сегмента (ожидается Source-B вокабуляр) или None.
Returns:
Та же Source-B метка (если в белом списке) либо None.
"""
if room_bucket is None:
return None
if room_bucket in _FORECAST_ROOM_BUCKETS:
return room_bucket
logger.warning(
"demand_supply_forecast: unrecognized room_bucket %r — dropping room filter "
"(district-wide pace). Expected Source-B vocab %s.",
room_bucket,
sorted(_FORECAST_ROOM_BUCKETS),
)
return None
@dataclass(frozen=True)
class DemandSupplyForecast:
@ -555,6 +607,15 @@ def compute_demand_supply_forecast(
(она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО
ради explainability-фразы её β/x_pct в арифметику спроса НЕ входят.
#1959 ПОСЕГМЕНТНЫЙ баланс (фикс «1.00 везде»): и СПРОС, и ПРЕДЛОЖЕНИЕ теперь
сужаются под spec (obj_class/room_bucket), а не берутся district-wide:
base_pace = compute_market_metrics(obj_class, room_bucket).unit_velocity
дедуплицированный темп ИМЕННО сегмента (раньше один темп на все ячейки
каждая what_to_build-ячейка делила одно и то же дефицит = 1.0 везде).
open-сток = market_metrics.n_available того же сегмента (дедуп). При заданном
сегменте L2/L3 (hidden/future) ИСКЛЮЧАЮТСЯ из баланса они класс/формат-
агностичны, иначе один объём двоился бы по всем ячейкам (снова 1.0).
Один раз на вызов:
macro = get_monthly_macro(db) для дефолтного rate-path (hold_last_rate).
base_pace = compute_market_metrics(...).unit_velocity (§9.2 ед./мес).
@ -565,9 +626,10 @@ def compute_demand_supply_forecast(
rate_future = rate_path[h] (или hold_last_rate).
§9.4 compute_demand_normalization(rate_future=rate_future) norm (β внутри).
projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда).
§9.3 compute_future_supply_pressure(horizon_months=h) open/hidden/future;
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
open + hidden_release + future поглощённое спросом (clamp 0).
§9.3 compute_future_supply_pressure(horizon_months=h) open/hidden/future
(для НЕзаданного сегмента); при заданном сегменте open = n_available,
hidden/future = 0. Чистое предложение = open + hidden_release + future
поглощённое спросом (clamp 0).
balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
§9.7 future_competitors = top-N get_competitors(horizon_months=h).
@ -600,7 +662,19 @@ def compute_demand_supply_forecast(
)
# ── Один раз: §9.2 наблюдаемый темп (base_pace) ────────────────────────────
metrics = compute_market_metrics(db, district=district, premise_kind=premise_kind)
# #1959: ПОСЕГМЕНТНЫЙ темп — фильтруем market_metrics по obj_class + room_bucket
# сегмента. Раньше брался district-wide unit_velocity (один темп на ВСЕ ячейки
# what_to_build → дефицит вырождался в 1.0 везде). room_bucket идёт из Source-B
# вокабуляра what_to_build ("Студии 15-30"/…); market_metrics теперь принимает
# его напрямую (room CASE = зеркало room_area_bucket_of) → переводчик-валидатор
# (см. _market_room_bucket) только страхует от неизвестных меток.
metrics = compute_market_metrics(
db,
district=district,
premise_kind=premise_kind,
obj_class=spec.obj_class,
room_bucket=_market_room_bucket(spec.room_bucket),
)
base_pace = metrics.unit_velocity
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
@ -616,6 +690,15 @@ def compute_demand_supply_forecast(
# значение).
confounded = _series_confounded(macro_coef, sensitivity)
# #1959: сегмент «задан», если сужена хотя бы одна предметная ось (класс/формат).
# Тогда ПРЕДЛОЖЕНИЕ берём ПОСЕГМЕНТНОЕ — open = market_metrics.n_available
# (дедуплицированный сток ИМЕННО этого сегмента), а L2/L3 (hidden/future)
# ИСКЛЮЧАЕМ из баланса: они класс/формат-агностичны (нет room-оси) → один и тот
# же объём прибавился бы КАЖДОЙ ячейке → двойной счёт и снова вырождение к 1.0.
# Незаданный сегмент (оба None) → прежний district-wide путь (open из §9.3 view +
# hidden + future), обратная совместимость для агрегатных вызовов.
segmented = spec.obj_class is not None or spec.room_bucket is not None
out: list[DemandSupplyForecast] = []
for h in horizon_list:
out.append(
@ -629,6 +712,8 @@ def compute_demand_supply_forecast(
rate_future=effective_rate_path.get(h),
base_pace=base_pace,
market_confidence=metrics.confidence,
segment_open_units=metrics.n_available,
segmented=segmented,
macro_coef=macro_coef,
sensitivity_phrase=sensitivity.phrase,
confounded=confounded,
@ -663,6 +748,8 @@ def _forecast_for_horizon(
rate_future: float | None,
base_pace: float | None,
market_confidence: Confidence,
segment_open_units: int,
segmented: bool,
macro_coef: Any,
sensitivity_phrase: str | None,
confounded: bool,
@ -680,16 +767,28 @@ def _forecast_for_horizon(
projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon)
# ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое ( поглощённое) ───
# fsp читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного
# сегмента — ещё и open/hidden/future как раньше).
fsp = compute_future_supply_pressure(
db, district=district, horizon_months=horizon, premise_kind=premise_kind
)
hidden_fraction = _hidden_release_fraction(horizon)
hidden_release = fsp.hidden_units * hidden_fraction
future_online = fsp.future_units_by_horizon
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
projected_supply = _project_supply(
fsp.open_units, hidden_release, future_online, projected_demand
)
if segmented:
# #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента
# (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future)
# класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке,
# один и тот же объём двоится по всем сегментам → дефицит снова липнет к 1.0.
# Поэтому в посегментном балансе hidden/future = 0 (в dataclass их тоже не
# показываем, чтобы explain не вводил в заблуждение «этот сегмент имеет L2/L3»).
open_units = segment_open_units
hidden_release = 0.0
future_online = 0.0
else:
open_units = fsp.open_units
hidden_fraction = _hidden_release_fraction(horizon)
hidden_release = fsp.hidden_units * hidden_fraction
future_online = fsp.future_units_by_horizon
gross_supply = _gross_supply(open_units, hidden_release, future_online)
projected_supply = _project_supply(open_units, hidden_release, future_online, projected_demand)
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
@ -737,7 +836,7 @@ def _forecast_for_horizon(
demand_norm_coefficient=norm_coefficient,
macro_coefficient=macro_coefficient,
projected_demand_units=projected_demand,
open_units=fsp.open_units,
open_units=open_units,
hidden_release_units=hidden_release,
future_online_units=future_online,
projected_supply_units=projected_supply,

View file

@ -95,7 +95,8 @@ _CLASS_TO_FORECAST: dict[str, str] = {
"Comfort+": "комфорт",
"Business": "бизнес",
"Elite": "бизнес",
"Economy": "эконом",
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
"Economy": "стандарт",
}
# ── #983 named-константы (§10/§16) ─────────────────────────────────────────────

View file

@ -88,6 +88,14 @@ _STRONG_DEFICIT_THRESHOLD: float = 0.25
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
_SIGNAL_BALANCE_EPS: float = 0.05
# #1959 honesty-guard: если ВСЕ ячейки сетки прижаты к ±1.0 (дефицит не различает
# сегменты — degenerate-fallback, как было до фикса посегментации), категоричные
# вердикты «строить»/«избегать» вводят в заблуждение (на самом деле сигнал не
# дискриминирует). Тогда вместо вердикта показываем честную плашку. |di| ≥ этого
# порога считаем «прижато к пределу шкалы».
_DEGENERATE_CLAMP_THRESHOLD: float = 0.999
_DEGENERATE_SEGMENT_NOTE: str = "недостаточно гранулярных данных для посегментного вывода"
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
_PRIMARY_HORIZON_MONTHS: int = 12
@ -532,33 +540,71 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
"""
explicit = product_tz.get("mix")
if isinstance(explicit, list):
return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)]
entries = [m for m in explicit if isinstance(m, dict)]
degenerate = _all_cells_clamped([e.get("deficit_index") for e in entries])
return [_enrich_mix_entry(m, degenerate=degenerate) for m in entries]
ranked = product_tz.get("ranked_segments")
if isinstance(ranked, list):
segs = [seg for seg in ranked if isinstance(seg, dict)]
# #1959 honesty-guard: вся сетка прижата к ±1.0 → дефицит не дискриминирует
# сегменты (degenerate-fallback). Не эмитим категоричные «строить»/«избегать»
# — вместо них честная плашка `_DEGENERATE_SEGMENT_NOTE`.
degenerate = _all_cells_clamped([seg.get("deficit_index") for seg in segs])
return [
{
"bucket": seg.get("bucket"),
"obj_class": seg.get("obj_class"),
"deficit_index": seg.get("deficit_index"),
"projected_demand_units": seg.get("projected_demand_units"),
"signal": _build_signal(seg.get("deficit_index")),
"signal": (
_DEGENERATE_SEGMENT_NOTE
if degenerate
else _build_signal(seg.get("deficit_index"))
),
}
for seg in ranked
if isinstance(seg, dict)
for seg in segs
]
return []
def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]:
def _all_cells_clamped(deficit_indices: Sequence[Any]) -> bool:
"""True если ВСЕ измеримые ячейки прижаты к ±1.0 (degenerate-fallback). PURE.
#1959 honesty-guard. «Измеримые» = числовые deficit_index (None-ячейки —
тонкие данные, в знаменатель не идут). Пусто / нет измеримых False (нечего
защищать обычный graceful-путь). Если хотя бы одна ячейка различается
(|di| < порога), сетка ДИСКРИМИНИРУЕТ вердикты честны False. PURE.
Args:
deficit_indices: deficit_index ячеек (числа / None / мусор).
Returns:
True только когда есть 1 измеримая ячейка и ВСЕ они |di| порога.
"""
measured = [
float(d) for d in deficit_indices if isinstance(d, int | float) and not isinstance(d, bool)
]
if not measured:
return False
return all(abs(d) >= _DEGENERATE_CLAMP_THRESHOLD for d in measured)
def _enrich_mix_entry(entry: dict[str, Any], *, degenerate: bool = False) -> dict[str, Any]:
"""Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE.
Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет
`signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует
вход assembler-чистота).
#1959 honesty-guard: `degenerate=True` (вся сетка прижата к ±1.0) → вместо
категоричного сигнала ставим честную плашку `_DEGENERATE_SEGMENT_NOTE` (только
когда у ячейки не было своего явного `signal`).
"""
enriched = dict(entry)
if enriched.get("signal") is None:
enriched["signal"] = _build_signal(enriched.get("deficit_index"))
enriched["signal"] = (
_DEGENERATE_SEGMENT_NOTE if degenerate else _build_signal(enriched.get("deficit_index"))
)
return enriched

View file

@ -64,7 +64,10 @@ Confidence = Literal["high", "medium", "low"]
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
# может добавить). Держим явной константой для детерминизма сетки.
_DEFAULT_CLASSES: tuple[str, ...] = ("эконом", "комфорт", "бизнес")
# #1959: нижний массовый класс objective_lots.class — «стандарт», НЕ «эконом»
# (verified на prod: стандарт=483k лотов, эконома НЕТ → раньше эта ячейка матчила
# 0 строк → молча выпадала из сетки; посегментный дефицит её не видел).
_DEFAULT_CLASSES: tuple[str, ...] = ("стандарт", "комфорт", "бизнес")
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это

View file

@ -51,18 +51,23 @@ def _market_metrics_key(
obj_ids: Sequence[int] | None = None,
window_months: int = 6,
premise_kind: str = "квартира",
obj_class: str | None = None,
room_bucket: str | None = None,
) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для compute_market_metrics (#1129).
Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
hashability), окно, premise_kind. В форсайт-пути obj_ids всегда None tuple()
устойчив. `db` не в ключе (одна сессия на отчёт).
hashability), окно, premise_kind, + сегмент-оси obj_class/room_bucket (#1959 —
посегментный дефицит). В форсайт-пути obj_ids всегда None tuple() устойчив.
`db` не в ключе (одна сессия на отчёт).
"""
return (
district,
tuple(obj_ids) if obj_ids is not None else None,
window_months,
premise_kind,
obj_class,
room_bucket,
)
@ -269,15 +274,33 @@ def _room_bucket(rooms_int: int | None) -> str:
# SQL aggregation
# ──────────────────────────────────────────────────────────────────────────────
# Текущий сток per-flat. Считаем по objective_lots (последний UPSERT-снапшот).
# Текущий сток per-flat. ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959):
# objective_lots — append-per-snapshot (~2.9× инфляция строк), без дедупа сток
# раздут и симметрично раздувает спрос И предложение → деградирует посегментный
# дефицит. Ключ физлота = (project_name, corpus_name, section, floor, lot_number);
# берём строку с MAX(snapshot_date), tie-break MAX(id) (зеркало
# objective_lots_physflat_latest_idx из 100_*.sql). project_name NOT NULL;
# corpus/section/floor/lot_number могут быть NULL (~2.5% лотов) — для таких лотов
# DISTINCT ON схлопывает NULL=NULL в одну группу (неидентифицируемые физлоты, в
# дефицит-расчёте безопасно), для остального — точный per-flat дедуп.
#
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр
# ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²"), ТОЧНОЕ зеркало
# sales_series.room_area_bucket_of (и _SOURCE_B_SQL): площадь ≥ :large_area → LARGE,
# иначе по rooms_int; так what_to_build (Source-B сетка) фильтрует БЕЗ перевода
# вокабуляра. Применяются ДО DISTINCT ON: сегментация объективна по физлоту
# (класс/комнатность/площадь не меняются между снапшотами).
#
# is_sold распознаём И через флаг is_sold, И через наличие contract_date / статус
# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и
# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»).
_STOCK_SQL = text(
"""
WITH lots AS (
SELECT
ol.objective_lot_id,
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
ol.project_name,
ol.rooms_int,
ol.area_pd,
@ -297,6 +320,26 @@ _STOCK_SQL = text(
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
)
AND (
CAST(:cls AS text) IS NULL
OR LOWER(ol.class) = LOWER(CAST(:cls AS text))
)
AND (
CAST(:room_bucket AS text) IS NULL
OR (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) = CAST(:room_bucket AS text)
)
ORDER BY
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT
COUNT(*) AS n_lots,
@ -320,9 +363,41 @@ _STOCK_SQL = text(
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
# area_pd берём из самого objective_lots (текущий per-flat area).
#
# ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959, симметрично _STOCK_SQL):
# без дедупа один проданный физлот, присутствующий в N снапшотах, считается N раз
# → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому
# предложению → дефицит вырождается в 1.0). Дедупим в CTE `latest`, окно по
# contract_date и сегмент-фильтры применяем уже к дедуплицированному набору.
# Ключ/направления дедупа — зеркало _STOCK_SQL / objective_lots_physflat_latest_idx.
# contract_date / rooms_int / area_pd берём из последнего снапшота физлота.
# Сегмент-фильтры (#1959): obj_class (lowercase, LOWER=LOWER) + room_bucket
# (Source-A вокабуляр студия/1/2/3/4/5+) — применяются ПОСЛЕ дедупа (объективны
# по физлоту). ROLLUP остаётся: при room_bucket-фильтре отдаёт {bucket, total}.
_SALES_WINDOW_SQL = text(
"""
WITH latest AS (
SELECT DISTINCT ON (
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
)
ol.rooms_int,
ol.area_pd,
ol.contract_date,
ol.class
FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
)
AND (
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
)
ORDER BY
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
ol.snapshot_date DESC, ol.id DESC
)
SELECT
COUNT(*) AS units_sold_window,
COALESCE(SUM(area_pd), 0) AS area_sold_window,
@ -334,18 +409,26 @@ _SALES_WINDOW_SQL = text(
-- эмитит итог ПЕРВЫМ NULL-группа затирает units_total частичным
-- счётом unit_velocity/absorption занижены, MoS завышен.
GROUPING(rooms_int) AS is_total
FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind
FROM latest
WHERE contract_date IS NOT NULL
AND contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
AND (
CAST(:has_district AS boolean) IS FALSE
OR ol.district = ANY(CAST(:districts AS text[]))
CAST(:cls AS text) IS NULL
OR LOWER(class) = LOWER(CAST(:cls AS text))
)
AND (
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
CAST(:room_bucket AS text) IS NULL
OR (CASE
WHEN area_pd >= CAST(:large_area AS numeric)
THEN CAST(:b_large AS text)
WHEN rooms_int IS NULL THEN CAST(:b_unknown AS text)
WHEN rooms_int <= 0 THEN CAST(:b_studio AS text)
WHEN rooms_int = 1 THEN CAST(:b_1k AS text)
WHEN rooms_int = 2 THEN CAST(:b_2k AS text)
WHEN rooms_int = 3 THEN CAST(:b_3k AS text)
ELSE CAST(:b_large AS text)
END) = CAST(:room_bucket AS text)
)
AND ol.contract_date IS NOT NULL
AND ol.contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
GROUP BY ROLLUP (rooms_int)
"""
)
@ -359,6 +442,8 @@ def compute_market_metrics(
obj_ids: Sequence[int] | None = None,
window_months: int = 6,
premise_kind: str = "квартира",
obj_class: str | None = None,
room_bucket: str | None = None,
) -> MarketMetrics:
"""Вычислить рыночные метрики ТЗ §9.2 для локации.
@ -366,9 +451,32 @@ def compute_market_metrics(
заданы). Если ни один не задан считается по всей выборке premise_kind
(имеет смысл для ЕКБ-wide baseline).
#1959 (посегментный дефицит): `obj_class` (lowercase в objective_lots,
матчим регистронезависимо) и `room_bucket` (Source-A вокабуляр
"студия"/"1"/"2"/"3"/"4"/"5+", зеркало `_room_bucket()`) сужают выборку до
одного сегмента. Так base_pace (unit_velocity) и доступный сток становятся
ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки what_to_build делят один
темп дефицит вырождается в 1.0). None по обеим осям прежнее district-wide
поведение. ВАЖНО: SQL дедуплицирует objective_lots до последнего снапшота на
физлот counts (а значит и confidence) считаются на дедуплицированном наборе.
Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики =
None, confidence='low'. Никогда не бросает на отсутствии данных.
"""
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of /
# _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без перевода.
# Локальный (lazy) импорт: модульный импорт sales_series тянет forecasting/__init__
# → demand_supply_forecast → future_supply → market_metrics (циклический импорт).
from app.services.forecasting.sales_series import (
_LARGE_AREA_THRESHOLD_M2,
ROOM_AREA_BUCKET_1K,
ROOM_AREA_BUCKET_2K,
ROOM_AREA_BUCKET_3K,
ROOM_AREA_BUCKET_LARGE,
ROOM_AREA_BUCKET_STUDIO,
ROOM_AREA_BUCKET_UNKNOWN,
)
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
has_obj_ids = bool(obj_id_list)
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
@ -386,6 +494,19 @@ def compute_market_metrics(
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
"obj_ids": obj_id_list,
"overstock_interval": f"{_OVERSTOCK_MONTHS_THRESHOLD} months",
# #1959 сегмент-фильтры (None → ось не сужается).
"cls": obj_class,
"room_bucket": room_bucket,
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of
# / _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без
# перевода. Меняешь bucketing — правь в ОБОИХ местах (тут + sales_series).
"large_area": _LARGE_AREA_THRESHOLD_M2,
"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,
}
# ── Текущий сток ──────────────────────────────────────────────────────────
@ -433,10 +554,12 @@ def compute_market_metrics(
confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
logger.info(
"market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d "
"n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
"market_metrics: district=%s micros=%s class=%s room=%s obj_ids=%d n_lots=%d "
"n_sold=%d n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
district,
micros,
obj_class,
room_bucket,
len(obj_id_list),
n_lots,
n_sold_total,

View file

@ -44,6 +44,7 @@ from app.services.forecasting.demand_supply_forecast import (
_demand_per_mo,
_gross_supply,
_hidden_release_fraction,
_market_room_bucket,
_min_confidence,
_months_of_inventory,
_project_demand,
@ -552,10 +553,18 @@ class TestAsDict:
# ── orchestrator helpers (стабы §9.x compute_*) ───────────────────────────────
def _metrics_stub(*, unit_velocity: float | None = 10.0, confidence: str = "high") -> MagicMock:
def _metrics_stub(
*,
unit_velocity: float | None = 10.0,
confidence: str = "high",
n_available: int = 0,
) -> MagicMock:
m = MagicMock()
m.unit_velocity = unit_velocity
m.confidence = confidence
# #1959: посегментный путь читает n_available как open-сток сегмента. Дефолт 0
# (НЕзаданный сегмент его не использует — supply берётся из fsp-стаба).
m.n_available = n_available
return m
@ -683,7 +692,24 @@ class _Patches:
def _run(**over: object) -> list[DemandSupplyForecast]:
spec = SegmentSpec(obj_class="комфорт", district="Академический")
# #1959: НЕзаданный сегмент (только district) → предложение берётся из §9.3 слоёв
# (fsp-стаб), как и тестировалось исторически. Посегментный путь (obj_class/
# room_bucket заданы → open=n_available, L2/L3 исключены) проверяется отдельно в
# TestSegmentedSupply ниже.
spec = SegmentSpec(district="Академический")
return compute_demand_supply_forecast(
MagicMock(),
spec=spec,
district="Академический",
cad_num="66:41:0303161:123",
**over, # type: ignore[arg-type]
)
def _run_segmented(**over: object) -> list[DemandSupplyForecast]:
"""#1959 посегментный прогон: spec с obj_class+room_bucket → open=n_available,
L2/L3 (hidden/future) исключены из баланса."""
spec = SegmentSpec(obj_class="комфорт", room_bucket="Студии 15-30", district="Академический")
return compute_demand_supply_forecast(
MagicMock(),
spec=spec,
@ -1193,6 +1219,95 @@ class TestGraceful:
assert all(isinstance(f, DemandSupplyForecast) for f in res)
# ── #1959: VOCAB-мост room_bucket (load-bearing) ──────────────────────────────
class TestMarketRoomBucketTranslator:
"""_market_room_bucket: Source-B метки what_to_build → room-фильтр market_metrics.
Вокабуляр выровнен (#1959) → распознанная Source-B метка проходит КАК ЕСТЬ;
None / неизвестная None (= без room-фильтра, district-wide темп) вместо тихого
0-rows фильтра, который выбросил бы ячейку из ранкинга.
"""
@pytest.mark.parametrize(
"bucket",
["Студии 15-30", "1-к 30-45", "2-к 45-60", "3-к 60-80", "80+ м²"],
)
def test_recognized_source_b_passes_through(self, bucket: str) -> None:
assert _market_room_bucket(bucket) == bucket
def test_none_to_none(self) -> None:
assert _market_room_bucket(None) is None
def test_unknown_vocab_to_none(self) -> None:
# Source-A метка ("студия"/"1"/…) или опечатка → None (НЕ тихий 0-rows).
assert _market_room_bucket("студия") is None
assert _market_room_bucket("1") is None
assert _market_room_bucket("totally-bogus") is None
# ── #1959: ПОСЕГМЕНТНОЕ предложение (open=n_available, L2/L3 исключены) ─────────
class TestSegmentedSupply:
"""#1959: при заданном сегменте (obj_class/room_bucket) предложение = открытый
сток ИМЕННО сегмента (market_metrics.n_available), а L2/L3 (hidden/future)
ИСКЛЮЧАЮТСЯ из баланса (они класс/формат-агностичны иначе двоились бы по всем
ячейкам дефицит снова вырождается в 1.0).
"""
def test_open_units_from_n_available_not_fsp(self) -> None:
# fsp отдаёт open=300/hidden=200/future=40, НО сегмент задан → open = 555
# (n_available сегмента), hidden/future = 0.
with _Patches(
_METRICS=_metrics_stub(unit_velocity=0.0, n_available=555),
_SUPPLY=_supply_stub(open_units=300, hidden_units=200, future_units_by_horizon=40.0),
):
res = _run_segmented(horizons=[12])
f = res[0]
assert f.open_units == 555 # из n_available, НЕ fsp.open_units (300)
assert f.hidden_release_units == 0.0 # L2 исключён
assert f.future_online_units == 0.0 # L3 исключён
# спрос 0 → нет поглощения → чистое = open сегмента.
assert f.projected_supply_units == pytest.approx(555.0)
def test_segments_differentiate_not_all_minus_one(self) -> None:
"""Регрессия #1959: ДВА сегмента с разным спросом/стоком → РАЗНЫЙ
deficit_index (НЕ оба 1.0). Корень бага: один district-wide темп + один
district-wide сток каждая ячейка делила одно и то же все 1.0.
"""
def seg(*, velocity: float, n_available: int) -> DemandSupplyForecast:
with _Patches(
_METRICS=_metrics_stub(unit_velocity=velocity, n_available=n_available),
_NORM=_norm_stub(coefficient=1.0),
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
):
return _run_segmented(horizons=[24])[0]
# Сегмент с сильным спросом + малым стоком → дефицит (index > 0).
hot = seg(velocity=50.0, n_available=10)
# Сегмент со слабым спросом + большим стоком → затоварка (index < 0).
cold = seg(velocity=1.0, n_available=5000)
assert hot.deficit_index is not None and cold.deficit_index is not None
# КЛЮЧЕВОЕ: ячейки ДИСКРИМИНИРУЮТ (не липнут к одному значению).
assert hot.deficit_index != cold.deficit_index
assert hot.deficit_index > cold.deficit_index
def test_unsegmented_still_uses_fsp_layers(self) -> None:
# Контроль обратной совместимости: spec без class/room → supply из fsp слоёв
# (open 300 + hidden-phased + future), n_available игнорируется.
with _Patches(
_METRICS=_metrics_stub(unit_velocity=0.0, n_available=999),
_SUPPLY=_supply_stub(open_units=300, hidden_units=0, future_units_by_horizon=40.0),
):
res = _run(horizons=[12]) # _run = НЕзаданный сегмент
f = res[0]
assert f.open_units == 300 # из fsp, НЕ n_available (999)
assert f.future_online_units == pytest.approx(40.0)
# ── sanity: math import used (deficit lib) ────────────────────────────────────

View file

@ -119,7 +119,8 @@ class TestMapClass:
("Comfort+", "комфорт"),
("Business", "бизнес"),
("Elite", "бизнес"),
("Economy", "эконом"),
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
("Economy", "стандарт"),
],
)
def test_folding(self, live: str, expected: str) -> None:

View file

@ -29,8 +29,11 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
from app.services.forecasting.report import SiteFinderReport
from app.services.forecasting.report_assembler import (
_DEGENERATE_SEGMENT_NOTE,
_all_cells_clamped,
_analog_count,
_as_dict_or,
_build_product_tz,
_component_confidences,
_confounded,
_deal_count,
@ -383,6 +386,51 @@ class TestProductTz:
assert "комфорт" in pt["reasons"][0]["why"]
# ── #1959 honesty-guard: вся сетка прижата к ±1.0 → нет категоричных вердиктов ──
class TestDegenerateHonestyGuard:
def test_all_cells_clamped_detector(self) -> None:
# Все измеримые |di| ≥ порога → degenerate.
assert _all_cells_clamped([-1.0, -1.0, 1.0]) is True
assert _all_cells_clamped([-1.0, -0.9999999]) is True
# Хотя бы одна различается → НЕ degenerate.
assert _all_cells_clamped([-1.0, 0.3]) is False
assert _all_cells_clamped([0.0]) is False
# None-ячейки / пусто / мусор → False (нечего защищать).
assert _all_cells_clamped([None, None]) is False
assert _all_cells_clamped([]) is False
assert _all_cells_clamped([True, "x"]) is False
def test_degenerate_grid_suppresses_verdicts(self) -> None:
# Вся сетка 1.0 → вместо «строить»/«избегать» — честная плашка.
product_tz = {
"ranked_segments": [
{"bucket": "1-Студия", "obj_class": "стандарт", "deficit_index": -1.0},
{"bucket": "2-1к", "obj_class": "комфорт", "deficit_index": -1.0},
]
}
pt = _build_product_tz(product_tz).as_dict()
assert all(m["signal"] == _DEGENERATE_SEGMENT_NOTE for m in pt["mix"])
assert all(m["signal"] not in {"строить", "избегать", "баланс"} for m in pt["mix"])
def test_differentiated_grid_keeps_categorical_verdicts(self) -> None:
# Ячейки различаются → категоричные вердикты честны (фикс #1959 сработал).
product_tz = {
"ranked_segments": [
{"bucket": "1-Студия", "obj_class": "комфорт", "deficit_index": 0.73},
{"bucket": "2-3к", "obj_class": "комфорт", "deficit_index": -0.14},
{"bucket": "3-Студия", "obj_class": "стандарт", "deficit_index": -1.0},
]
}
pt = _build_product_tz(product_tz).as_dict()
signals = {m["bucket"]: m["signal"] for m in pt["mix"]}
assert signals["1-Студия"] == "строить"
assert signals["2-3к"] == "избегать"
assert signals["3-Студия"] == "избегать"
assert _DEGENERATE_SEGMENT_NOTE not in signals.values()
# ── scenarios ← #984 by_scenario ──────────────────────────────────────────────

View file

@ -733,8 +733,12 @@ class TestSalesWindowSource:
sales_sql = _executed_sql(db, 1)
# Окно теперь по contract_date из objective_lots — без зависимости от history.
assert "objective_lots_history" not in sales_sql
assert "ol.contract_date IS NOT NULL" in sales_sql
assert "ol.contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)" in sales_sql
# #1959: дедуп до последнего снапшота физлота в CTE latest, окно по
# contract_date применяется к дедуплицированному набору (bare-колонка,
# без ol.-префикса в outer WHERE).
assert "DISTINCT ON (" in sales_sql
assert "contract_date IS NOT NULL" in sales_sql
assert "contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)" in sales_sql
assert "FROM objective_lots ol" in sales_sql
# Контракт окна сохранён: ROLLUP по комнатности + interval-bind через CAST.
assert "GROUP BY ROLLUP (rooms_int)" in sales_sql

View file

@ -0,0 +1,38 @@
-- Контекст: индекс дефицита #1959 (эпик #1953) — «1.00 везде».
--
-- Прогнозный движок дефицита (compute_demand_supply_forecast) дедуплицирует
-- objective_lots до ПОСЛЕДНЕГО снапшота на каждый ФИЗИЧЕСКИЙ лот
-- (objective_lots — append-per-snapshot, ~2.9× инфляция строк; без дедупа спрос И
-- предложение оба раздуты симметрично → каждая ячейка what_to_build даёт один и тот
-- же ratio → все deficit_index = 1.0). Дедуп делается через
-- SELECT DISTINCT ON (project_name, corpus_name, section, floor, lot_number) …
-- ORDER BY project_name, corpus_name, section, floor, lot_number,
-- snapshot_date DESC, id DESC
-- по квартирам (premise_kind='квартира'). Этот partial index точно совпадает с
-- ключом DISTINCT ON и его направлениями сортировки → планировщик берёт Index-Only
-- Scan + Unique вместо сортировки 1.75M строк (EXPLAIN на prod: Sort исчезает,
-- cost ≈ 122k вместо полной сортировки).
--
-- DESC на snapshot_date/id — чтобы «последний снапшот первым» читался прямо из
-- индекса (DISTINCT ON берёт первую строку каждой группы). premise_kind в WHERE —
-- partial index только по квартирам (единственный сегмент дефицит-расчёта), меньше
-- и плотнее, чем полный.
--
-- Порядок применения: миграция ПЕРВОЙ (deploy first), затем backend-код, который
-- использует DISTINCT ON. Idempotent (CREATE INDEX IF NOT EXISTS) — безопасно при
-- ре-apply. Self-wrapped BEGIN/COMMIT.
BEGIN;
CREATE INDEX IF NOT EXISTS objective_lots_physflat_latest_idx
ON objective_lots (
project_name,
corpus_name,
section,
floor,
lot_number,
snapshot_date DESC,
id DESC
)
WHERE premise_kind = 'квартира';
COMMIT;