gendesign/backend/app/services/analytics_queries.py
bot-backend 72e9b24f2c
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m24s
Deploy / build-worker (push) Successful in 2m33s
Deploy / deploy (push) Successful in 1m4s
feat(recommend): horizon-aware recommend_mix opt-in overlay (#982, 953-A) (#1014)
2026-06-03 07:40:33 +00:00

2994 lines
123 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 logging
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
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 по area_per_unit = area / deal_count (rosreestr
# с 2025Q1 публикует пакетные ДДУ одной строкой с суммарной area).
# deal_count — это число квартир в строке; bucket по сырой area без
# деления попадал в 80+ м² для большинства аггрегаций → перекошенный
# «парадокс портфеля» (70% 80+ вместо реальных 5%).
rows = (
db.execute(
text(
"""
WITH per_unit AS (
SELECT (area / deal_count) AS area_per_unit,
price_per_sqm,
deal_count
FROM rosreestr_deals
WHERE region_code = :region_id
AND doc_type = 'ДДУ'
AND realestate_type_code = '002001003000'
AND area > 0 AND deal_count > 0
AND (area / deal_count) BETWEEN 15 AND 200
AND price_per_sqm > 0
AND period_start_date >= '2025-07-01'
),
bucketed AS (
SELECT CASE
WHEN area_per_unit < 30 THEN '1-Студия'
WHEN area_per_unit < 45 THEN '2-1-к'
WHEN area_per_unit < 60 THEN '3-2-к'
WHEN area_per_unit < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
price_per_sqm,
deal_count
FROM per_unit
)
SELECT bucket,
SUM(deal_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 d.district_name, d.zk_count, d.flat_count, d.area_m2,
d.median_price_per_m2, d.mean_price_per_m2,
COALESCE(cq.cad_quarter_count, 0) AS cad_quarter_count
FROM ekb_districts d
LEFT JOIN (
SELECT district_name,
COUNT(*) FILTER (WHERE cad_quarter IS NOT NULL) AS cad_quarter_count
FROM v_complex_full
WHERE district_name IS NOT NULL
GROUP BY district_name
) cq ON cq.district_name = d.district_name
WHERE d.district_name <> 'не определён'
ORDER BY d.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"]),
"cad_quarter_count": int(r["cad_quarter_count"]),
}
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,
(SELECT COUNT(*) FROM complexes c
WHERE c.developer_id = m.developer_id) AS complexes_count
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"]),
"complexes_count": int(r["complexes_count"] or 0),
}
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).
Также возвращает buildings_count из v_complex_buildings (0 если зданий нет).
"""
row = (
db.execute(
text(
"""
SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd,
o.dev_id, o.dev_name, o.floor_min, o.floor_max, o.flat_count,
o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class,
o.latitude, o.longitude, o.obj_status, o.snapshot_date,
COALESCE(cb.buildings_count, 0) AS buildings_count
FROM domrf_kn_objects o
LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id
WHERE o.obj_id = :obj
ORDER BY o.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,
"buildings_count": int(row["buildings_count"]),
}
def object_sale_graph(
db: Session, obj_id: int, type_filter: str | None = None
) -> list[dict[str, Any]]:
"""Time-series продаж per-ЖК. Latest snapshot.
NOTE: намеренно оставлен на domrf_kn_sale_graph — это внутренний
per-object detail view для PRINZIP-аналитики (/api/v1/analytics/object/*).
Миграция на objective_corpus_room_month требует отдельного pr: там другая
гранулярность (corpus × room_bucket), а не obj_id.
Данные stale (newest 2026-01) — приемлемо для исторического графика.
"""
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 object_full_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
"""Extended object detail — adds all Wave A+B columns (22begh).
Returns the same base fields as object_detail() PLUS the 30 new columns
from 113_22begh_kn_schema_extension.sql. Falls back gracefully: columns
that haven't been scraped yet return NULL.
"""
row = (
db.execute(
text(
"""
SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd,
o.dev_id, o.dev_name, o.dev_group_name,
o.floor_min, o.floor_max, o.flat_count,
o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class,
o.latitude, o.longitude, o.obj_status, o.snapshot_date,
o.energy_eff, o.wall_type,
-- Building specs (22e)
o.first_floor_type, o.section_count,
o.elevators_passenger_count, o.elevators_cargo_count,
o.parking_total_slots, o.guest_parking_inside_count,
o.guest_parking_outside_count, o.ceiling_height_m,
-- Apartment summary (22e)
o.finishing_variants_count, o.has_free_planning, o.avg_flat_area_m2,
-- Yard (22e)
o.playground_kids_count, o.playground_sport_count,
o.has_bike_paths, o.trash_areas_count,
-- OVZ (22e)
o.has_ramp, o.has_low_platforms, o.has_wheelchair_lift,
-- Catalog/UI (22e)
o.flat_area_min, o.flat_area_max,
o.price_min_rub, o.price_max_rub,
o.price_per_m2_min, o.price_per_m2_max,
o.parking_provision_pct, o.project_published_at,
o.project_declaration_num,
-- Metro & scores (22e/22h)
o.metro_nearest_name, o.metro_nearest_walk_minutes, o.metro_top3,
o.domrf_score_location, o.domrf_score_transport,
o.domrf_score_infrastructure,
COALESCE(cb.buildings_count, 0) AS buildings_count
FROM domrf_kn_objects o
LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id
WHERE o.obj_id = :obj
ORDER BY o.snapshot_date DESC
LIMIT 1
"""
),
{"obj": obj_id},
)
.mappings()
.first()
)
if not row:
return None
metro_top3 = row["metro_top3"]
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"],
"dev_group_name": row["dev_group_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,
"energy_eff": row["energy_eff"],
"wall_type": row["wall_type"],
# Building specs
"first_floor_type": row["first_floor_type"],
"section_count": row["section_count"],
"elevators_passenger_count": row["elevators_passenger_count"],
"elevators_cargo_count": row["elevators_cargo_count"],
"parking_total_slots": row["parking_total_slots"],
"guest_parking_inside_count": row["guest_parking_inside_count"],
"guest_parking_outside_count": row["guest_parking_outside_count"],
"ceiling_height_m": _f(row["ceiling_height_m"]),
# Apartment summary
"finishing_variants_count": row["finishing_variants_count"],
"has_free_planning": row["has_free_planning"],
"avg_flat_area_m2": _f(row["avg_flat_area_m2"]),
# Yard
"playground_kids_count": row["playground_kids_count"],
"playground_sport_count": row["playground_sport_count"],
"has_bike_paths": row["has_bike_paths"],
"trash_areas_count": row["trash_areas_count"],
# OVZ
"has_ramp": row["has_ramp"],
"has_low_platforms": row["has_low_platforms"],
"has_wheelchair_lift": row["has_wheelchair_lift"],
# Catalog/UI
"flat_area_min": _f(row["flat_area_min"]),
"flat_area_max": _f(row["flat_area_max"]),
"price_min_rub": row["price_min_rub"],
"price_max_rub": row["price_max_rub"],
"price_per_m2_min": _f(row["price_per_m2_min"]),
"price_per_m2_max": _f(row["price_per_m2_max"]),
"parking_provision_pct": _f(row["parking_provision_pct"]),
"project_published_at": (
row["project_published_at"].isoformat() if row["project_published_at"] else None
),
"project_declaration_num": row["project_declaration_num"],
# Metro & scores
"metro_nearest_name": row["metro_nearest_name"],
"metro_nearest_walk_minutes": row["metro_nearest_walk_minutes"],
"metro_top3": metro_top3, # already jsonb → dict/list from psycopg3
"domrf_score_location": row["domrf_score_location"],
"domrf_score_transport": row["domrf_score_transport"],
"domrf_score_infrastructure": row["domrf_score_infrastructure"],
"buildings_count": int(row["buildings_count"]),
}
def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]]:
"""Per-rooms aggregation из domrf_kn_flats для объекта.
Группирует по rooms: 1/2/3/Нежилые (rooms IS NULL).
Возвращает count total, count 'free', min/max area, min/max price.
"""
rows = (
db.execute(
text(
"""
WITH latest AS (
SELECT MAX(snapshot_date) AS snap
FROM domrf_kn_flats
WHERE obj_id = :obj
)
SELECT
CASE
WHEN f.rooms IS NULL OR LOWER(f.flat_type) LIKE '%нежил%'
OR LOWER(f.flat_type) LIKE '%nonliv%' THEN 'Нежилые'
WHEN f.rooms = 0 THEN 'Студия'
WHEN f.rooms = 1 THEN '1-комн.'
WHEN f.rooms = 2 THEN '2-комн.'
WHEN f.rooms = 3 THEN '3-комн.'
ELSE (f.rooms::text || '-комн.')
END AS room_label,
COALESCE(f.rooms, -1) AS sort_key,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE LOWER(f.status) = 'free'
OR LOWER(f.status) LIKE '%свобод%')
AS free_count,
MIN(f.total_area) AS area_min,
MAX(f.total_area) AS area_max,
MIN(f.price_rub) FILTER (WHERE f.price_rub > 0)
AS price_min,
MAX(f.price_rub) FILTER (WHERE f.price_rub > 0)
AS price_max
FROM domrf_kn_flats f
CROSS JOIN latest l
WHERE f.obj_id = :obj
AND f.snapshot_date = l.snap
GROUP BY room_label, sort_key
ORDER BY sort_key
"""
),
{"obj": obj_id},
)
.mappings()
.all()
)
return [
{
"room_label": r["room_label"],
"total_count": r["total_count"],
"free_count": r["free_count"],
"area_min": _f(r["area_min"]),
"area_max": _f(r["area_max"]),
"price_min": r["price_min"],
"price_max": r["price_max"],
}
for r in rows
]
def object_obj_checks(db: Session, obj_id: int) -> list[dict[str, Any]]:
"""6 «Проверено на наш.дом.рф» checks из domrf_obj_checks (22f)."""
rows = (
db.execute(
text(
"""
SELECT check_type, passed, checked_at
FROM domrf_obj_checks
WHERE obj_id = :obj
ORDER BY check_type
"""
),
{"obj": obj_id},
)
.mappings()
.all()
)
return [
{
"check_type": r["check_type"],
"passed": r["passed"],
"checked_at": r["checked_at"].isoformat() if r["checked_at"] else None,
}
for r in rows
]
def object_documents(db: Session, obj_id: int) -> list[dict[str, Any]]:
"""PDF documents из domrf_kn_documents (22i), сортировка по doc_type + posted_at."""
rows = (
db.execute(
text(
"""
SELECT doc_type, doc_num, posted_at, file_url, size_bytes
FROM domrf_kn_documents
WHERE obj_id = :obj
ORDER BY doc_type, posted_at DESC NULLS LAST
"""
),
{"obj": obj_id},
)
.mappings()
.all()
)
return [
{
"doc_type": r["doc_type"],
"doc_num": r["doc_num"],
"posted_at": r["posted_at"].isoformat() if r["posted_at"] else None,
"file_url": r["file_url"],
"size_bytes": r["size_bytes"],
}
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.
NOTE: sparkline (velocity CTE) намеренно оставлен на domrf_kn_sale_graph.
objective_corpus_room_month не имеет obj_id — требует JOIN через
objective_complex_mapping по project_name. Это отдельная задача рефакторинга
admin-view. Данные stale (newest 2026-01), sparkline визуально OK для тренда.
"""
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(
"""
-- ВАЖНО: rosreestr агрегирует пакетные ДДУ-сделки в одну строку.
-- Например, 5 квартир по 40 м² одного покупателя → row с
-- area=200, deal_count=5. Если bucket'ить по сырой area, такая
-- запись попадает в «80+ м²» хотя реально это 5 квартир «1-к».
-- Поэтому:
-- * area_per_unit = area / deal_count (площадь одной квартиры)
-- * COUNT через SUM(deal_count) — реальное число единиц жилья
-- * Медианы взвешиваем по deal_count (PERCENTILE_DISC по разворачиванию
-- не PostgreSQL-friendly; используем PERCENTILE_CONT — приближение,
-- для редких outliers с deal_count >>1 расхождение <2%)
WITH per_unit AS (
SELECT (area / deal_count) AS area_per_unit,
price_per_sqm,
deal_count
FROM rosreestr_deals
WHERE region_code = :rc
AND doc_type = 'ДДУ'
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
-- 001 = земельные участки, 002 = нежилые помещения.
AND realestate_type_code = '002001003000'
AND area > 10
-- ВНИМАНИЕ: с 2025Q1 rosreestr резко увеличил агрегацию строк
-- (1 row = 30+ сделок, area = SUM по пакету). Фильтр по сырой
-- area отрезает 95% свежих данных. Используем только per-unit
-- фильтр (15..200 м² — реалистичный диапазон одной квартиры).
AND deal_count > 0
AND (area / deal_count) BETWEEN 15 AND 200
AND price_per_sqm BETWEEN 30000 AND 1000000
AND period_start_date >= NOW()
- (:months_window || ' months')::INTERVAL
),
bucketed AS (
SELECT CASE
WHEN area_per_unit < 30 THEN '1-Студия'
WHEN area_per_unit < 45 THEN '2-1-к'
WHEN area_per_unit < 60 THEN '3-2-к'
WHEN area_per_unit < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
area_per_unit,
price_per_sqm,
deal_count
FROM per_unit
)
SELECT bucket,
SUM(deal_count)::bigint AS deals,
SUM(area_per_unit * deal_count) / SUM(deal_count) AS area_avg,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area_per_unit) 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
objective_corpus_room_month for objects in the same район+class over last 24 mo.
Migrated from domrf_kn_sale_graph (stale since 2026-01) to
objective_corpus_room_month (updated weekly via Objective API).
objective_corpus_room_month.district matches domrf_kn_objects.district_name.
class filter uses 'class' column (Комфорт/Бизнес/Стандарт).
Returns dict {realised_per_month_median, realised_per_month_avg,
objects_count, observations}. All-None means no data → caller falls back.
"""
# Objective class naming: "Комфорт", "Бизнес", "Стандарт" — capitalised.
# domrf obj_class may differ in case; apply ILIKE for robustness.
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
params: dict[str, Any] = {"dn": district_name}
if target_class:
params["cls"] = target_class
# region_code not used — objective_corpus_room_month covers only EKB (region 66).
# district filter is sufficient for locality. If district returns no rows,
# caller falls back to rosreestr_fallback path (unchanged behaviour).
_ = region_code # retained in signature for backward compat
row = (
db.execute(
text(
f"""
WITH per_project_month AS (
SELECT project_name,
report_month,
SUM(deals_total_count) AS month_units
FROM objective_corpus_room_month crm
WHERE crm.district = :dn
{where_class}
AND crm.deals_total_count > 0
AND crm.report_month >= NOW() - INTERVAL '24 months'
GROUP BY project_name, report_month
)
SELECT
AVG(month_units) AS avg_pm,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units) AS median_pm,
COUNT(DISTINCT project_name) AS objects,
COUNT(*) AS observations
FROM per_project_month
"""
),
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+.
Мигрировано с domrf_kn_sale_graph (stale 2026-01) на
objective_corpus_room_month (обновляется еженедельно).
deals_total_count заменяет realised (DDU + DKP всего по корпусу).
Возвращает (ratio, recent_units, prior_units). None если данных мало.
"""
row = (
db.execute(
text(
"""
SELECT
SUM(crm.deals_total_count)
FILTER (WHERE crm.report_month >= DATE '2025-07-01')
AS recent,
SUM(crm.deals_total_count)
FILTER (WHERE crm.report_month BETWEEN DATE '2025-01-01'
AND DATE '2025-06-30')
AS prior
FROM objective_corpus_room_month crm
WHERE crm.district = :dn
AND crm.deals_total_count > 0
"""
),
{"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
-- floors INTEGER (Rosreestr ETL приводит к int); NULL = unknown.
-- Считаем МКД если floors ≥3 или purpose содержит «многокв».
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 _velocity_baseline_per_bucket(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
) -> dict[str, float] | None:
"""Per-bucket median velocity (units/month per ЖК) из objective_corpus_room_month.
Группирует по room_bucket → для каждого бакета вычисляет median(month_units)
по проектам района/класса за последние 24 месяца.
Маппинг room_bucket → _BUCKET_PRETTY ключи:
студия/studio/0 → '1-Студия'
1 → '2-1-к'
2 → '3-2-к'
3 → '4-3-к'
4/5+ → '5-80+ м²'
Возвращает dict {bucket_id → median velocity} только для бакетов с данными,
или None если нет данных совсем (caller переходит на rosreestr-fallback).
_ region_code retained for backward compat; objective data covers EKB only.
"""
_ = region_code
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
params: dict[str, Any] = {"dn": district_name}
if target_class:
params["cls"] = target_class
rows = (
db.execute(
text(
f"""
WITH bucket_mapped AS (
SELECT
CASE
WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0')
THEN '1-Студия'
WHEN crm.room_bucket = '1' THEN '2-1-к'
WHEN crm.room_bucket = '2' THEN '3-2-к'
WHEN crm.room_bucket = '3' THEN '4-3-к'
WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²'
ELSE NULL
END AS bucket_id,
crm.project_name,
crm.report_month,
crm.deals_total_count
FROM objective_corpus_room_month crm
WHERE crm.district = :dn
{where_class}
AND crm.deals_total_count > 0
AND crm.report_month >= NOW() - INTERVAL '24 months'
),
per_project_bucket_month AS (
SELECT bucket_id,
project_name,
report_month,
SUM(deals_total_count) AS month_units
FROM bucket_mapped
WHERE bucket_id IS NOT NULL
GROUP BY bucket_id, project_name, report_month
)
SELECT
bucket_id,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY month_units) AS median_pm,
COUNT(DISTINCT project_name) AS objects,
COUNT(*) AS observations
FROM per_project_bucket_month
GROUP BY bucket_id
"""
),
params,
)
.mappings()
.all()
)
if not rows:
return None
result: dict[str, float] = {}
for r in rows:
v = _f(r["median_pm"])
if v is not None and int(r["observations"] or 0) >= 3:
result[r["bucket_id"]] = v
return result if result else None
def _elasticity_coef(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
elasticity_window_months: int = 24,
) -> dict[str, Any]:
"""Fit log-log regression LN(deals_total_count) ~ LN(price_per_m2) on
objective_corpus_room_month observations for the same район+class.
Returns elasticity (slope), R², n.
Falls back to FALLBACK_ELASTICITY if data thin or regression weak.
Мигрировано с domrf_kn_sale_graph (stale 2026-01) на
objective_corpus_room_month (обновляется еженедельно).
Маппинг: realised → deals_total_count,
price_avg → deals_total_avg_price_thousand_rub_per_m2.
LN-масштаб цены (тыс.руб/м²) сохраняет slope relative magnitude — slope
не зависит от единиц (аддитивный сдвиг в LN пространстве).
"""
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
params: dict[str, Any] = {
"dn": district_name,
"ew": elasticity_window_months,
}
if target_class:
params["cls"] = target_class
_ = region_code # retained for backward compat; objective data covers EKB only
row = (
db.execute(
text(
f"""
WITH pts AS (
SELECT
LN(crm.deals_total_count::float8) AS y,
LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x
FROM objective_corpus_room_month crm
WHERE crm.district = :dn
{where_class}
AND crm.deals_total_count > 0
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
AND crm.report_month >= NOW() - (:ew || ' months')::interval
)
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],
elasticity_window_months: int = 24,
) -> dict[str, dict[str, Any]]:
"""Per-bucket эластичность (Tier 3): группируем objective_corpus_room_month
по room_bucket — регрессия log-log для каждой группы.
Студии vs 80+ м² реагируют на цену по-разному.
Мигрировано с domrf_kn_sale_graph + domrf_kn_flats (stale 2026-01) на
objective_corpus_room_month (обновляется еженедельно).
objective_corpus_room_month уже содержит room_bucket напрямую — нет
необходимости в MODE-агрегации domrf_kn_flats.
Маппинг room_bucket → _BUCKET_PRETTY ключи:
'студия''1-Студия'
'1''2-1-к'
'2''3-2-к'
'3''4-3-к'
'4'/'5+''5-80+ м²'
Returns: dict[bucket_pretty → {elasticity, r2, n, source}]. Если в bucket'е
меньше 30 точек или регрессия слабая (R²<0.05 либо positive slope) — берём
общую эластичность из `fallback` со source='fallback_global'.
"""
where_class = "AND LOWER(crm.class) = LOWER(:cls)" if target_class else ""
params: dict[str, Any] = {
"dn": district_name,
"ew": elasticity_window_months,
}
if target_class:
params["cls"] = target_class
_ = region_code # retained for backward compat; objective data covers EKB only
rows = (
db.execute(
text(
f"""
WITH pts AS (
SELECT
CASE
WHEN LOWER(crm.room_bucket) IN ('студия', 'studio', '0')
THEN '1-Студия'
WHEN crm.room_bucket = '1' THEN '2-1-к'
WHEN crm.room_bucket = '2' THEN '3-2-к'
WHEN crm.room_bucket = '3' THEN '4-3-к'
WHEN crm.room_bucket IN ('4', '5+') THEN '5-80+ м²'
ELSE NULL
END AS bucket,
LN(crm.deals_total_count::float8) AS y,
LN(crm.deals_total_avg_price_thousand_rub_per_m2::float8) AS x
FROM objective_corpus_room_month crm
WHERE crm.district = :dn
{where_class}
AND crm.deals_total_count > 0
AND crm.deals_total_avg_price_thousand_rub_per_m2 > 0
AND crm.report_month >= NOW() - (:ew || ' months')::interval
)
SELECT bucket,
regr_slope(y, x) AS slope,
regr_r2(y, x) AS r2,
COUNT(*)::bigint AS n
FROM pts
WHERE bucket IS NOT NULL
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 _noise_penalty_factor(db: Session, district_name: str | None) -> tuple[float, dict]:
"""Penalty к ценам исходя из плотности шумных объектов в районе.
Returns: (factor in [0.90, 1.0], breakdown dict).
Чем больше магистралей/жд/промзон — тем ниже factor (max -10%).
"""
if not district_name:
return 1.0, {}
row = (
db.execute(
text(
"""
WITH district_noise AS (
SELECT n.source_type, n.road_class, COUNT(*) AS n
FROM osm_noise_sources_ekb n
JOIN ekb_districts d ON ST_Intersects(n.geom, d.geom)
WHERE d.district_name = :dn
GROUP BY 1, 2
)
SELECT COALESCE(SUM(n), 0) AS total_sources,
COALESCE(SUM(CASE WHEN source_type = 'railway' THEN n END), 0) AS railway_n,
COALESCE(SUM(CASE WHEN source_type = 'industrial' THEN n END), 0)
AS industrial_n,
COALESCE(
SUM(CASE WHEN road_class IN ('motorway', 'trunk') THEN n END), 0
) AS magistral_n
FROM district_noise
"""
),
{"dn": district_name},
)
.mappings()
.first()
)
if not row or not row["total_sources"]:
return 1.0, {"district": district_name, "noise_sources": 0}
score = (
float(row["magistral_n"]) * 0.05
+ float(row["railway_n"]) * 0.02
+ float(row["industrial_n"]) * 0.03
)
penalty = min(0.10, max(0.0, score / 100))
factor = 1.0 - penalty
return round(factor, 4), {
"district": district_name,
"magistral_n": int(row["magistral_n"]),
"railway_n": int(row["railway_n"]),
"industrial_n": int(row["industrial_n"]),
"total_sources": int(row["total_sources"]),
"penalty_pct": round(penalty * 100, 1),
}
def _competitors_two_dim(
db: Session,
*,
region_code: int,
district_name: str,
target_class: str | None,
) -> tuple[int, int, float, str]:
"""Двумерный подсчёт активных конкурентов:
- radius_n: ЖК в радиусе 3км от центроида района
- district_only_n: ЖК в районе, но вне 3км радиуса
- total_weighted = radius_n * 1.0 + district_only_n * 0.6
Returns (radius_n, district_only_n, total_weighted, scope).
Если district_name не найден в ekb_districts — падает в старый
_active_competitors_count с total_weighted = float(competitors).
"""
# Получаем центроид района для radius-фильтра
centroid_row = (
db.execute(
text(
"""
SELECT ST_AsText(ST_Centroid(geom)) AS centroid_wkt
FROM ekb_districts
WHERE district_name = :dn
LIMIT 1
"""
),
{"dn": district_name},
)
.mappings()
.first()
)
if not centroid_row or not centroid_row["centroid_wkt"]:
# Fallback: используем старый одномерный счётчик
n, scope = _active_competitors_count(
db, region_code=region_code, district_name=district_name, target_class=target_class
)
return 0, n, float(n), scope
class_filter = "AND obj_class = :cls" if target_class else ""
params: dict[str, Any] = {
"rc": region_code,
"dn": district_name,
"centroid": centroid_row["centroid_wkt"],
}
if target_class:
params["cls"] = target_class
row = (
db.execute(
text(
f"""
WITH active AS (
SELECT DISTINCT ON (obj_id) obj_id, latitude, longitude, district_name
FROM domrf_kn_objects
WHERE region_cd = :rc
AND site_status = 'Строящиеся'
AND district_name = :dn
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
),
centroid AS (
SELECT ST_SetSRID(ST_GeomFromText(:centroid), 4326)::geography AS pt
)
SELECT
COUNT(*) FILTER (
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography,
c.pt,
3000
)
) AS radius_n,
COUNT(*) FILTER (
WHERE NOT ST_DWithin(
ST_SetSRID(ST_MakePoint(a.longitude, a.latitude), 4326)::geography,
c.pt,
3000
)
) AS district_only_n
FROM active a, centroid c
"""
),
params,
)
.mappings()
.first()
)
radius_n = int(row["radius_n"] or 0) if row else 0
district_only_n = int(row["district_only_n"] or 0) if row else 0
total_weighted = radius_n * 1.0 + district_only_n * 0.6
if total_weighted < 1.0:
# Нет конкурентов в районе — fallback к старому счётчику (регион)
n, scope = _active_competitors_count(
db, region_code=region_code, district_name=district_name, target_class=target_class
)
return 0, n, float(max(n, 1)), scope
return radius_n, district_only_n, max(total_weighted, 1.0), "district_2d"
def _bucket_success_ranking(
db: Session, district_name: str | None, target_class: str | None
) -> list[dict]:
"""Рейтинг bucket'ов по success_score из v_bucket_success_score.
Возвращает список dict {bucket, success_score, n_deals, velocity_z,
price_z, area_z}, sorted DESC by success_score. Пустой список если
данных нет или district_name не передан.
"""
if not district_name:
return []
rows = (
db.execute(
text(
"""
SELECT bucket, success_score, n_deals, velocity_z, price_z, area_z
FROM v_bucket_success_score
WHERE district_name = :dn
AND obj_class = COALESCE(:cls, 'Comfort')
ORDER BY success_score DESC
"""
),
{"dn": district_name, "cls": target_class},
)
.mappings()
.all()
)
return [
{
"bucket": r["bucket"],
"success_score": float(r["success_score"]) if r["success_score"] is not None else 0.0,
"n_deals": int(r["n_deals"] or 0),
"velocity_z": float(r["velocity_z"]) if r["velocity_z"] is not None else 0.0,
"price_z": float(r["price_z"]) if r["price_z"] is not None else 0.0,
"area_z": float(r["area_z"]) if r["area_z"] is not None else 0.0,
}
for r in rows
]
def recommend_mix(
db: Session,
*,
district_name: str,
area_total_m2: float | None = None,
target_class: str | None = None,
months_window: int = 24,
region_code: int = 66,
price_factor: float = 1.0,
target_months: int | None = None,
horizon_months: int | None = None,
cad_num: str | None = None,
) -> dict[str, Any]:
"""Rule-based квартирография recommender v3.1-v3.4.
City-wide bucket distribution from rosreestr_deals (последние N месяцев),
скорректированная на район (через ekb_districts.median_price_per_m2) и
класс (через yandex_realty_zk price-агрегаты per-class).
v3.1: noise penalty (-10% max) по osm_noise_sources_ekb
v3.2: hard-cap comparables по boundaries района
v3.3: hard-cap 24 мес + elasticity_window_months = 24
v3.4: success-driven mix из v_bucket_success_score
"""
warnings: list[str] = []
# #24 Hard-cap: данные старше 24 мес нерелевантны (ставки ЦБ, ипотека менялись)
if months_window > 24:
logger.warning("recommend_mix: months_window=%d > 24, capped to 24", months_window)
months_window = 24
elasticity_window_months = 24 # синхронизировано с share_window (issue #24)
# 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.
# Дефолт 24 мес — после загрузки rosreestr 2024Q1+Q2 у нас 27 мес истории,
# 24 мес даёт устойчивые per-bucket медианы для большинства региональных
# ЖК-классов. Если хоть в одном бакете <30 сделок — расширяем до 27 мес
# (мах доступного окна), это последний rampe перед deferring к global.
bucket_rows = _bucket_distribution(db, region_code, months_window)
effective_window = months_window
if months_window < 27 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
bucket_rows_27 = _bucket_distribution(db, region_code, 27)
if bucket_rows_27:
bucket_rows = bucket_rows_27
effective_window = 27
warnings.append(
f"Окно расширено до 27 мес: при {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 label: "objective" when data available, "rosreestr_fallback" otherwise.
# Value key kept as "sale_graph" in output for frontend backward-compat (no breaking change).
# After fix #574: per-bucket objective data (_velocity_baseline_per_bucket) is used even
# when aggregate sale_graph_vel_pm is None. velocity_source reflects aggregate source;
# per-bucket source is tracked in bucket["velocity_source"] added below.
velocity_source = "objective" 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_window_months=elasticity_window_months,
)
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,
elasticity_window_months=elasticity_window_months,
)
# 5b-1) Двумерные конкуренты (#23): radius_n (3км) + district_only_n.
# total_weighted используется как divisor в rosreestr-fallback.
competitors_radius_n, competitors_district_only_n, competitors_weighted, competitors_scope = (
_competitors_two_dim(
db,
region_code=region_code,
district_name=district_row["district_name"],
target_class=target_class_for_geo,
)
)
# Обратная совместимость: одномерный счётчик для warnings
competitors = round(competitors_weighted)
if competitors_scope == "fallback_singleton":
warnings.append(
f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}"
f" ни в регионе {region_code} — нормировка отключена (как для монополиста)."
)
elif competitors_scope not in ("district+class", "district_2d"):
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 (fix #574: per-bucket formula, realistic срок).
#
# BUG (до fix): market_vel_pm = total_deals/months/competitors_district, затем
# bucket_v = market_vel_pm × share/100 → all buckets scale proportionally →
# aggregate velocity ≡ market_vel_pm независимо от mix (slider static bug).
# При competitors_district≈1 получаем 159 кв/мес (темп РЫНКА, не проекта).
#
# FIX: per-bucket velocity вычисляется независимо для каждого бакета:
# objective path: _velocity_baseline_per_bucket → median per ЖК per room_bucket
# rosreestr fallback: bucket_deals / months / n_comp (district+class competitors)
#
# Это позволяет mix-слайдерам реально менять aggregate KPI, т.к. velocities
# студий, 1к и т.д. теперь независимые константы, не производные от share.
# Objective path: per-bucket velocities из objective_corpus_room_month
obj_per_bucket = _velocity_baseline_per_bucket(
db,
region_code=region_code,
district_name=district_row["district_name"],
target_class=target_class_for_geo,
)
# n_comp — district+class конкуренты (_active_competitors_count каскад,
# уже вычислено выше как competitors_weighted). Делим темп РЫНКА района/класса
# на число активных ЖК этого района/класса → среднерыночный темп одного проекта.
# НЕ region-wide (~442 ЖК) — это давало срок 379-1180 мес (нереалистично).
n_comp = max(round(competitors_weighted), 1) # district+class competitors, NOT region-wide
# Aggregate market_vel_pm (сохраняем для scope/output, не для bucket расчётов)
if sale_graph_vel_pm is not None:
market_vel_pm = sale_graph_vel_pm
else:
# Rosreestr fallback aggregate: district+class deals / months / n_comp =
# среднерыночный темп одного ЖК района/класса (срок ~12-24 мес).
market_vel_pm = (
(total_deals / max(effective_window, 1) / n_comp) if total_deals else 0.0
)
warnings.append(
f"Нет objective-данных для района/класса — темп по rosreestr ÷ "
f"{n_comp} активных ЖК района/класса (грубее, срок ориентировочный)."
)
# 5b-2.5) Per-bucket market velocity (fix #574).
#
# Objective path: используем per-bucket velocities из objective_corpus_room_month.
# Для бакетов без данных в objective — fallback к rosreestr per-bucket.
# Rosreestr fallback: bucket_deals_per_month / n_comp (district+class competitors).
# КРИТИЧНО: bucket_deal_counts — это MARKET deal counts (независимы от share_pct
# пользователя), поэтому per-bucket velocities — независимые константы →
# mix-слайдеры реально влияют на aggregate KPI (static-mix bug fix).
bucket_deal_counts = {r["bucket"]: int(r["deals"] or 0) for r in bucket_rows}
bucket_market_velocities: dict[str, float] = {}
for b in buckets:
bkey = b["bucket"]
bkt_id = next((k for k, v in _BUCKET_PRETTY.items() if v == bkey), bkey)
# Objective per-bucket (preferred): median units/month per ЖК в этом бакете
if obj_per_bucket and bkt_id in obj_per_bucket:
bucket_market_velocities[bkey] = obj_per_bucket[bkt_id]
else:
# Rosreestr fallback per-bucket: market bucket_deals / months / n_comp.
# n_comp — district+class competitors (НЕ region-wide ~442), иначе срок
# выходит 379-1180 мес. bucket_deal_counts — market (не user share).
raw_deals = bucket_deal_counts.get(bkt_id, 0)
bucket_market_velocities[bkey] = raw_deals / max(effective_window, 1) / n_comp
# 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)
# #22 Noise penalty: плотность шумных объектов района → штраф до -10% цены
noise_penalty, noise_breakdown = _noise_penalty_factor(db, district_row["district_name"])
# #25 Success-driven ranking из v_bucket_success_score
success_ranking = _bucket_success_ranking(db, district_row["district_name"], target_class)
# 5b-3) Per-bucket project velocity at price_factor=1.0:
# bucket_market_v = per-bucket velocity из objective или rosreestr/N_active_region.
# После fix #574: каждый бакет имеет независимую скорость
# (не производную от share) → mix-слайдеры реально меняют KPI.
# 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 (развитость района = премиум)
# и noise_penalty (шумное окружение = дисконт).
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
macro_velocity_mult = sat_factor * trend_factor
# Комбинированный ценовой коэффициент: POI-премиум × noise-дисконт
combined_price_factor = poi_factor * noise_penalty
total_units = 0
for b in buckets:
bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0)
bkt_id_for_src = next(
(k for k, v in _BUCKET_PRETTY.items() if v == b["bucket"]), b["bucket"]
)
b["velocity_source"] = (
"objective_per_bucket"
if (obj_per_bucket and bkt_id_for_src in obj_per_bucket)
else "rosreestr_fallback"
)
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-корректировка + noise penalty на цены (ВСЕ p25/median/p75)
b["price_median_per_m2"] = round(b["price_median_per_m2"] * combined_price_factor, 2)
b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * combined_price_factor, 2)
b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * combined_price_factor, 2)
b["is_top_success"] = False
if b["units_planned"] and bucket_velocity > 0:
# Revenue тоже пересчитываем после combined-correction (linear scale).
if b["revenue_planned_rub"] is not None:
b["revenue_planned_rub"] = round(
b["revenue_planned_rub"] * combined_price_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 + noise penalty.
if have_revenue:
total_revenue *= combined_price_factor
if weighted_avg_price is not None:
weighted_avg_price = round(weighted_avg_price * combined_price_factor, 2)
# #25 Success-driven mix: поднимаем долю top-success bucket'а на 10%,
# пропорционально уменьшаем остальные. Условие: success_score > 0 AND n_deals >= 30.
if success_ranking:
top = next(
(r for r in success_ranking if r["success_score"] > 0 and r["n_deals"] >= 30),
None,
)
if top:
top_bucket_name = top["bucket"]
# Найти bucket в списке по имени
top_b = next((b for b in buckets if b["bucket"] == top_bucket_name), None)
if top_b is not None:
boost = top_b["share_pct"] * 0.10 # +10%
top_b["share_pct"] = round(top_b["share_pct"] + boost, 1)
top_b["is_top_success"] = True
# Пропорционально уменьшаем остальные чтобы sum = 100
other_sum = sum(b["share_pct"] for b in buckets if b["bucket"] != top_bucket_name)
if other_sum > 0:
scale = (100.0 - top_b["share_pct"]) / other_sum
for b in buckets:
if b["bucket"] != top_bucket_name:
b["share_pct"] = round(b["share_pct"] * scale, 1)
# 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.
# #22 Hard-cap по границам: фильтруем по ST_Within чтобы исключить ЖК
# у границы района, формально в domrf по district_name, но реально за
# пределами полигона (координаты из v_complex_full). ЖК без координат
# (latitude/longitude NULL) — пропускаем через LEFT JOIN + фильтр.
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
),
vcf_dedup AS (
-- один ряд на canonical_name: берём с наибольшим cad_buildings_n
SELECT DISTINCT ON (lower(canonical_name))
lower(canonical_name) AS name_key,
cad_quarter,
latitude,
longitude,
cad_buildings_n
FROM v_complex_full
ORDER BY lower(canonical_name), cad_buildings_n DESC NULLS LAST
),
district_geom AS (
SELECT geom FROM ekb_districts WHERE district_name = :dn LIMIT 1
),
latest_obj AS (
-- domrf_kn_objects содержит ~3 snapshot'а на obj_id;
-- берём только самый свежий, иначе comparables дублируются
SELECT DISTINCT ON (obj_id) *
FROM domrf_kn_objects
WHERE region_cd = :rc
AND district_name = :dn
AND (CAST(:cls AS TEXT) IS NULL OR obj_class = :cls)
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.flat_count,
a.perc AS sold_perc,
c.cad_quarter,
c.latitude AS lat,
c.longitude AS lon,
c.cad_buildings_n AS buildings_n
FROM latest_obj 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'
LEFT JOIN vcf_dedup c ON c.name_key = lower(o.comm_name)
WHERE (
-- hard-cap по границам района: только если координаты известны И
-- точка внутри полигона. Без координат — включаем (нет данных для отсева)
c.latitude IS NULL
OR c.longitude IS NULL
OR ST_Within(
ST_SetSRID(ST_MakePoint(c.longitude, c.latitude), 4326),
(SELECT geom FROM district_geom)
)
)
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
result: dict[str, Any] = {
"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,
# fix #574: n_competitors (district+class) — знаменатель в rosreestr-fallback
"n_competitors": n_comp,
"velocity_observations": vel["observations"],
"velocity_objects": vel["objects_count"],
"competitors_count": competitors,
"competitors_scope": competitors_scope,
"competitors_radius_n": competitors_radius_n,
"competitors_district_only_n": competitors_district_only_n,
"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,
# Окна источников данных — для прозрачности и UI-tooltip.
# share_window_months — окно по rosreestr_deals для bucket-shares
# и market velocity (input months_window, может расшириться до 27).
# elasticity_window_months — синхронизировано с share_window (issue #24).
"share_window_months": effective_window,
"elasticity_window_months": elasticity_window_months,
# Noise penalty (issue #22)
"noise_penalty": noise_penalty,
"noise_breakdown": noise_breakdown,
# Success ranking (issue #25)
"success_ranking": success_ranking,
"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"]),
"cad_quarter": r["cad_quarter"],
"lat": _f(r["lat"]),
"lon": _f(r["lon"]),
"buildings_n": r["buildings_n"],
}
for r in cmp_rows
],
}
# #982 (953-A) ADDITIVE OPT-IN: horizon-aware forecast-overlay. Живой микс выше
# БАЙТ-в-БАЙТ не тронут — overlay добавляется ТОЛЬКО при заданном horizon_months
# и ТОЛЬКО под scope["forecast"] (открытый dict). КРИТИЧНО (live-endpoint safety):
# overlay СОВЕТУЮЩИЙ и НИКОГДА не должен уронить живой ответ — вся ветка в
# try/except, на сбое кладём {error, advisory} и всё равно возвращаем микс.
# Локальный импорт — избегаем потенциального import-cycle (analytics_queries ↔
# forecasting) и не утяжеляем top-level живого analytics-стека advisory-движком.
if horizon_months is not None:
try:
from app.services.forecasting.recommendation import build_forecast_overlay
result["scope"]["forecast"] = build_forecast_overlay(
db,
district=district_row["district_name"],
cad_num=cad_num,
horizon_months=horizon_months,
target_class=target_class,
market_buckets=buckets,
)
except Exception as e:
logger.exception(
"recommend_mix: forecast-overlay failed (district=%s cad_num=%s horizon=%s) "
"— живой микс возвращён без overlay",
district_row["district_name"],
cad_num,
horizon_months,
)
result["scope"]["forecast"] = {"error": str(e), "advisory": True}
return result
# ── Cadastral buildings per complex ──────────────────────────────────────────
def complex_buildings(db: Session, obj_id: int) -> list[dict[str, Any]]:
"""Список зданий из cad_buildings для данного ЖК.
Возвращает [] если ни одного здания не найдено.
"""
rows = (
db.execute(
text(
"""
SELECT cad_num, floors, area, purpose, building_name,
readable_address, ST_AsGeoJSON(geom) AS geom_geojson
FROM cad_buildings
WHERE complex_id = :obj_id
ORDER BY cad_num
"""
),
{"obj_id": obj_id},
)
.mappings()
.all()
)
import json as _json
result: list[dict[str, Any]] = []
for r in rows:
geom_raw = r["geom_geojson"]
geom: dict[str, Any] | None = None
if geom_raw:
try:
geom = _json.loads(geom_raw)
except (ValueError, TypeError):
geom = None
result.append(
{
"cad_num": r["cad_num"],
"floors": r["floors"],
"area": _f(r["area"]),
"purpose": r["purpose"],
"building_name": r["building_name"],
"readable_address": r["readable_address"],
"geom_geojson": geom,
}
)
return result