audit-fix: 27mo окно, MODE bucket, индексы recommend_mix

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, остальное игнорилось.
This commit is contained in:
lekss361 2026-04-30 22:41:43 +03:00
parent 78b8d7aa00
commit e22f86bc06
7 changed files with 116 additions and 30 deletions

View file

@ -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

View file

@ -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"),

View file

@ -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.';

View file

@ -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,
};

View file

@ -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({
))}
</select>
<span style={hintStyle}>
При &lt;30 сделок в любом бакете окно автоматически расширяется до 24
мес.
При &lt;30 сделок в любом бакете окно автоматически расширяется до 27
мес (max доступный rosreestr-архив).
</span>
</label>

View file

@ -388,7 +388,7 @@ export function RecommendVelocityPanel({
>
Эластичность ценатемп <strong>{elasticity}</strong> (
{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 &&

View file

@ -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;