From 3cafe22c15abd50df3406b18aa1d40d7138a21b0 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 27 Jun 2026 23:02:09 +0500 Subject: [PATCH] =?UTF-8?q?docs(forecast):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D1=83=D1=81=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B5=D0=B2=D1=88=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20#1959=20(deep-review=20f?= =?UTF-8?q?ollow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-only, без изменений логики (deep-code-reviewer APPROVE): - market_metrics _STOCK_SQL/_SALES_WINDOW_SQL + docstring: убрать ложную отсылку «зеркало … из 100_*.sql» (индекс создаётся ТОЛЬКО в миграции 173). - _SALES_WINDOW_SQL комментарий + compute_market_metrics docstring: room_bucket это Source-B room_area-вокабуляр («Студии 15-30»/«2-к 45-60»/«80+ м²», зеркало room_area_bucket_of), НЕ Source-A «студия/1/2/3» — приводим к фактическому CASE. - market_metrics: добавить комментарий, что DISTINCT-ON дедуп БЕЗУСЛОВНЫЙ (все вызовы compute_market_metrics, не только форсайт-путь); deep-review подтвердил безопасность для ratio/saturated-velocity консьюмеров; дедуп сырой таблицы objective_lots для platform-wide остаётся #1964. - recommendation.map_class docstring: Economy → «стандарт» (код уже маппит так). --- .../services/forecasting/recommendation.py | 5 +- .../services/site_finder/market_metrics.py | 47 ++++++++++++------- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index ff17da88..8f159ad3 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -146,8 +146,9 @@ def map_class(target_class: str | None) -> str | None: """Live target_class → forecast obj_class. PURE. None/неизвестное → None. Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite — в «бизнес», - Economy — в «эконом» (3 массовых класса §9.7). None → None = «без фильтра - класса», вызывающий отдаёт движку дефолтную сетку классов. + Economy — в «стандарт» (#1959: нижний массовый класс objective_lots — + «стандарт», эконома в данных нет). 3 массовых класса §9.7. None → None = + «без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов. """ if target_class is None: return None diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index f8bc6c99..da102fc7 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -278,11 +278,20 @@ def _room_bucket(rooms_int: int | None) -> str: # 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 дедуп. +# берём строку с MAX(snapshot_date), tie-break MAX(id). Поддерживается индексом +# objective_lots_physflat_latest_idx (создаётся в миграции 173) — он совпадает с +# ключом и направлениями этого DISTINCT ON → Index Only Scan + Unique без Sort. +# project_name NOT NULL; corpus/section/floor/lot_number могут быть NULL (~2.5% +# лотов) — для таких лотов DISTINCT ON схлопывает NULL=NULL в одну группу +# (неидентифицируемые физлоты, в дефицит-расчёте безопасно), для остального — +# точный per-flat дедуп. +# +# ⚠️ ДЕДУП БЕЗУСЛОВНЫЙ — применяется ко ВСЕМ вызовам compute_market_metrics, не +# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных +# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated), +# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп +# здесь касается ТОЛЬКО выборки внутри метрик — дедуп самой сырой таблицы +# objective_lots (для всех platform-консьюмеров) остаётся отдельной задачей #1964. # # Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим # LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр @@ -369,11 +378,15 @@ _STOCK_SQL = text( # → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому # предложению → дефицит вырождается в −1.0). Дедупим в CTE `latest`, окно по # contract_date и сегмент-фильтры применяем уже к дедуплицированному набору. -# Ключ/направления дедупа — зеркало _STOCK_SQL / objective_lots_physflat_latest_idx. -# contract_date / rooms_int / area_pd берём из последнего снапшота физлота. +# Ключ/направления дедупа — зеркало _STOCK_SQL (оба поддержаны индексом +# objective_lots_physflat_latest_idx из миграции 173). 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}. +# (Source-B room_area-вокабуляр "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/ +# "80+ м²" — ТОЧНОЕ зеркало sales_series.room_area_bucket_of, как в _STOCK_SQL) — +# применяются ПОСЛЕ дедупа (объективны по физлоту: класс/комнатность/площадь не +# меняются между снапшотами). ROLLUP остаётся: при room_bucket-фильтре отдаёт +# {bucket, total}. _SALES_WINDOW_SQL = text( """ WITH latest AS ( @@ -452,13 +465,15 @@ def compute_market_metrics( (имеет смысл для ЕКБ-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) считаются на дедуплицированном наборе. + матчим регистронезависимо) и `room_bucket` (Source-B room_area-вокабуляр — + "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²", зеркало + `sales_series.room_area_bucket_of` → what_to_build фильтрует БЕЗ перевода) + сужают выборку до одного сегмента. Так base_pace (unit_velocity) и доступный + сток становятся ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки + what_to_build делят один темп → дефицит вырождается в −1.0). None по обеим осям + → прежнее district-wide поведение. ВАЖНО: SQL дедуплицирует objective_lots до + последнего снапшота на физлот → counts (а значит и confidence) считаются на + дедуплицированном наборе. Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики = None, confidence='low'. Никогда не бросает на отсутствии данных.