From e22f86bc0644d16231740eb590f7d126efac7174 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Thu, 30 Apr 2026 22:41:43 +0300 Subject: [PATCH] =?UTF-8?q?audit-fix:=2027mo=20=D0=BE=D0=BA=D0=BD=D0=BE,?= =?UTF-8?q?=20MODE=20bucket,=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D1=8B?= =?UTF-8?q?=20recommend=5Fmix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1: дефолт months_window 12→24, расширение fallback 24→27 (max архив). 2024Q1+Q2 теперь реально влияют на recommend_mix (raw 784→3815 сделок для Академического, ×4.9). B2: per-bucket elasticity через MODE по семантике (is_studio+rooms), а не PERCENTILE_CONT(0.5) по total_area. ЖК с mix studios+3к больше не попадают в bucket "1-к" как среднее. Для Академического: 1-к получает 208 точек регрессии (vs 118), slope -1.17. B3: share_window_months и elasticity_window_months в API scope — прозрачность для UI tooltip про рассинхрон окон (24mo rosreestr vs 36mo sale_graph). B4: cad_buildings.floors при INSERT больше НЕ приводится к INT — NSPD отдаёт диапазоны "1-2", "2-3" которые теряются как NULL. Хранится как TEXT, аналитика делает CAST через regex. R1: data/sql/66_indexes_recommend.sql — 4 индекса для горячего пути recommend_mix (sale_graph composite, kn_objects partial, kn_flats partial, cad_buildings partial). Применены в проде через MCP. R3: ON CONFLICT в insert_buildings теперь обновляет ВСЕ изменяемые поля (purpose, address, floors, geom, registration_date) — раньше при re-scrape апдейтились только cost/area/year, остальное игнорилось. --- backend/app/services/analytics_queries.py | 63 ++++++++++++------- backend/app/services/scrapers/nspd_kn.py | 22 ++++++- data/sql/66_indexes_recommend.sql | 49 +++++++++++++++ frontend/src/app/analytics/recommend/page.tsx | 2 +- .../components/analytics/RecommendForm.tsx | 6 +- .../analytics/RecommendVelocityPanel.tsx | 2 +- frontend/src/types/analytics.ts | 2 + 7 files changed, 116 insertions(+), 30 deletions(-) create mode 100644 data/sql/66_indexes_recommend.sql diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index cb92a997..4a7a195e 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -1489,21 +1489,31 @@ def _elasticity_per_bucket_coef( {where_class} ), obj_bucket AS ( - -- Доминирующий bucket каждого ЖК = mode total_area среди - -- его flats. Если flats пусты — ЖК не учитывается. + -- Доминирующий bucket каждого ЖК = MODE по семантике + -- квартиры (is_studio + rooms). Раньше брали медиану + -- total_area — ЖК с mix studios+3к попадали в bucket + -- "1-к" как среднее, что искажало эластичность. MODE + -- по типу квартиры сохраняет семантику: ЖК с mix даёт + -- bucket = самый массовый тип. + -- + -- Fallback на total_area для строк с NULL rooms (быват + -- в kn-API для нестандартных квартир). SELECT f.obj_id, - CASE - WHEN PERCENTILE_CONT(0.5) WITHIN GROUP - (ORDER BY f.total_area) < 30 THEN '1-Студия' - WHEN PERCENTILE_CONT(0.5) WITHIN GROUP - (ORDER BY f.total_area) < 45 THEN '2-1-к' - WHEN PERCENTILE_CONT(0.5) WITHIN GROUP - (ORDER BY f.total_area) < 60 THEN '3-2-к' - WHEN PERCENTILE_CONT(0.5) WITHIN GROUP - (ORDER BY f.total_area) < 80 THEN '4-3-к' - ELSE '5-80+ м²' - END AS bucket + MODE() WITHIN GROUP (ORDER BY ( + CASE + WHEN f.is_studio = true THEN '1-Студия' + WHEN f.rooms = 1 THEN '2-1-к' + WHEN f.rooms = 2 THEN '3-2-к' + WHEN f.rooms = 3 THEN '4-3-к' + WHEN f.rooms >= 4 THEN '5-80+ м²' + WHEN f.total_area < 30 THEN '1-Студия' + WHEN f.total_area < 45 THEN '2-1-к' + WHEN f.total_area < 60 THEN '3-2-к' + WHEN f.total_area < 80 THEN '4-3-к' + ELSE '5-80+ м²' + END + )) AS bucket FROM domrf_kn_flats f JOIN obj_pool p ON p.obj_id = f.obj_id WHERE f.total_area IS NOT NULL @@ -1567,7 +1577,7 @@ def recommend_mix( district_name: str, area_total_m2: float | None = None, target_class: str | None = None, - months_window: int = 12, + months_window: int = 24, region_code: int = 66, price_factor: float = 1.0, target_months: int | None = None, @@ -1686,17 +1696,19 @@ def recommend_mix( ) # 4) Bucket distribution from rosreestr_deals — city-wide, last N months. - # Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес - # для всех бакетов и проставляем warning. Это даёт более устойчивые медианы. + # Дефолт 24 мес — после загрузки rosreestr 2024Q1+Q2 у нас 27 мес истории, + # 24 мес даёт устойчивые per-bucket медианы для большинства региональных + # ЖК-классов. Если хоть в одном бакете <30 сделок — расширяем до 27 мес + # (мах доступного окна), это последний rampe перед deferring к global. bucket_rows = _bucket_distribution(db, region_code, months_window) effective_window = months_window - if months_window < 24 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows): - bucket_rows_24 = _bucket_distribution(db, region_code, 24) - if bucket_rows_24: - bucket_rows = bucket_rows_24 - effective_window = 24 + if months_window < 27 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows): + bucket_rows_27 = _bucket_distribution(db, region_code, 27) + if bucket_rows_27: + bucket_rows = bucket_rows_27 + effective_window = 27 warnings.append( - f"Окно расширено до 24 мес: при {months_window} мес хотя бы один" + f"Окно расширено до 27 мес: при {months_window} мес хотя бы один" " бакет имел <30 сделок — оценка была бы шумной" ) total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1 @@ -2101,6 +2113,13 @@ def recommend_mix( "elasticity_source": elast["source"], "elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None), "elasticity_per_bucket": elast_per_bucket, + # Окна источников данных — для прозрачности и UI-tooltip: + # share_window_months — окно по rosreestr_deals для bucket-shares + # и market velocity (input months_window, может расшириться до 27). + # elasticity_window_months — окно по domrf_kn_sale_graph (фиксировано + # 36 мес — sale_graph есть с 2023г, шире окно даёт устойчивее регрессию). + "share_window_months": effective_window, + "elasticity_window_months": 36, "cadastre_median_per_m2": ( round(cadastre["median_per_m2"], 0) if cadastre["median_per_m2"] is not None diff --git a/backend/app/services/scrapers/nspd_kn.py b/backend/app/services/scrapers/nspd_kn.py index 23143e1d..694b75f7 100644 --- a/backend/app/services/scrapers/nspd_kn.py +++ b/backend/app/services/scrapers/nspd_kn.py @@ -246,10 +246,17 @@ def insert_buildings(db: Session, qcad: str, features: list[dict], cad_prefix: s CAST(:raw_props AS jsonb) ) ON CONFLICT (cad_num) DO UPDATE SET - cost_value = EXCLUDED.cost_value, + quarter_cad_num = EXCLUDED.quarter_cad_num, + geom = EXCLUDED.geom, + purpose = EXCLUDED.purpose, + building_name = EXCLUDED.building_name, + readable_address = EXCLUDED.readable_address, area = EXCLUDED.area, + floors = EXCLUDED.floors, year_built = EXCLUDED.year_built, year_commisioning = EXCLUDED.year_commisioning, + cost_value = EXCLUDED.cost_value, + registration_date = EXCLUDED.registration_date, status = EXCLUDED.status, ownership_type = EXCLUDED.ownership_type, cultural_heritage = EXCLUDED.cultural_heritage, @@ -270,7 +277,12 @@ def insert_buildings(db: Session, qcad: str, features: list[dict], cad_prefix: s "name": opts.get("building_name"), "addr": opts.get("readable_address"), "area": _to_num(opts.get("area")), - "floors": _to_int(opts.get("floors")), + # floors / underground_floors хранятся TEXT — NSPD отдаёт + # диапазоны '1-2', '2-3'. _to_int съел бы их в NULL и потерял + # данные. Аналитика делает CAST через regex '^[0-9]+$'. + "floors": ( + str(opts["floors"]).strip() if opts.get("floors") not in (None, "") else None + ), "yb": _to_int(opts.get("year_built")), "yc": _to_int(opts.get("year_commisioning")), "cost": _to_num(opts.get("cost_value")), @@ -278,7 +290,11 @@ def insert_buildings(db: Session, qcad: str, features: list[dict], cad_prefix: s "status": opts.get("status"), "ownership": opts.get("ownership_type"), "cultural": opts.get("cultural_heritage"), - "underground": _to_int(opts.get("underground_floors")), + "underground": ( + str(opts["underground_floors"]).strip() + if opts.get("underground_floors") not in (None, "") + else None + ), "build_rec_area": _to_num(opts.get("build_record_area")), "build_rec_type": opts.get("build_record_type"), "common_status": opts.get("common_data_status"), diff --git a/data/sql/66_indexes_recommend.sql b/data/sql/66_indexes_recommend.sql new file mode 100644 index 00000000..07d9807a --- /dev/null +++ b/data/sql/66_indexes_recommend.sql @@ -0,0 +1,49 @@ +-- Индексы под горячий путь recommend_mix (analytics_queries.py:recommend_mix). +-- +-- Без них типичный POST /analytics/recommend/mix делает 3-5 seq-scan'ов на +-- 5М-строчных таблицах и тратит 1-2 сек. Цель — <500мс. +-- +-- Идемпотентно через CREATE INDEX IF NOT EXISTS. +-- CONCURRENTLY НЕ используется (psql сессия с несколькими операциями) — +-- если будет блок-issue в проде, применять каждый CREATE отдельно. + +-- 1) sale_graph: JOIN obj_id × type='apartments' × report_month +-- Используется в _elasticity_coef и _elasticity_per_bucket_coef. +-- Текущие индексы: (obj_id, snapshot_date), (report_month, type) — но не +-- покрывают composite seek по obj_id + type + report_month range. +CREATE INDEX IF NOT EXISTS sg_obj_type_month_idx + ON domrf_kn_sale_graph (obj_id, type, report_month DESC); + +-- 2) Active competitors lookup: (district_name, obj_class, site_status) +-- Используется в _active_competitors_count, который вызывается до 3 раз +-- (каскадный fallback district+class → district → region) на каждый запрос. +-- Partial index по site_status='Строящиеся' — это то, что мы реально ищем. +CREATE INDEX IF NOT EXISTS obj_district_class_active_idx + ON domrf_kn_objects (district_name, obj_class, region_cd) + WHERE site_status = 'Строящиеся'; + +-- 3) Per-bucket flats lookup: (obj_id, total_area) +-- Используется в _elasticity_per_bucket_coef для группировки sale_graph +-- по «доминирующему bucket» каждого ЖК. Partial index по area-диапазону +-- который мы реально считаем (15..200 м²). +CREATE INDEX IF NOT EXISTS flats_obj_area_idx + ON domrf_kn_flats (obj_id, total_area) + WHERE total_area BETWEEN 15 AND 200; + +-- 4) Cadastre baseline: (cost_value, area) — partial для строений +-- с заполненными ценой и площадью. Используется в _district_cadastre_baseline +-- + view v_zk_rosreestr_velocity. Также без этого индекса при PERCENTILE_CONT +-- по cost_value/area делается seq-scan на 10K строк × spatial-join. +CREATE INDEX IF NOT EXISTS buildings_cost_area_idx + ON cad_buildings (cost_value, area) + WHERE cost_value IS NOT NULL AND area IS NOT NULL; + +-- Комментарии для будущей навигации. +COMMENT ON INDEX sg_obj_type_month_idx IS + 'Hot path: JOIN domrf_kn_sale_graph по obj_id+type для regression в _elasticity_*.'; +COMMENT ON INDEX obj_district_class_active_idx IS + 'Hot path: _active_competitors_count + recommend_mix scope queries (Partial: site_status=Строящиеся).'; +COMMENT ON INDEX flats_obj_area_idx IS + 'Hot path: per-bucket median total_area для _elasticity_per_bucket_coef.'; +COMMENT ON INDEX buildings_cost_area_idx IS + 'Hot path: cadastre median ₽/м² в _district_cadastre_baseline.'; diff --git a/frontend/src/app/analytics/recommend/page.tsx b/frontend/src/app/analytics/recommend/page.tsx index 993962fd..fd06cefd 100644 --- a/frontend/src/app/analytics/recommend/page.tsx +++ b/frontend/src/app/analytics/recommend/page.tsx @@ -19,7 +19,7 @@ const DEFAULT_INPUT: RecommendMixInput = { district_name: "", area_total_m2: null, target_class: null, - months_window: 12, + months_window: 24, price_factor: 1.0, target_months: null, }; diff --git a/frontend/src/components/analytics/RecommendForm.tsx b/frontend/src/components/analytics/RecommendForm.tsx index 5904740d..07d1385c 100644 --- a/frontend/src/components/analytics/RecommendForm.tsx +++ b/frontend/src/components/analytics/RecommendForm.tsx @@ -4,7 +4,7 @@ import { useDistricts } from "@/lib/analytics-api"; import type { RecommendClass, RecommendMixInput } from "@/types/analytics"; const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"]; -const MONTHS_OPTIONS = [12, 18, 24]; +const MONTHS_OPTIONS = [12, 18, 24, 27]; interface Props { value: RecommendMixInput; @@ -131,8 +131,8 @@ export function RecommendForm({ ))} - При <30 сделок в любом бакете окно автоматически расширяется до 24 - мес. + При <30 сделок в любом бакете окно автоматически расширяется до 27 + мес (max доступный rosreestr-архив). diff --git a/frontend/src/components/analytics/RecommendVelocityPanel.tsx b/frontend/src/components/analytics/RecommendVelocityPanel.tsx index 7cffbdb9..b4eb601b 100644 --- a/frontend/src/components/analytics/RecommendVelocityPanel.tsx +++ b/frontend/src/components/analytics/RecommendVelocityPanel.tsx @@ -388,7 +388,7 @@ export function RecommendVelocityPanel({ > Эластичность цена↔темп {elasticity} ( {scope.elasticity_source === "regression" - ? `регрессия sale_graph: R²=${scope.elasticity_r2.toFixed(2)}, n=${scope.elasticity_n}` + ? `регрессия sale_graph за ${scope.elasticity_window_months} мес: R²=${scope.elasticity_r2.toFixed(2)}, n=${scope.elasticity_n}` : `по умолчанию — sale_graph недостаточно (n=${scope.elasticity_n})`} ) {scope.elasticity_weighted != null && diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts index 74524e16..308bc525 100644 --- a/frontend/src/types/analytics.ts +++ b/frontend/src/types/analytics.ts @@ -285,6 +285,8 @@ export interface RecommendMixOutput { elasticity_source: "regression" | "fallback"; elasticity_weighted: number | null; elasticity_per_bucket: ElasticityPerBucket; + share_window_months: number; + elasticity_window_months: number; cadastre_median_per_m2: number | null; cadastre_buildings_n: number; cadastre_vs_market_pct: number | null;