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;