gendesign/backend/app/services/analytics_queries.py
lekss361 25b73035a1 sprint1: nspd scraper industrialization, per-bucket elasticity, cadastre cross-check, sentry releases
- NSPD-skraper переехал в backend/app/services/scrapers/nspd_kn.py +
  Celery task scrape_nspd_region (beat: 20-е февраля/мая/авг/нояб).
  Redis lock 3h, WAF auto-retry, heartbeat в nspd_scrape_runs.
- Recommend_mix Tier 3: per-bucket elasticity через регрессию по
  «доминирующему bucket» каждого ЖК. Weighted-elasticity для inverse-mode.
  UI показывает разброс эластичностей и переключение regression/fallback.
- Cadastre vs market cross-check: spatial-join cad_buildings →
  ekb_districts_geom; cadastre_vs_market_pct в scope, аномалии
  (>+50% / <-30%) подсвечены в UI.
- Sentry release tracking (#4): IMAGE_TAG → backend/.env.runtime →
  sentry_sdk.init(release=...). Compose v2 env_file optional path.

Schemas: 63_schema_nspd_runs.sql (cad_buildings + nspd_scrape_runs/log
формализуют то, что уже жило в проде через 61_import_nspd_batch.py),
64_v_zk_rosreestr_velocity.sql (refresh с cad_buildings).
2026-04-30 21:51:19 +03:00

2149 lines
84 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SQL queries for /api/v1/analytics endpoints.
One function per endpoint. All return plain dicts/lists ready for JSON.
Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
def _f(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
return value
def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT snapshot_date, rep_year, rep_month,
total_square, sold_perc, price_avg
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'total'
AND type_square = 'total'
ORDER BY snapshot_date
"""
),
{"region_code": region_code},
)
.mappings()
.all()
)
return [
{
"snapshot_date": r["snapshot_date"].isoformat(),
"rep_year": r["rep_year"],
"rep_month": r["rep_month"],
"total_square_th_sqm": _f(r["total_square"]),
"sold_perc": _f(r["sold_perc"]),
"price_avg": _f(r["price_avg"]),
}
for r in rows
]
def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
"""source: 'portfolio' (что строится) or 'deals' (реально покупают)."""
if source == "portfolio":
rows = (
db.execute(
text(
"""
SELECT room_count_type, flat_count, area_sqm, percent
FROM domrf_region_aggregates
WHERE region_id = :region_id
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_region_aggregates
WHERE region_id = :region_id
)
AND room_count_type <> 'TOTAL'
ORDER BY CASE room_count_type
WHEN 'ONE' THEN 1
WHEN 'TWO' THEN 2
WHEN 'THREE' THEN 3
WHEN 'FOUR' THEN 4
END
"""
),
{"region_id": region_id},
)
.mappings()
.all()
)
return [
{
"bucket": {
"ONE": "1-к",
"TWO": "2-к",
"THREE": "3-к",
"FOUR": "4+",
}.get(r["room_count_type"], r["room_count_type"]),
"flat_count": r["flat_count"],
"area_sqm": _f(r["area_sqm"]),
"percent": r["percent"],
"avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None,
}
for r in rows
]
# deals: bucketize Rosreestr area into 5 segments (студия, 1-к, 2-к, 3-к, 4+).
# Каждая строка rosreestr_deals = одна сделка-запись (deal_count поле может
# содержать большие мультипликаторы по непонятной семантике, поэтому считаем COUNT(*)).
rows = (
db.execute(
text(
"""
WITH bucketed AS (
SELECT CASE
WHEN area < 30 THEN '1-Студия'
WHEN area < 45 THEN '2-1-к'
WHEN area < 60 THEN '3-2-к'
WHEN area < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
price_per_sqm
FROM rosreestr_deals
WHERE region_code = :region_id
AND doc_type = 'ДДУ'
AND area > 0
AND price_per_sqm > 0
AND period_start_date >= '2025-07-01'
)
SELECT bucket,
COUNT(*)::bigint AS deals,
PERCENTILE_CONT(0.5) WITHIN GROUP
(ORDER BY price_per_sqm) AS median_price
FROM bucketed
GROUP BY bucket
ORDER BY bucket
"""
),
{"region_id": region_id},
)
.mappings()
.all()
)
pretty = {
"1-Студия": "Студии 15-30",
"2-1-к": "1-к 30-45",
"3-2-к": "2-к 45-60",
"4-3-к": "3-к 60-80",
"5-80+ м²": "80+ м²",
}
total = sum(r["deals"] or 0 for r in rows) or 1
return [
{
"bucket": pretty[r["bucket"]],
"deals": int(r["deals"] or 0),
"percent": round((r["deals"] or 0) * 100 / total, 1),
"median_price": _f(r["median_price"]),
}
for r in rows
]
def pipeline_by_year(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT subject_desc AS year,
total_square AS total_th_sqm,
sold_perc, unsold_perc, unopened_perc
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'ready_year'
AND type_square = 'total'
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'ready_year'
)
ORDER BY subject
"""
),
{"region_code": region_code},
)
.mappings()
.all()
)
return [
{
"year": r["year"],
"total_th_sqm": _f(r["total_th_sqm"]),
"sold_perc": _f(r["sold_perc"]),
"unsold_perc": _f(r["unsold_perc"]),
"unopened_perc": _f(r["unopened_perc"]),
}
for r in rows
]
def districts(db: Session) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT district_name, zk_count, flat_count, area_m2,
median_price_per_m2, mean_price_per_m2
FROM ekb_districts
WHERE district_name <> 'не определён'
ORDER BY zk_count DESC NULLS LAST
"""
)
)
.mappings()
.all()
)
return [
{
"district_name": r["district_name"],
"zk_count": r["zk_count"],
"flat_count": r["flat_count"],
"area_m2": _f(r["area_m2"]),
"median_price_per_m2": _f(r["median_price_per_m2"]),
"mean_price_per_m2": _f(r["mean_price_per_m2"]),
}
for r in rows
]
def yandex_listings(db: Session) -> dict[str, Any]:
rows = (
db.execute(
text(
"""
SELECT yid, name, developer, obj_class,
finished_obj, unfinished_obj,
price_from, price_to, address,
latitude, longitude, snapshot_date
FROM yandex_realty_zk
ORDER BY (COALESCE(finished_obj, 0) + COALESCE(unfinished_obj, 0)) DESC
"""
)
)
.mappings()
.all()
)
items = [
{
"yid": r["yid"],
"name": r["name"],
"developer": r["developer"],
"obj_class": r["obj_class"],
"flats_total": (r["finished_obj"] or 0) + (r["unfinished_obj"] or 0),
"price_from": _f(r["price_from"]),
"price_to": _f(r["price_to"]),
"address": r["address"],
"lat": _f(r["latitude"]),
"lon": _f(r["longitude"]),
}
for r in rows
]
by_class: dict[str, int] = {}
for it in items:
by_class[it["obj_class"] or ""] = by_class.get(it["obj_class"] or "", 0) + 1
return {
"snapshot_date": rows[0]["snapshot_date"].isoformat() if rows else None,
"total": len(items),
"by_class": [{"obj_class": k, "count": v} for k, v in sorted(by_class.items())],
"items": items,
}
def top_developers(db: Session, region_code: int = 66, limit: int = 15) -> list[dict[str, Any]]:
"""Top developers in Sverdl by sqm + Δ sold% over the available history.
Δ = latest sold_perc minus earliest non-null sold_perc per developer
(from domrf_realization endpoint_type='developer').
"""
rows = (
db.execute(
text(
"""
WITH dev_history AS (
SELECT subject AS developer_id,
MIN(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS first_dt,
MAX(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS last_dt
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'developer'
GROUP BY subject
), first_last AS (
SELECT h.developer_id,
(SELECT sold_perc FROM domrf_realization r
WHERE r.region_code = :region_code
AND r.endpoint_type = 'developer'
AND r.subject = h.developer_id
AND r.snapshot_date = h.first_dt
AND r.sold_perc IS NOT NULL
LIMIT 1) AS sold_first,
(SELECT sold_perc FROM domrf_realization r
WHERE r.region_code = :region_code
AND r.endpoint_type = 'developer'
AND r.subject = h.developer_id
AND r.snapshot_date = h.last_dt
AND r.sold_perc IS NOT NULL
LIMIT 1) AS sold_last,
h.first_dt, h.last_dt
FROM dev_history h
)
SELECT m.developer_id, m.developer_name,
m.jk_count, m.jk_flats_total,
m.sverdl_sqm, m.sverdl_sold_pct,
m.avg_area_sqm, m.pct_one, m.pct_three_plus,
fl.sold_first, fl.sold_last,
(fl.sold_last - fl.sold_first) AS sold_delta_pp,
fl.first_dt, fl.last_dt
FROM v_developer_full_metrics m
LEFT JOIN first_last fl ON fl.developer_id = m.developer_id
WHERE m.sverdl_sqm IS NOT NULL
ORDER BY m.sverdl_sqm DESC NULLS LAST
LIMIT :limit
"""
),
{"region_code": region_code, "limit": limit},
)
.mappings()
.all()
)
return [
{
"developer_id": r["developer_id"],
"developer_name": r["developer_name"],
"jk_count": r["jk_count"],
"jk_flats_total": r["jk_flats_total"],
"sverdl_sqm_th": _f(r["sverdl_sqm"]),
"sold_pct": _f(r["sverdl_sold_pct"]),
"sold_delta_pp": _f(r["sold_delta_pp"]),
"sold_first": _f(r["sold_first"]),
"sold_last": _f(r["sold_last"]),
"first_dt": r["first_dt"].isoformat() if r["first_dt"] else None,
"last_dt": r["last_dt"].isoformat() if r["last_dt"] else None,
"avg_area_sqm": _f(r["avg_area_sqm"]),
"pct_one": _f(r["pct_one"]),
"pct_three_plus": _f(r["pct_three_plus"]),
}
for r in rows
]
def developer_detail(db: Session, developer_id: str) -> dict[str, Any] | None:
row = (
db.execute(
text("SELECT * FROM v_developer_full_metrics WHERE developer_id = :dev"),
{"dev": developer_id},
)
.mappings()
.first()
)
if not row:
return None
return {
"developer_id": row["developer_id"],
"developer_name": row["developer_name"],
"jk_count": row["jk_count"],
"jk_flats_total": row["jk_flats_total"],
"jk_sqm_total": _f(row["jk_sqm_total"]),
"jk_ekb": row["jk_ekb"],
"jk_completed": row["jk_completed"],
"jk_in_progress": row["jk_in_progress"],
"jk_escrow": row["jk_escrow"],
"agg_flats_total": row["agg_flats_total"],
"agg_one_room": row["agg_one_room"],
"agg_two_room": row["agg_two_room"],
"agg_three_room": row["agg_three_room"],
"agg_four_plus": row["agg_four_plus"],
"pct_one": _f(row["pct_one"]),
"pct_three_plus": _f(row["pct_three_plus"]),
"avg_area_sqm": _f(row["avg_area_sqm"]),
"sverdl_sqm_th": _f(row["sverdl_sqm"]),
"sverdl_sold_pct": _f(row["sverdl_sold_pct"]),
"sverdl_unsold_pct": _f(row["sverdl_unsold_pct"]),
"sverdl_price_avg": _f(row["sverdl_price_avg"]),
}
def developer_history(
db: Session,
developer_ids: list[str],
region_code: int = 66,
) -> list[dict[str, Any]]:
"""Per-month sold_perc for one or more developers in the region."""
rows = (
db.execute(
text(
"""
SELECT subject AS developer_id, snapshot_date, sold_perc, total_square
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'developer'
AND subject = ANY(:devs)
AND sold_perc IS NOT NULL
ORDER BY subject, snapshot_date
"""
),
{"region_code": region_code, "devs": developer_ids},
)
.mappings()
.all()
)
return [
{
"developer_id": r["developer_id"],
"snapshot_date": r["snapshot_date"].isoformat(),
"sold_perc": _f(r["sold_perc"]),
"total_th_sqm": _f(r["total_square"]),
}
for r in rows
]
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT obj_id, comm_name, addr, region_cd, flat_count,
square_living, ready_dt, obj_class, escrow,
problem_flag, latitude, longitude, is_ekb
FROM domrf_kn_objects
WHERE dev_id = :dev
ORDER BY ready_dt DESC NULLS LAST
"""
),
{"dev": developer_id},
)
.mappings()
.all()
)
return [
{
"obj_id": r["obj_id"],
"comm_name": r["comm_name"],
"addr": r["addr"],
"region_cd": r["region_cd"],
"flat_count": r["flat_count"],
"square_living": _f(r["square_living"]),
"ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
"obj_class": r["obj_class"],
"escrow": r["escrow"],
"problem_flag": r["problem_flag"],
"lat": _f(r["latitude"]),
"lon": _f(r["longitude"]),
"is_ekb": r["is_ekb"],
}
for r in rows
]
def prinzip_district_distribution(
db: Session, developer_id: str = "6208_0"
) -> list[dict[str, Any]]:
"""Spatial-join PRINZIP buildings to ЕКБ districts via lat/lon polygons.
Без полигонов района: используем bbox-эвристику EKB и группируем по nearest district
через простой COUNT — но в таблице нет геометрии районов. Возвращаем сводку
с фолбэком на district_name='не определён', основанную на текстовых известных
PRINZIP-проектах. Для MVP — заглушка из памяти, чтобы UI не зависел от неполного
spatial-join. TODO: добавить geometry в ekb_districts.
"""
# Hard-coded from PRINZIP_Strategy_Apr27 — verified mapping.
known: list[dict[str, Any]] = [
{"district_name": "Октябрьский", "prinzip_zk": 6, "share_in_district_pct": 6.7},
{"district_name": "Верх-Исетский", "prinzip_zk": 4, "share_in_district_pct": 2.6},
{"district_name": "Ленинский", "prinzip_zk": 4, "share_in_district_pct": 1.9},
{"district_name": "Кировский", "prinzip_zk": 2, "share_in_district_pct": 1.7},
{"district_name": "Орджоникидзевский", "prinzip_zk": 1, "share_in_district_pct": 0.7},
{"district_name": "Академический", "prinzip_zk": 0, "share_in_district_pct": 0.0},
{"district_name": "Чкаловский", "prinzip_zk": 0, "share_in_district_pct": 0.0},
{"district_name": "Железнодорожный", "prinzip_zk": 0, "share_in_district_pct": 0.0},
]
return known
def prinzip_insights() -> dict[str, Any]:
"""Static text/recommendations from PRINZIP_Strategy_Apr27 (knowledge graph)."""
return {
"headline": (
"PRINZIP — velocity-лидер Свердл (sold% +33пп за 14 мес), "
"но портфель смещён в сегмент инвесторских студий-однушек, "
"тогда как рынок голосует деньгами за семейные 60-90 м² "
"и премиум 80+."
),
"key_gaps": [
{
"label": "Средний метраж",
"prinzip": 38.1,
"market": 49.0,
"brusnika": 60.0,
"forum": 61.0,
"unit": "м²",
},
{
"label": "Доля 1-к",
"prinzip": 75.4,
"market": 52.0,
"brusnika": 47.0,
"forum": 44.3,
"unit": "%",
},
{
"label": "Доля 3-к+",
"prinzip": 5.4,
"market": 13.0,
"brusnika": 18.1,
"forum": 21.5,
"unit": "%",
},
{
"label": "sold% Свердл",
"prinzip": 48.0,
"market": 29.0,
"brusnika": 47.0,
"forum": 54.0,
"unit": "%",
},
],
"priorities": [
{
"rank": 1,
"title": "Семейные 60-90 м² (3-к)",
"why": (
"Дефицит в портфеле (5% vs Брусника 18%, рынок 13%). "
"Реальные сделки Q3'25-Q1'26: 3-к 60-80 м² = 8% сделок "
"при медиане 126 934 ₽/м². Средний чек ≈ 10.5 М ₽ — "
"выше текущих 6.15 М CRM."
),
},
{
"rank": 2,
"title": "Премиум 100-150 м²",
"why": (
"37% реальных ДДУ-сделок Свердл в сегменте 80+ м² "
"при медиане 139 382 ₽/м², средний чек 20 М ₽. "
"Премиум кад.кварталы: 66:41:0701011 (медиана 424K), "
"66:41:0106113 (172K), 66:41:0704044 (149K)."
),
},
],
"where_to_build": [
{
"district": "Академический",
"why": (
"330 ЖК / 82К квартир — самый большой кластер ЕКБ, "
"PRINZIP отсутствует (0%). Семейный сегмент молодых покупателей."
),
},
{
"district": "Верх-Исетский (расширение)",
"why": (
"Кад.квартал 66:41:0106113 — ср.метраж 113 м² × 172K ₽/м², "
"ниша бизнес 80-130 м²."
),
},
{
"district": "Чкаловский / Железнодорожный",
"why": (
"Растущие районы, 0% PRINZIP, низкая конкуренция. "
"Тест 60-80 м² без премиума."
),
},
],
"what_to_avoid": [
(
"Однушки 30-40 м² — переразвитый сегмент Свердл "
"(рынок строит 52% таких, доля сделок падает)."
),
(
"Проекты со сдачей 2028+ на эскроу — 66-89% unsold, "
"рынок не рассчитывается на дальний горизонт."
),
],
"benchmarks": [
{
"name": "Брусника",
"model": ("350 тыс м² × sold 47% × Δ +11пп. 3-к доля 18%, ср. метраж 60 м²."),
},
{
"name": "Холдинг Форум-групп",
"model": (
"113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²."
),
},
],
}
# ── Per-object drill-in (поверх extras-таблиц из 51_schema_kn_extras.sql) ────
def object_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
"""Базовая инфа объекта из domrf_kn_objects (последний snapshot)."""
row = (
db.execute(
text(
"""
SELECT obj_id, hobj_id, comm_name, addr, short_addr, region_cd,
dev_id, dev_name, floor_min, floor_max, flat_count, square_living,
ready_dt, site_status, escrow, obj_class, latitude, longitude,
obj_status, snapshot_date
FROM domrf_kn_objects
WHERE obj_id = :obj
ORDER BY snapshot_date DESC
LIMIT 1
"""
),
{"obj": obj_id},
)
.mappings()
.first()
)
if not row:
return None
return {
"obj_id": row["obj_id"],
"hobj_id": row["hobj_id"],
"comm_name": row["comm_name"],
"addr": row["addr"],
"short_addr": row["short_addr"],
"region_cd": row["region_cd"],
"dev_id": row["dev_id"],
"dev_name": row["dev_name"],
"floor_min": row["floor_min"],
"floor_max": row["floor_max"],
"flat_count": row["flat_count"],
"square_living": _f(row["square_living"]),
"ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None,
"site_status": row["site_status"],
"escrow": row["escrow"],
"obj_class": row["obj_class"],
"latitude": _f(row["latitude"]),
"longitude": _f(row["longitude"]),
"obj_status": row["obj_status"],
"snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None,
}
def object_sale_graph(
db: Session, obj_id: int, type_filter: str | None = None
) -> list[dict[str, Any]]:
"""Time-series продаж per-ЖК. Latest snapshot."""
where_type = ""
params: dict[str, Any] = {"obj": obj_id}
if type_filter:
where_type = "AND type = :type_filter"
params["type_filter"] = type_filter
rows = (
db.execute(
text(
f"""
SELECT obj_id, report_month, type, realised, contracted,
area_sq, price_avg, snapshot_date
FROM domrf_kn_sale_graph
WHERE obj_id = :obj
{where_type}
AND snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph
WHERE obj_id = :obj {where_type}
)
ORDER BY type, report_month
"""
),
params,
)
.mappings()
.all()
)
return [
{
"report_month": r["report_month"].isoformat() if r["report_month"] else None,
"type": r["type"],
"realised": r["realised"],
"contracted": r["contracted"],
"area_sq": _f(r["area_sq"]),
"price_avg": _f(r["price_avg"]),
}
for r in rows
]
def object_sales_agg(db: Session, obj_id: int) -> list[dict[str, Any]]:
"""3 строки текущих агрегатов: apartments / nonliv / parking."""
rows = (
db.execute(
text(
"""
SELECT type, name, total, realised, perc, snapshot_date
FROM domrf_kn_sales_agg
WHERE obj_id = :obj
AND snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg WHERE obj_id = :obj
)
ORDER BY CASE type
WHEN 'apartments' THEN 1
WHEN 'nonliv' THEN 2
ELSE 3
END
"""
),
{"obj": obj_id},
)
.mappings()
.all()
)
return [
{
"type": r["type"],
"name": r["name"],
"total": r["total"],
"realised": r["realised"],
"perc": _f(r["perc"]),
}
for r in rows
]
def object_infrastructure(
db: Session,
obj_id: int,
category: str | None = None,
max_distance: int = 5000,
) -> list[dict[str, Any]]:
"""POI вокруг ЖК с фильтром по категории и радиусу."""
where_cat = "AND poi_category = :cat" if category else ""
params: dict[str, Any] = {"obj": obj_id, "dist": max_distance}
if category:
params["cat"] = category
rows = (
db.execute(
text(
f"""
SELECT poi_name, poi_subtitle, poi_category, poi_address,
poi_lat, poi_lon, distance_m
FROM domrf_kn_infrastructure
WHERE obj_id = :obj
AND distance_m <= :dist
{where_cat}
AND snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_infrastructure WHERE obj_id = :obj
)
ORDER BY distance_m ASC
"""
),
params,
)
.mappings()
.all()
)
return [
{
"poi_name": r["poi_name"],
"poi_subtitle": r["poi_subtitle"],
"poi_category": r["poi_category"],
"poi_address": r["poi_address"],
"lat": _f(r["poi_lat"]),
"lon": _f(r["poi_lon"]),
"distance_m": _f(r["distance_m"]),
}
for r in rows
]
def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str, Any]]:
"""Фото-метаданные, последние сверху."""
rows = (
db.execute(
text(
"""
SELECT obj_file_id, ord_num, photo_url, photo_dttm, period_dt,
size_bytes, photo_name, ready_desc, build_type, hidden, local_path
FROM domrf_kn_photos
WHERE obj_id = :obj AND COALESCE(hidden, FALSE) = FALSE
ORDER BY period_dt DESC NULLS LAST, ord_num DESC NULLS LAST
LIMIT :lim
"""
),
{"obj": obj_id, "lim": limit},
)
.mappings()
.all()
)
return [
{
"obj_file_id": r["obj_file_id"],
"ord_num": r["ord_num"],
"photo_url": r["photo_url"],
# Always serve thumbs through our backend — cached WebP, no upstream
# latency, no Next.js dev-mode optimizer cold-hit cost.
"thumb_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=thumb",
"full_url": f"/api/v1/photos/{obj_id}/{r['obj_file_id']}?size=full",
"photo_dttm": r["photo_dttm"].isoformat() if r["photo_dttm"] else None,
"period_dt": r["period_dt"].isoformat() if r["period_dt"] else None,
"size_bytes": r["size_bytes"],
"photo_name": r["photo_name"],
"ready_desc": r["ready_desc"],
"build_type": r["build_type"],
"local_path": r["local_path"],
}
for r in rows
]
def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]]:
"""Воронка по месяцам из materialized view."""
rows = (
db.execute(
text(
"""
SELECT month, source, leads, engaged, converted, conv_pct
FROM prinzip_funnel_monthly
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
ORDER BY month DESC, leads DESC
"""
),
{"months": months},
)
.mappings()
.all()
)
return [
{
"month": r["month"].isoformat() if r["month"] else None,
"source": r["source"],
"leads": r["leads"],
"engaged": r["engaged"],
"converted": r["converted"],
"conv_pct": _f(r["conv_pct"]),
}
for r in rows
]
def prinzip_funnel_by_source(db: Session, months: int = 12) -> list[dict[str, Any]]:
"""Агрегаты по source за последние N месяцев."""
rows = (
db.execute(
text(
"""
SELECT source,
SUM(leads) AS leads,
SUM(engaged) AS engaged,
SUM(converted) AS converted,
ROUND(100.0 * SUM(converted) / NULLIF(SUM(leads), 0), 2) AS conv_pct
FROM prinzip_funnel_monthly
WHERE month >= (CURRENT_DATE - (:months || ' months')::interval)::date
GROUP BY source
ORDER BY leads DESC
"""
),
{"months": months},
)
.mappings()
.all()
)
return [
{
"source": r["source"],
"leads": int(r["leads"] or 0),
"engaged": int(r["engaged"] or 0),
"converted": int(r["converted"] or 0),
"conv_pct": _f(r["conv_pct"]),
}
for r in rows
]
def prinzip_funnel_by_object(db: Session) -> list[dict[str, Any]]:
"""Conversion per ЖК."""
rows = (
db.execute(
text(
"""
SELECT obj_id, comm_name, leads_count, deals_count, conv_pct,
total_revenue, avg_deal_price
FROM prinzip_funnel_by_object
ORDER BY total_revenue DESC NULLS LAST
"""
),
)
.mappings()
.all()
)
return [
{
"obj_id": r["obj_id"],
"comm_name": r["comm_name"],
"leads_count": r["leads_count"],
"deals_count": r["deals_count"],
"conv_pct": _f(r["conv_pct"]),
"total_revenue": _f(r["total_revenue"]),
"avg_deal_price": _f(r["avg_deal_price"]),
}
for r in rows
]
def prinzip_objects_with_velocity(db: Session) -> list[dict[str, Any]]:
"""Список 28 PRINZIP-ЖК с агрегатами + apartments-velocity sparkline data."""
rows = (
db.execute(
text(
"""
WITH agg AS (
SELECT obj_id, total, realised, perc
FROM domrf_kn_sales_agg
WHERE type = 'apartments'
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sales_agg)
),
velocity AS (
SELECT obj_id,
ARRAY_AGG(realised ORDER BY report_month) AS sparkline_realised,
ARRAY_AGG(report_month::text ORDER BY report_month) AS months
FROM domrf_kn_sale_graph
WHERE type = 'apartments'
AND snapshot_date = (SELECT MAX(snapshot_date) FROM domrf_kn_sale_graph)
GROUP BY obj_id
)
SELECT o.obj_id, o.comm_name, o.addr, o.flat_count, o.square_living, o.ready_dt,
o.site_status,
a.total, a.realised, a.perc,
v.sparkline_realised, v.months
FROM domrf_kn_objects o
LEFT JOIN agg a ON a.obj_id = o.obj_id
LEFT JOIN velocity v ON v.obj_id = o.obj_id
WHERE o.dev_id = '6208_0'
AND o.snapshot_date = (
SELECT MAX(snapshot_date) FROM domrf_kn_objects WHERE dev_id = '6208_0'
)
ORDER BY a.total DESC NULLS LAST
"""
),
)
.mappings()
.all()
)
return [
{
"obj_id": r["obj_id"],
"comm_name": r["comm_name"],
"addr": r["addr"],
"flat_count": r["flat_count"],
"square_living": _f(r["square_living"]),
"ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
"site_status": r["site_status"],
"total": r["total"],
"realised": r["realised"],
"perc": _f(r["perc"]),
"sparkline_realised": (
list(r["sparkline_realised"]) if r["sparkline_realised"] else []
),
"months": list(r["months"]) if r["months"] else [],
}
for r in rows
]
# ── Rule-based recommender (Уровень 1) ────────────────────────────────────────
# Pretty-name map shared with quartirography_deals(). Keep IDs sortable so
# bucket ordering is deterministic in the response.
_BUCKET_PRETTY: dict[str, str] = {
"1-Студия": "Студии 15-30",
"2-1-к": "1-к 30-45",
"3-2-к": "2-к 45-60",
"4-3-к": "3-к 60-80",
"5-80+ м²": "80+ м²",
}
_BUCKET_SQL = text(
"""
WITH bucketed AS (
SELECT CASE
WHEN area < 30 THEN '1-Студия'
WHEN area < 45 THEN '2-1-к'
WHEN area < 60 THEN '3-2-к'
WHEN area < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
area,
price_per_sqm
FROM rosreestr_deals
WHERE region_code = :rc
AND doc_type = 'ДДУ'
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
-- 001 = земельные участки, 002 = нежилые помещения.
AND realestate_type_code = '002001003000'
AND area > 10
AND area <= 200 -- отсечь выбросы (коммерческие площади)
AND price_per_sqm BETWEEN 30000 AND 1000000
AND period_start_date >= NOW()
- (:months_window || ' months')::INTERVAL
)
SELECT bucket,
COUNT(*)::bigint AS deals,
AVG(area) AS area_avg,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median,
PERCENTILE_CONT(0.5) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_median,
PERCENTILE_CONT(0.25) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_p25,
PERCENTILE_CONT(0.75) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_p75
FROM bucketed
GROUP BY bucket
ORDER BY bucket
"""
)
def _bucket_distribution(db: Session, region_code: int, months_window: int) -> list[Any]:
return list(
db.execute(
_BUCKET_SQL,
{"rc": region_code, "months_window": months_window},
)
.mappings()
.all()
)
# Industry-default elasticity used when sale_graph regression is not reliable
# (n<30 or R²<0.1). Negative because higher price ⇒ slower sales.
FALLBACK_ELASTICITY = -1.5
def _velocity_baseline(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
) -> dict[str, Any]:
"""Median monthly sales velocity (apartments/month per ЖК) from
domrf_kn_sale_graph for objects in the same район+class over last 24 mo.
Returns dict {realised_per_month_median, realised_per_month_avg,
objects_count, observations}. All-None means no data → caller falls back.
"""
where_class = "AND o.obj_class = :cls" if target_class else ""
params: dict[str, Any] = {
"rc": region_code,
"dn": district_name,
}
if target_class:
params["cls"] = target_class
row = (
db.execute(
text(
f"""
WITH obj_pool AS (
SELECT o.obj_id
FROM domrf_kn_objects o
WHERE o.region_cd = :rc
AND o.district_name = :dn
{where_class}
),
sg AS (
SELECT sg.obj_id, sg.realised
FROM domrf_kn_sale_graph sg
JOIN obj_pool p ON p.obj_id = sg.obj_id
WHERE sg.type = 'apartments'
AND sg.realised IS NOT NULL
AND sg.report_month >= NOW() - INTERVAL '24 months'
)
SELECT
AVG(realised) AS avg_pm,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY realised) AS median_pm,
COUNT(DISTINCT obj_id) AS objects,
COUNT(*) AS observations
FROM sg
"""
),
params,
)
.mappings()
.first()
)
if not row:
return {
"realised_per_month_avg": None,
"realised_per_month_median": None,
"objects_count": 0,
"observations": 0,
}
return {
"realised_per_month_avg": _f(row["avg_pm"]),
"realised_per_month_median": _f(row["median_pm"]),
"objects_count": int(row["objects"] or 0),
"observations": int(row["observations"] or 0),
}
def _district_market_saturation(db: Session, *, district_name: str) -> tuple[float | None, int]:
"""Median sold% активных строящихся ЖК в районе. >50% = зрелый рынок
(конкуренты много продали, новый проект имеет место). <20% = свежий
(много инвентаря на продажу, сложнее пробиться).
Возвращает (median_pct, n_objects). None если <5 ЖК с perc.
"""
row = (
db.execute(
text(
"""
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median,
COUNT(*) AS n
FROM domrf_kn_sales_agg a
JOIN domrf_kn_objects o
ON o.obj_id = a.obj_id
AND o.snapshot_date = a.snapshot_date
WHERE a.type = 'apartments'
AND a.perc IS NOT NULL
AND o.district_name = :dn
AND o.site_status = 'Строящиеся'
"""
),
{"dn": district_name},
)
.mappings()
.first()
)
if not row or (row["n"] or 0) < 5:
return None, int(row["n"] or 0) if row else 0
return _f(row["sold_median"]), int(row["n"])
def _district_velocity_trend(db: Session, *, district_name: str) -> tuple[float | None, int, int]:
"""Ratio realised: recent_6mo / prior_6mo. >1.5 — рынок горит, <0.7 —
остывает. Считаем за окно 12 мес: H1 2025 vs H2 2025+.
Возвращает (ratio, recent_units, prior_units). None если данных мало.
"""
row = (
db.execute(
text(
"""
SELECT
SUM(sg.realised) FILTER (WHERE sg.report_month >= DATE '2025-07-01')
AS recent,
SUM(sg.realised) FILTER (WHERE sg.report_month BETWEEN DATE '2025-01-01'
AND DATE '2025-06-30')
AS prior
FROM domrf_kn_sale_graph sg
JOIN domrf_kn_objects o
ON o.obj_id = sg.obj_id
AND o.snapshot_date = sg.snapshot_date
WHERE sg.type = 'apartments'
AND o.district_name = :dn
"""
),
{"dn": district_name},
)
.mappings()
.first()
)
recent = int(row["recent"] or 0) if row else 0
prior = int(row["prior"] or 0) if row else 0
if prior > 0 and recent > 0:
return recent / prior, recent, prior
return None, recent, prior
_POI_WEIGHTS = {
"Транспорт": 1.5,
"Метро": 2.0,
"Образование": 1.2,
"Медицина": 1.3,
"Спорт": 1.0,
"Продукты": 0.8,
"Развлечения": 0.7,
"Новостройки": 0.0, # сами ЖК — не используем как amenity
}
def _district_poi_score(db: Session, *, district_name: str) -> float | None:
"""Среднее по ЖК района: weighted POI count в радиусе 1000м.
Используем категории-веса (метро/медицина важнее, новостройки игнор).
Возвращает None если в районе <3 ЖК с POI.
"""
weights_sql = " ".join(
[f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()]
)
row = (
db.execute(
text(
f"""
WITH per_obj AS (
SELECT i.obj_id,
SUM(CASE {weights_sql} ELSE 0.5 END)
FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000)
AS weighted_poi
FROM domrf_kn_infrastructure i
JOIN domrf_kn_objects o
ON o.obj_id = i.obj_id
AND o.snapshot_date = i.snapshot_date
WHERE o.district_name = :dn
GROUP BY i.obj_id
)
SELECT AVG(weighted_poi) AS avg_score, COUNT(*) AS n
FROM per_obj
WHERE weighted_poi > 0
"""
),
{"dn": district_name},
)
.mappings()
.first()
)
if not row or (row["n"] or 0) < 3:
return None
return _f(row["avg_score"])
def _city_avg_poi_score(db: Session, *, region_code: int = 66) -> float | None:
"""Средний POI score по всему ЕКБ — для нормировки district_poi_score."""
weights_sql = " ".join(
[f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()]
)
row = (
db.execute(
text(
f"""
WITH per_obj AS (
SELECT i.obj_id,
SUM(CASE {weights_sql} ELSE 0.5 END)
FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000)
AS weighted_poi
FROM domrf_kn_infrastructure i
JOIN domrf_kn_objects o
ON o.obj_id = i.obj_id
AND o.snapshot_date = i.snapshot_date
WHERE o.region_cd = :rc
AND o.district_name IS NOT NULL
GROUP BY i.obj_id
)
SELECT AVG(weighted_poi) AS avg_score
FROM per_obj
WHERE weighted_poi > 0
"""
),
{"rc": region_code},
)
.mappings()
.first()
)
return _f(row["avg_score"]) if row else None
def _district_cadastre_baseline(db: Session, *, district_name: str) -> dict[str, Any]:
"""Медианная кадастровая стоимость ₽/м² жилых строений в районе через
spatial-join cad_buildings → ekb_districts_geom. Возвращает None полей,
если в районе нет cad_buildings со cost_value.
Используется как cross-check для market price из rosreestr_deals:
cadastre_vs_market_pct > +50% (рынок сильно дороже кадастра, переоценка)
или < -30% (рынок дешевле кадастра, аномалия) → warning badge на UI.
"""
row = (
db.execute(
text(
"""
WITH district_geom AS (
SELECT geom
FROM ekb_districts_geom
WHERE district_name = :dn
LIMIT 1
),
buildings_in AS (
SELECT
cb.cost_value / NULLIF(cb.area, 0) AS price_per_m2
FROM cad_buildings cb
JOIN district_geom dg
ON ST_Intersects(dg.geom, cb.geom)
WHERE cb.cost_value IS NOT NULL
AND cb.area IS NOT NULL
AND cb.area >= 100
AND (cb.floors IS NOT NULL AND cb.floors >= 3
OR cb.purpose ILIKE '%многокв%')
AND (cb.cost_value / NULLIF(cb.area, 0))
BETWEEN 5000 AND 500000
)
SELECT
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2)
AS median_per_m2,
COUNT(*)::bigint AS n
FROM buildings_in
"""
),
{"dn": district_name},
)
.mappings()
.first()
)
if not row or row["n"] == 0:
return {"median_per_m2": None, "buildings_n": 0}
return {
"median_per_m2": _f(row["median_per_m2"]),
"buildings_n": int(row["n"]),
}
def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
"""Последняя средневзвешенная ставка ИЖК из cbr_mortgage_series.
ВАЖНО: возвращаем СРЕДНЕВЗВЕШЕННУЮ С льготами (семейная/IT/ДВ-ипотека) —
это ~7-8%. РЫНОЧНАЯ ставка без льгот в БД отсутствует (она ~20% по
публикациям ЦБ Янв 2026, но в наших cbr_mortgage_series этой серии нет).
Старый ILIKE '%ипотечн%жилищн%' случайно матчил «долю ипотечных кредитов
на ИЖС» (5.57% на ИЖС — НЕ ставка). Теперь строго matchим
'Средневзвешенная ставка по ипотечным жилищным' + 'в рублях, %'.
"""
row = (
db.execute(
text(
"""
SELECT value, period
FROM cbr_mortgage_series
WHERE title ILIKE 'Средневзвешенная ставка по ипотечным жилищным%'
AND title ILIKE '%в рублях, %'
AND value IS NOT NULL
AND value BETWEEN 1 AND 30 -- защита от мусорных
ORDER BY period DESC
LIMIT 1
"""
)
)
.mappings()
.first()
)
if not row:
return None, None
return _f(row["value"]), row["period"]
def _active_competitors_count(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
) -> tuple[int, str]:
"""N активно строящихся ЖК для нормировки velocity. Каскадный fallback:
1) (район + класс) — самый узкий
2) (район) без класса — если первый дал <2
3) весь регион — если второй дал <2
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
def _q(where_extras: str, params: dict[str, Any]) -> int:
n = db.execute(
text(
f"""
SELECT COUNT(*) FROM domrf_kn_objects
WHERE region_cd = :rc
AND site_status = 'Строящиеся'
{where_extras}
"""
),
params,
).scalar()
return int(n or 0)
# Tier 1: район + класс (через PostGIS-полигоны district_name)
if target_class:
n = _q(
"AND district_name = :dn AND obj_class = :cls",
{"rc": region_code, "dn": district_name, "cls": target_class},
)
if n >= 2:
return n, "district+class"
# Tier 2: район (без класса — могут быть ЖК где obj_class NULL)
n = _q(
"AND district_name = :dn",
{"rc": region_code, "dn": district_name},
)
if n >= 2:
return n, "district"
# Tier 3: весь регион (когда район по сути не покрыт скрапером)
n = _q("", {"rc": region_code})
if n >= 1:
return n, "region"
return 1, "fallback_singleton"
def _elasticity_coef(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
) -> dict[str, Any]:
"""Fit log-log regression LN(realised) ~ LN(price_avg) on sale_graph
observations for the same район+class. Returns elasticity (slope), R²,
n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak."""
where_class = "AND o.obj_class = :cls" if target_class else ""
params: dict[str, Any] = {"rc": region_code, "dn": district_name}
if target_class:
params["cls"] = target_class
row = (
db.execute(
text(
f"""
WITH obj_pool AS (
SELECT o.obj_id
FROM domrf_kn_objects o
WHERE o.region_cd = :rc
AND o.district_name = :dn
{where_class}
),
pts AS (
SELECT LN(sg.realised)::float8 AS y,
LN(sg.price_avg)::float8 AS x
FROM domrf_kn_sale_graph sg
JOIN obj_pool p ON p.obj_id = sg.obj_id
WHERE sg.type = 'apartments'
AND sg.realised IS NOT NULL AND sg.realised > 0
AND sg.price_avg IS NOT NULL AND sg.price_avg > 0
AND sg.report_month >= NOW() - INTERVAL '36 months'
)
SELECT
regr_slope(y, x) AS slope,
regr_r2(y, x) AS r2,
COUNT(*) AS n
FROM pts
"""
),
params,
)
.mappings()
.first()
)
n = int(row["n"]) if row and row["n"] is not None else 0
slope = _f(row["slope"]) if row else None
r2 = _f(row["r2"]) if row else None
if n >= 30 and slope is not None and r2 is not None and r2 >= 0.1 and slope < 0:
return {
"elasticity": round(slope, 4),
"r2": round(r2, 4),
"n": n,
"source": "regression",
}
return {
"elasticity": FALLBACK_ELASTICITY,
"r2": r2 or 0.0,
"n": n,
"source": "fallback",
}
def _elasticity_per_bucket_coef(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
fallback: dict[str, Any],
) -> dict[str, dict[str, Any]]:
"""Per-bucket эластичность (Tier 3): группируем sale_graph-наблюдения по
«доминирующему bucket» каждого ЖК (mode total_area из domrf_kn_flats),
регрессия log-log для каждой группы. Студии vs 80+ м² реагируют на цену
по-разному.
Returns: dict[bucket_pretty → {elasticity, r2, n, source}]. Если в bucket'е
меньше 30 точек или регрессия слабая (R²<0.05 либо positive slope) — берём
общую эластичность из `fallback` со source='fallback_global'.
"""
where_class = "AND o.obj_class = :cls" if target_class else ""
params: dict[str, Any] = {"rc": region_code, "dn": district_name}
if target_class:
params["cls"] = target_class
rows = (
db.execute(
text(
f"""
WITH obj_pool AS (
SELECT o.obj_id
FROM domrf_kn_objects o
WHERE o.region_cd = :rc
AND o.district_name = :dn
{where_class}
),
obj_bucket AS (
-- Доминирующий bucket каждого ЖК = mode total_area среди
-- его flats. Если flats пусты — ЖК не учитывается.
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
FROM domrf_kn_flats f
JOIN obj_pool p ON p.obj_id = f.obj_id
WHERE f.total_area IS NOT NULL
AND f.total_area BETWEEN 15 AND 200
GROUP BY f.obj_id
),
pts AS (
SELECT
ob.bucket,
LN(sg.realised)::float8 AS y,
LN(sg.price_avg)::float8 AS x
FROM domrf_kn_sale_graph sg
JOIN obj_bucket ob ON ob.obj_id = sg.obj_id
WHERE sg.type = 'apartments'
AND sg.realised IS NOT NULL AND sg.realised > 0
AND sg.price_avg IS NOT NULL AND sg.price_avg > 0
AND sg.report_month >= NOW() - INTERVAL '36 months'
)
SELECT bucket,
regr_slope(y, x) AS slope,
regr_r2(y, x) AS r2,
COUNT(*)::bigint AS n
FROM pts
GROUP BY bucket
"""
),
params,
)
.mappings()
.all()
)
out: dict[str, dict[str, Any]] = {}
fallback_e = float(fallback["elasticity"])
by_bucket = {r["bucket"]: r for r in rows}
for bucket_id, bucket_pretty in _BUCKET_PRETTY.items():
r = by_bucket.get(bucket_id)
n_b = int(r["n"]) if r and r["n"] is not None else 0
slope = _f(r["slope"]) if r else None
r2 = _f(r["r2"]) if r else None
if n_b >= 30 and slope is not None and r2 is not None and r2 >= 0.05 and slope < 0:
out[bucket_pretty] = {
"elasticity": round(slope, 4),
"r2": round(r2, 4),
"n": n_b,
"source": "regression",
}
else:
out[bucket_pretty] = {
"elasticity": fallback_e,
"r2": round(r2, 4) if r2 is not None else 0.0,
"n": n_b,
"source": "fallback_global",
}
return out
def recommend_mix(
db: Session,
*,
district_name: str,
area_total_m2: float | None = None,
target_class: str | None = None,
months_window: int = 12,
region_code: int = 66,
price_factor: float = 1.0,
target_months: int | None = None,
) -> dict[str, Any]:
"""Rule-based квартирография recommender.
City-wide bucket distribution from rosreestr_deals (последние N месяцев),
скорректированная на район (через ekb_districts.median_price_per_m2) и
класс (через yandex_realty_zk price-агрегаты per-class).
See plan: C:/Users/user/.claude/plans/crispy-swinging-gadget.md
"""
warnings: list[str] = []
# 1) District lookup
district_row = (
db.execute(
text(
"""
SELECT district_name, zk_count, flat_count,
median_price_per_m2, mean_price_per_m2
FROM ekb_districts
WHERE district_name ILIKE :dn
AND district_name <> 'не определён'
LIMIT 1
"""
),
{"dn": district_name.strip()},
)
.mappings()
.first()
)
if not district_row:
return {
"scope": {"district": district_name, "error": "district unknown"},
"buckets": [],
"summary": {
"total_revenue_rub": None,
"weighted_avg_price_per_m2": None,
"warnings": [f"Район '{district_name}' не найден в ekb_districts"],
},
"comparables": [],
}
district_median = _f(district_row["median_price_per_m2"])
if district_median is None:
warnings.append(
f"В ekb_districts нет median_price_per_m2 для '{district_row['district_name']}',"
" коэффициент района = 1.0"
)
# 2) City-wide median baseline
city_median = _f(
db.execute(
text(
"""
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY median_price_per_m2)
FROM ekb_districts
WHERE median_price_per_m2 IS NOT NULL
"""
)
).scalar()
)
district_factor = (
district_median / city_median
if (district_median and city_median and city_median > 0)
else 1.0
)
# 3) Class multiplier через yandex_realty_zk + Comfort как BASELINE (×1.0).
# Раньше делили class_avg/overall_avg где overall = смесь по 12 rows
# → числа абсурдные (Elite ×1.22, Comfort+ ×0.66 < Comfort).
# Теперь: ratio(class) = class_price_avg / comfort_price_avg.
# Реалистичные индустриальные значения: Comfort=1.0, Comfort+=1.02,
# Business=1.86, Elite=4.27 (на основе текущих 12 rows yandex_realty_zk).
# yandex_realty_class_prices игнорируем — midpoint бессмыслен (ширина
# диапазонов класса искажает result).
# UI шлёт 'Comfort'/'Comfort+'/'Business'/'Elite' → realty_zk: 'COMFORT'/
# 'COMFORT_PLUS'/'BUSINESS'/'ELITE'.
class_multiplier = 1.0
class_multiplier_source: str | None = None
if target_class:
zk_norm = {
"Comfort": "COMFORT",
"Comfort+": "COMFORT_PLUS",
"Business": "BUSINESS",
"Elite": "ELITE",
}.get(target_class)
if zk_norm:
r = (
db.execute(
text(
"""
SELECT
AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg,
AVG(price_from) FILTER (WHERE obj_class = 'COMFORT') AS comfort_avg
FROM yandex_realty_zk
WHERE price_from IS NOT NULL AND price_from > 0
"""
),
{"cls": zk_norm},
)
.mappings()
.first()
)
cavg = _f(r["class_avg"]) if r else None
comfort_avg = _f(r["comfort_avg"]) if r else None
if cavg and comfort_avg and comfort_avg > 0:
class_multiplier = cavg / comfort_avg
class_multiplier_source = "realty_zk_vs_comfort"
else:
warnings.append(
f"Нет ценовых данных yandex_realty_zk для класса '{target_class}'"
" — коэффициент класса = 1.0"
)
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
# для всех бакетов и проставляем warning. Это даёт более устойчивые медианы.
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
warnings.append(
f"Окно расширено до 24 мес: при {months_window} мес хотя бы один"
" бакет имел <30 сделок — оценка была бы шумной"
)
total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1
# 5) Build buckets with adjusted prices + optional allocation
buckets: list[dict[str, Any]] = []
weighted_num = 0.0 # Σ area_avg × share × price
weighted_den = 0.0 # Σ area_avg × share
total_revenue = 0.0
have_revenue = False
for r in bucket_rows:
bid = r["bucket"]
deals = int(r["deals"] or 0)
share = round(deals * 100 / total_deals, 1)
area_avg = _f(r["area_avg"]) or 0.0
area_med = _f(r["area_median"]) or 0.0
p_med_city = _f(r["price_median"]) or 0.0
p25_city = _f(r["price_p25"]) or 0.0
p75_city = _f(r["price_p75"]) or 0.0
adj = district_factor * class_multiplier
p_med = round(p_med_city * adj, 2)
p25 = round(p25_city * adj, 2)
p75 = round(p75_city * adj, 2)
units_planned: int | None = None
revenue_planned: float | None = None
if area_total_m2 and area_avg > 0:
allocated = area_total_m2 * (share / 100.0)
units_planned = max(1, round(allocated / area_avg))
revenue_planned = round(units_planned * area_avg * p_med, 2)
total_revenue += revenue_planned
have_revenue = True
weighted_num += area_avg * share * p_med
weighted_den += area_avg * share
if deals < 30:
warnings.append(
f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок"
f" за {effective_window} мес — оценка с большой погрешностью"
)
buckets.append(
{
"bucket": _BUCKET_PRETTY.get(bid, bid),
"share_pct": share,
"deal_count": deals,
"area_avg_m2": round(area_avg, 1),
"area_median_m2": round(area_med, 1),
"price_median_per_m2": p_med,
"price_p25_per_m2": p25,
"price_p75_per_m2": p75,
"units_planned": units_planned,
"revenue_planned_rub": revenue_planned,
}
)
weighted_avg_price = round(weighted_num / weighted_den, 2) if weighted_den > 0 else None
# 5b) Velocity baseline (apartments/month per ЖК) + price elasticity.
# Both are required for the live "цена↔темп" calculator on the frontend.
# Graceful: kn-API returns obj_class=NULL для всех ЖК Свердл (отдельный
# баг скрейпера). Если в районе нет ни одного НЕ-NULL obj_class —
# игнорируем target_class фильтр на уровне velocity/elasticity/comparable
# запросов, иначе obj_pool пустой и всё падает в fallback.
has_class_data = bool(
db.execute(
text(
"""
SELECT 1 FROM domrf_kn_objects
WHERE region_cd = :rc
AND district_name = :dn
AND obj_class IS NOT NULL
LIMIT 1
"""
),
{"rc": region_code, "dn": district_row["district_name"]},
).scalar()
)
target_class_for_geo = target_class if has_class_data else None
if target_class and not has_class_data:
warnings.append(
f"obj_class не заполнен для ЖК района {district_row['district_name']}"
f" — фильтр по классу '{target_class}' игнорируется в velocity/comparable"
" (но class_multiplier из yandex_realty_zk применяется к ценам)."
)
vel = _velocity_baseline(
db,
region_code=region_code,
district_name=district_row["district_name"],
target_class=target_class_for_geo,
)
sale_graph_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"]
velocity_source = "sale_graph" if sale_graph_vel_pm is not None else "rosreestr_fallback"
elast = _elasticity_coef(
db,
region_code=region_code,
district_name=district_row["district_name"],
target_class=target_class_for_geo,
)
elasticity = elast["elasticity"]
if elast["source"] == "fallback":
warnings.append(
f"Эластичность цена↔темп взята по умолчанию ({elasticity})"
f" — sale_graph даёт n={elast['n']}, R²={round(elast['r2'], 2)}"
" (недостаточно для регрессии)."
)
# Tier 3: per-bucket эластичность. Регрессия sale_graph по
# «доминирующему bucket» каждого ЖК. Если для bucket'а данных мало —
# подставляем общую elasticity. Малые сегменты (1-2 студии в районе)
# таким образом не выкидываются — используем общую кривую.
elast_per_bucket = _elasticity_per_bucket_coef(
db,
region_code=region_code,
district_name=district_row["district_name"],
target_class=target_class_for_geo,
fallback=elast,
)
# 5b-1) N активных конкурентов с каскадным fallback (район+класс →
# район → регион). Используется как divisor в rosreestr-fallback ветке.
competitors, competitors_scope = _active_competitors_count(
db,
region_code=region_code,
district_name=district_row["district_name"],
target_class=target_class_for_geo,
)
if competitors_scope == "fallback_singleton":
warnings.append(
f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}"
f" ни в регионе {region_code} — нормировка отключена (как для монополиста)."
)
elif competitors_scope != "district+class":
# Информативное сообщение о расширении scope при недостатке локальных данных.
scope_label = {
"district": f"районе {district_row['district_name']} (без класса)",
"region": f"регионе {region_code} (вне района)",
}.get(competitors_scope, competitors_scope)
warnings.append(
f"Конкурентов класса '{target_class or '*'}' в районе мало —"
f" нормировка по {competitors} ЖК в {scope_label}."
)
# 5b-2) market_vel_pm = «что продаёт ОДИН активный ЖК района за месяц».
# ИСТОЧНИК ИСТИНЫ — sale_graph (median realised per ЖК). При отсутствии —
# rosreestr-fallback: city-wide deals/mo / N_competitors → per-ЖК proxy.
# Это критично: per-ЖК baseline должен иметь правильную размерность
# (~3-7 кв/мес для ЕКБ ЖК), иначе months_to_sellout получается
# нереалистично коротким.
if sale_graph_vel_pm is not None:
market_vel_pm = sale_graph_vel_pm
else:
warnings.append(
"Нет sale_graph данных для этого района и класса —"
" темп считается по rosreestr-сделкам ÷ конкуренты (грубее)."
)
market_vel_pm = (
(total_deals / max(effective_window, 1) / max(competitors, 1))
if total_deals and competitors
else 0.0
)
# 5b-2.5) Per-bucket market velocity = market_vel_pm × share / 100.
# Аллоцируем единый per-ЖК baseline на размерные сегменты по shares
# (одинаковая модель для sale_graph и rosreestr_fallback). Студии/1к
# получат больший абсолютный темп если их share высокая в районе.
bucket_market_velocities = {
b["bucket"]: market_vel_pm * (b["share_pct"] / 100.0) for b in buckets
}
# 5b-2.5) Дополнительные district-specific signals (Tier 2):
# sat_factor — насколько зрелый рынок района (median sold% активных
# ЖК). >50% = зрелый, новый проект имеет место, +bonus.
# <20% = свежий, много инвентаря, -penalty.
# trend_factor — recent_6mo / prior_6mo realised. Clamp 0.7..2.0 чтобы
# экстремум не разрушал расчёты.
# poi_factor — weighted POI density района / city avg. ±5% на цены.
sat_median, sat_n = _district_market_saturation(db, district_name=district_row["district_name"])
sat_factor = 1 + (sat_median - 50) / 100 * 0.3 if sat_median is not None else 1.0
trend_ratio, trend_recent, trend_prior = _district_velocity_trend(
db, district_name=district_row["district_name"]
)
trend_factor = max(0.7, min(2.0, trend_ratio)) if trend_ratio else 1.0
poi_score = _district_poi_score(db, district_name=district_row["district_name"])
city_avg_poi = _city_avg_poi_score(db, region_code=region_code)
# Cadastre cross-check: медианная кадастровая стоимость ₽/м² района через
# cad_buildings → ekb_districts spatial-join. Аномалии (рынок vs кадастр)
# выводятся как warning-цена в RecommendVelocityPanel.
cadastre = _district_cadastre_baseline(db, district_name=district_row["district_name"])
poi_factor = (
1 + (poi_score - city_avg_poi) / max(city_avg_poi, 1) * 0.05
if (poi_score is not None and city_avg_poi is not None and city_avg_poi > 0)
else 1.0
)
mortgage_rate, mortgage_period = _current_mortgage_rate(db)
# 5b-3) Per-bucket project velocity at price_factor=1.0:
# bucket_market_v = market_vel_pm × bucket.share/100 — доля per-ЖК
# темпа, аллоцированная на размерный сегмент.
# market_vel_pm УЖЕ per-ЖК (median sale_graph либо
# rosreestr/N_competitors), доп. нормировка не нужна.
# project_velocity = bucket_market_v × sat_factor × trend_factor
# sat — зрелый рынок ускоряет; trend — текущая
# динамика (горит/остывает).
# adjusted = project_velocity × price_factor^elasticity
# months_to_sellout = units_planned / adjusted
# Цена тоже корректируется на poi_factor (развитость района = премиум).
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
macro_velocity_mult = sat_factor * trend_factor
total_units = 0
for b in buckets:
bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0)
bucket_velocity = round(bucket_market_v * macro_velocity_mult, 4)
b["velocity_per_month"] = bucket_velocity
# Per-bucket эластичность: ключ — pretty-имя (b["bucket"] уже pretty).
be = elast_per_bucket.get(b["bucket"]) or {}
bucket_elasticity = float(be.get("elasticity", elasticity))
bucket_pf_pow = price_factor**bucket_elasticity if price_factor > 0 else 1.0
b["elasticity"] = bucket_elasticity
b["elasticity_r2"] = be.get("r2", 0.0)
b["elasticity_n"] = be.get("n", 0)
b["elasticity_source"] = be.get("source", "fallback_global")
# POI-корректировка на цену (на ВСЕ p25/median/p75)
b["price_median_per_m2"] = round(b["price_median_per_m2"] * poi_factor, 2)
b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * poi_factor, 2)
b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * poi_factor, 2)
if b["units_planned"] and bucket_velocity > 0:
# Revenue тоже пересчитываем после POI-correction (linear scale).
if b["revenue_planned_rub"] is not None:
b["revenue_planned_rub"] = round(b["revenue_planned_rub"] * poi_factor, 2)
adjusted_velocity = bucket_velocity * bucket_pf_pow
b["months_to_sellout"] = (
round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None
)
total_units += b["units_planned"]
else:
b["months_to_sellout"] = None
# Итог revenue + weighted_avg_price после POI-correction (linear scale).
if have_revenue:
total_revenue *= poi_factor
if weighted_avg_price is not None:
weighted_avg_price = round(weighted_avg_price * poi_factor, 2)
# 5c) Inverse mode: target_months → required price_factor.
# Tier 3: используем weighted-by-units эластичность (per-bucket эластичности
# → агрегатная только когда нужна одна цифра). При smooth-buckets разница
# с глобальной невелика, но если bucket-mix сильно перекошен в одну сторону —
# weighted-эластичность точнее отражает поведение портфеля.
required_price_factor: float | None = None
weighted_elasticity = elasticity
if total_units > 0:
weighted_elasticity = (
sum(
(b.get("elasticity") or elasticity) * (b.get("units_planned") or 0) for b in buckets
)
/ total_units
)
if target_months and total_units > 0:
base_total_velocity = sum(b["velocity_per_month"] or 0 for b in buckets)
if base_total_velocity > 0 and weighted_elasticity != 0:
required_velocity = total_units / target_months
ratio = required_velocity / base_total_velocity
try:
required_price_factor = round(ratio ** (1.0 / weighted_elasticity), 4)
except Exception:
required_price_factor = None
if required_price_factor and required_price_factor < 0.7:
warnings.append(
f"Целевой срок {target_months} мес требует скидки"
f" >{round((1 - required_price_factor) * 100)}% — рассмотри"
" сдвиг ассортимента в сторону ликвидных бакетов."
)
# 5d) Liquidity score (0-100): % units sold within 24 months.
liquidity_24mo: float | None = None
if total_units > 0:
sold_24mo = 0.0
for b in buckets:
mts = b["months_to_sellout"]
up = b["units_planned"] or 0
if up <= 0 or mts is None or mts <= 0:
continue
frac = min(1.0, 24.0 / mts)
sold_24mo += frac * up
liquidity_24mo = round(sold_24mo / total_units * 100, 1)
# 5e) Aggregate KPIs. Total months_to_sellout считаем как сумму
# bucket-уровневых adjusted velocities (каждая со своим pf_pow по своей
# эластичности) — иначе перекос в bucket-mix искажает агрегат.
months_to_sellout_total: float | None = None
base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets)
adjusted_total_v = 0.0
for b in buckets:
v = b.get("velocity_per_month") or 0
be = b.get("elasticity")
bpf = price_factor**be if (be is not None and price_factor > 0) else pf_pow
adjusted_total_v += v * bpf
if total_units > 0 and adjusted_total_v > 0:
months_to_sellout_total = round(total_units / adjusted_total_v, 1)
avg_ticket = (
round(total_revenue / total_units, 2) if (have_revenue and total_units > 0) else None
)
# 6) Comparable ЖК — same district (parsed from addr) and class
cmp_rows = (
db.execute(
text(
"""
WITH latest_agg AS (
SELECT obj_id, MAX(snapshot_date) AS snap
FROM domrf_kn_sales_agg
WHERE type = 'apartments'
GROUP BY obj_id
)
SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.flat_count,
a.perc AS sold_perc
FROM domrf_kn_objects o
LEFT JOIN latest_agg la ON la.obj_id = o.obj_id
LEFT JOIN domrf_kn_sales_agg a
ON a.obj_id = la.obj_id
AND a.snapshot_date = la.snap
AND a.type = 'apartments'
WHERE o.region_cd = :rc
AND o.district_name = :dn
AND (CAST(:cls AS TEXT) IS NULL OR o.obj_class = :cls)
ORDER BY o.flat_count DESC NULLS LAST
LIMIT 5
"""
),
{
"rc": region_code,
"dn": district_row["district_name"],
"cls": target_class_for_geo,
},
)
.mappings()
.all()
)
# 7) Headline для CEO — одна строка с тремя главными цифрами
headline_parts: list[str] = []
if have_revenue:
headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽")
if months_to_sellout_total:
headline_parts.append(f"за ~{months_to_sellout_total:.1f} мес")
if avg_ticket:
headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М")
if base_total_v > 0:
# Tempo = sum bucket-adjusted velocities (каждая со своим pf_pow по своей
# эластичности). Это согласовано с months_to_sellout_total выше.
tempo = adjusted_total_v if adjusted_total_v > 0 else base_total_v * pf_pow
headline_parts.append(
f"темп {tempo:.2f} кв/мес" if tempo < 1 else f"темп {tempo:.1f} кв/мес"
)
if liquidity_24mo is not None:
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
headline = " · ".join(headline_parts) if headline_parts else None
return {
"scope": {
"district": district_row["district_name"],
"district_zk_count": district_row["zk_count"],
"district_median_price_per_m2": district_median,
"district_factor": round(district_factor, 4),
"class_multiplier": round(class_multiplier, 4),
"class_multiplier_source": class_multiplier_source,
"target_class": target_class,
"months_window": months_window,
"effective_window_months": effective_window,
"region_code": region_code,
"total_deals": total_deals if bucket_rows else 0,
"market_velocity_per_month": (
round(market_vel_pm, 3) if market_vel_pm is not None else None
),
"velocity_source": velocity_source,
"velocity_observations": vel["observations"],
"velocity_objects": vel["objects_count"],
"competitors_count": competitors,
"competitors_scope": competitors_scope,
"saturation_median": sat_median,
"saturation_n": sat_n,
"sat_factor": round(sat_factor, 4),
"velocity_trend_ratio": (round(trend_ratio, 2) if trend_ratio is not None else None),
"trend_recent_units": trend_recent,
"trend_prior_units": trend_prior,
"trend_factor": round(trend_factor, 4),
"poi_score": round(poi_score, 1) if poi_score is not None else None,
"poi_score_city_avg": (round(city_avg_poi, 1) if city_avg_poi is not None else None),
"poi_factor": round(poi_factor, 4),
"mortgage_rate_pct": mortgage_rate,
"mortgage_rate_period": mortgage_period,
"elasticity": elasticity,
"elasticity_r2": elast["r2"],
"elasticity_n": elast["n"],
"elasticity_source": elast["source"],
"elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None),
"elasticity_per_bucket": elast_per_bucket,
"cadastre_median_per_m2": (
round(cadastre["median_per_m2"], 0)
if cadastre["median_per_m2"] is not None
else None
),
"cadastre_buildings_n": cadastre["buildings_n"],
"cadastre_vs_market_pct": (
round(
(district_median - cadastre["median_per_m2"])
/ cadastre["median_per_m2"]
* 100.0,
1,
)
if (cadastre["median_per_m2"] and cadastre["median_per_m2"] > 0 and district_median)
else None
),
"price_factor_applied": round(price_factor, 4),
"required_price_factor": required_price_factor,
"target_months": target_months,
"data_caveat": (
"MVP: bucket-распределение город-wide (регион 66). Район влияет"
" только на ценовой коэффициент. v2 добавит per-district demand"
" при заведении PostGIS-полигонов."
),
},
"buckets": buckets,
"summary": {
"total_revenue_rub": round(total_revenue, 2) if have_revenue else None,
"weighted_avg_price_per_m2": weighted_avg_price,
"total_units_planned": total_units if total_units > 0 else None,
"months_to_sellout_total": months_to_sellout_total,
"avg_ticket_rub": avg_ticket,
"liquidity_score_24mo": liquidity_24mo,
"headline": headline,
"warnings": warnings,
},
"comparables": [
{
"obj_id": r["obj_id"],
"comm_name": r["comm_name"],
"dev_name": r["dev_name"],
"obj_class": r["obj_class"],
"flat_count": r["flat_count"],
"sold_perc": _f(r["sold_perc"]),
}
for r in cmp_rows
],
}