diff --git a/backend/app/services/forecasting/demand_supply_forecast.py b/backend/app/services/forecasting/demand_supply_forecast.py index e4396b82..c87d8434 100644 --- a/backend/app/services/forecasting/demand_supply_forecast.py +++ b/backend/app/services/forecasting/demand_supply_forecast.py @@ -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, diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index 17e6f967..ff17da88 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -95,7 +95,8 @@ _CLASS_TO_FORECAST: dict[str, str] = { "Comfort+": "комфорт", "Business": "бизнес", "Elite": "бизнес", - "Economy": "эконом", + # #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет). + "Economy": "стандарт", } # ── #983 named-константы (§10/§16) ───────────────────────────────────────────── diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index e0bc0855..73733756 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -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 diff --git a/backend/app/services/forecasting/what_to_build.py b/backend/app/services/forecasting/what_to_build.py index 55fc99d0..4dc348ee 100644 --- a/backend/app/services/forecasting/what_to_build.py +++ b/backend/app/services/forecasting/what_to_build.py @@ -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+. Это diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index d8e415df..f8bc6c99 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -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, diff --git a/backend/tests/services/forecasting/test_demand_supply_forecast.py b/backend/tests/services/forecasting/test_demand_supply_forecast.py index abf56350..1c58a66f 100644 --- a/backend/tests/services/forecasting/test_demand_supply_forecast.py +++ b/backend/tests/services/forecasting/test_demand_supply_forecast.py @@ -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) ──────────────────────────────────── diff --git a/backend/tests/services/forecasting/test_recommendation.py b/backend/tests/services/forecasting/test_recommendation.py index c341c47e..e9c36cdf 100644 --- a/backend/tests/services/forecasting/test_recommendation.py +++ b/backend/tests/services/forecasting/test_recommendation.py @@ -119,7 +119,8 @@ class TestMapClass: ("Comfort+", "комфорт"), ("Business", "бизнес"), ("Elite", "бизнес"), - ("Economy", "эконом"), + # #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет). + ("Economy", "стандарт"), ], ) def test_folding(self, live: str, expected: str) -> None: diff --git a/backend/tests/services/forecasting/test_report_assembler.py b/backend/tests/services/forecasting/test_report_assembler.py index 85cb7b09..23385823 100644 --- a/backend/tests/services/forecasting/test_report_assembler.py +++ b/backend/tests/services/forecasting/test_report_assembler.py @@ -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 ────────────────────────────────────────────── diff --git a/backend/tests/services/site_finder/test_market_metrics.py b/backend/tests/services/site_finder/test_market_metrics.py index d6840fad..626fa451 100644 --- a/backend/tests/services/site_finder/test_market_metrics.py +++ b/backend/tests/services/site_finder/test_market_metrics.py @@ -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 diff --git a/data/sql/173_objective_lots_physflat_idx.sql b/data/sql/173_objective_lots_physflat_idx.sql new file mode 100644 index 00000000..8caf0ac8 --- /dev/null +++ b/data/sql/173_objective_lots_physflat_idx.sql @@ -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;