gendesign/backend/app/services/analytics_queries.py
2026-04-29 08:39:09 +03:00

1915 lines
73 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
import math
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 _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
"""Последняя средневзвешенная ипотечная ставка из cbr_mortgage_series.
Возвращает (rate_pct, period_label)."""
row = (
db.execute(
text(
"""
SELECT value, period
FROM cbr_mortgage_series
WHERE title ILIKE '%ипотечн%жилищн%'
AND value IS NOT NULL
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 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,
)
market_vel_pm = vel["realised_per_month_median"] or vel["realised_per_month_avg"]
if market_vel_pm is None:
# Fallback: derive from city-wide rosreestr deals (distribute per bucket
# by share). Coarser, but lets the calculator work anywhere.
warnings.append(
"Нет sale_graph данных для этого района и класса —"
" темп считается по rosreestr-сделкам (грубее)."
)
market_vel_pm = (total_deals / max(effective_window, 1)) if total_deals else 0.0
velocity_source = "rosreestr_fallback"
else:
velocity_source = "sale_graph"
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)}"
" (недостаточно для регрессии)."
)
# 5b-1) N активных конкурентов с каскадным fallback (район+класс →
# район → регион). Используется для нормировки рыночной velocity.
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) Per-bucket market velocity (сделок/мес для каждого размерного
# сегмента из rosreestr — НЕ city-wide, а РЕАЛЬНАЯ интенсивность сегмента).
# Студии/1к — обычно выше, 80+ — ниже.
bucket_market_velocities = {
_BUCKET_PRETTY.get(r["bucket"], r["bucket"]): (
int(r["deals"] or 0) / max(effective_window, 1)
)
for r in bucket_rows
}
# 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)
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 = темп РЫНКА для bucket'а (deals/mo по всему региону)
# normalisation = sqrt(N_competitors) — power-law эффективные
# конкуренты (sqrt компромисс между ÷1 и ÷N).
# project_velocity = bucket_market_v / sqrt(N) × 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
competitors_norm = math.sqrt(max(competitors, 1))
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 / competitors_norm * macro_velocity_mult, 4)
b["velocity_per_month"] = bucket_velocity
# 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 * 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.
# required_velocity = total_units / target_months
# base_velocity_total = sum(bucket_velocity) (at price_factor=1)
# required_pf^elasticity = required_velocity / base_velocity_total
# → required_pf = (required_velocity / base_velocity_total)^(1/elasticity)
required_price_factor: float | None = None
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 elasticity != 0:
required_velocity = total_units / target_months
ratio = required_velocity / base_total_velocity
try:
required_price_factor = round(ratio ** (1.0 / 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
months_to_sellout_total: float | None = None
base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets)
if total_units > 0 and base_total_v > 0:
months_to_sellout_total = round(total_units / (base_total_v * pf_pow), 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:
# Малая velocity — формат с 2 десятыми (0.07 кв/мес для ЖК-доли).
tempo = 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"],
"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
],
}