2267 lines
90 KiB
Python
2267 lines
90 KiB
Python
"""SQL queries for /api/v1/analytics endpoints.
|
||
|
||
One function per endpoint. All return plain dicts/lists ready for JSON.
|
||
Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
from sqlalchemy.orm import Session
|
||
|
||
|
||
def _f(value: Any) -> float | None:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, Decimal):
|
||
return float(value)
|
||
return value
|
||
|
||
|
||
def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT snapshot_date, rep_year, rep_month,
|
||
total_square, sold_perc, price_avg
|
||
FROM domrf_realization
|
||
WHERE region_code = :region_code
|
||
AND endpoint_type = 'total'
|
||
AND type_square = 'total'
|
||
ORDER BY snapshot_date
|
||
"""
|
||
),
|
||
{"region_code": region_code},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"snapshot_date": r["snapshot_date"].isoformat(),
|
||
"rep_year": r["rep_year"],
|
||
"rep_month": r["rep_month"],
|
||
"total_square_th_sqm": _f(r["total_square"]),
|
||
"sold_perc": _f(r["sold_perc"]),
|
||
"price_avg": _f(r["price_avg"]),
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
|
||
"""source: 'portfolio' (что строится) or 'deals' (реально покупают)."""
|
||
if source == "portfolio":
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT room_count_type, flat_count, area_sqm, percent
|
||
FROM domrf_region_aggregates
|
||
WHERE region_id = :region_id
|
||
AND snapshot_date = (
|
||
SELECT MAX(snapshot_date)
|
||
FROM domrf_region_aggregates
|
||
WHERE region_id = :region_id
|
||
)
|
||
AND room_count_type <> 'TOTAL'
|
||
ORDER BY CASE room_count_type
|
||
WHEN 'ONE' THEN 1
|
||
WHEN 'TWO' THEN 2
|
||
WHEN 'THREE' THEN 3
|
||
WHEN 'FOUR' THEN 4
|
||
END
|
||
"""
|
||
),
|
||
{"region_id": region_id},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"bucket": {
|
||
"ONE": "1-к",
|
||
"TWO": "2-к",
|
||
"THREE": "3-к",
|
||
"FOUR": "4+",
|
||
}.get(r["room_count_type"], r["room_count_type"]),
|
||
"flat_count": r["flat_count"],
|
||
"area_sqm": _f(r["area_sqm"]),
|
||
"percent": r["percent"],
|
||
"avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None,
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
# deals: bucketize по 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."""
|
||
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(
|
||
"""
|
||
-- ВАЖНО: 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
|
||
domrf_kn_sale_graph for objects in the same район+class over last 24 mo.
|
||
|
||
Returns dict {realised_per_month_median, realised_per_month_avg,
|
||
objects_count, observations}. All-None means no data → caller falls back.
|
||
"""
|
||
where_class = "AND o.obj_class = :cls" if target_class else ""
|
||
params: dict[str, Any] = {
|
||
"rc": region_code,
|
||
"dn": district_name,
|
||
}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH obj_pool AS (
|
||
SELECT o.obj_id
|
||
FROM domrf_kn_objects o
|
||
WHERE o.region_cd = :rc
|
||
AND o.district_name = :dn
|
||
{where_class}
|
||
),
|
||
sg AS (
|
||
SELECT sg.obj_id, sg.realised
|
||
FROM domrf_kn_sale_graph sg
|
||
JOIN obj_pool p ON p.obj_id = sg.obj_id
|
||
WHERE sg.type = 'apartments'
|
||
AND sg.realised IS NOT NULL
|
||
AND sg.report_month >= NOW() - INTERVAL '24 months'
|
||
)
|
||
SELECT
|
||
AVG(realised) AS avg_pm,
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY realised) AS median_pm,
|
||
COUNT(DISTINCT obj_id) AS objects,
|
||
COUNT(*) AS observations
|
||
FROM sg
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row:
|
||
return {
|
||
"realised_per_month_avg": None,
|
||
"realised_per_month_median": None,
|
||
"objects_count": 0,
|
||
"observations": 0,
|
||
}
|
||
return {
|
||
"realised_per_month_avg": _f(row["avg_pm"]),
|
||
"realised_per_month_median": _f(row["median_pm"]),
|
||
"objects_count": int(row["objects"] or 0),
|
||
"observations": int(row["observations"] or 0),
|
||
}
|
||
|
||
|
||
def _district_market_saturation(db: Session, *, district_name: str) -> tuple[float | None, int]:
|
||
"""Median sold% активных строящихся ЖК в районе. >50% = зрелый рынок
|
||
(конкуренты много продали, новый проект имеет место). <20% = свежий
|
||
(много инвентаря на продажу, сложнее пробиться).
|
||
|
||
Возвращает (median_pct, n_objects). None если <5 ЖК с perc.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY a.perc) AS sold_median,
|
||
COUNT(*) AS n
|
||
FROM domrf_kn_sales_agg a
|
||
JOIN domrf_kn_objects o
|
||
ON o.obj_id = a.obj_id
|
||
AND o.snapshot_date = a.snapshot_date
|
||
WHERE a.type = 'apartments'
|
||
AND a.perc IS NOT NULL
|
||
AND o.district_name = :dn
|
||
AND o.site_status = 'Строящиеся'
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row or (row["n"] or 0) < 5:
|
||
return None, int(row["n"] or 0) if row else 0
|
||
return _f(row["sold_median"]), int(row["n"])
|
||
|
||
|
||
def _district_velocity_trend(db: Session, *, district_name: str) -> tuple[float | None, int, int]:
|
||
"""Ratio realised: recent_6mo / prior_6mo. >1.5 — рынок горит, <0.7 —
|
||
остывает. Считаем за окно 12 мес: H1 2025 vs H2 2025+.
|
||
|
||
Возвращает (ratio, recent_units, prior_units). None если данных мало.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
SUM(sg.realised) FILTER (WHERE sg.report_month >= DATE '2025-07-01')
|
||
AS recent,
|
||
SUM(sg.realised) FILTER (WHERE sg.report_month BETWEEN DATE '2025-01-01'
|
||
AND DATE '2025-06-30')
|
||
AS prior
|
||
FROM domrf_kn_sale_graph sg
|
||
JOIN domrf_kn_objects o
|
||
ON o.obj_id = sg.obj_id
|
||
AND o.snapshot_date = sg.snapshot_date
|
||
WHERE sg.type = 'apartments'
|
||
AND o.district_name = :dn
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
recent = int(row["recent"] or 0) if row else 0
|
||
prior = int(row["prior"] or 0) if row else 0
|
||
if prior > 0 and recent > 0:
|
||
return recent / prior, recent, prior
|
||
return None, recent, prior
|
||
|
||
|
||
_POI_WEIGHTS = {
|
||
"Транспорт": 1.5,
|
||
"Метро": 2.0,
|
||
"Образование": 1.2,
|
||
"Медицина": 1.3,
|
||
"Спорт": 1.0,
|
||
"Продукты": 0.8,
|
||
"Развлечения": 0.7,
|
||
"Новостройки": 0.0, # сами ЖК — не используем как amenity
|
||
}
|
||
|
||
|
||
def _district_poi_score(db: Session, *, district_name: str) -> float | None:
|
||
"""Среднее по ЖК района: weighted POI count в радиусе 1000м.
|
||
Используем категории-веса (метро/медицина важнее, новостройки игнор).
|
||
|
||
Возвращает None если в районе <3 ЖК с POI.
|
||
"""
|
||
weights_sql = " ".join(
|
||
[f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()]
|
||
)
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH per_obj AS (
|
||
SELECT i.obj_id,
|
||
SUM(CASE {weights_sql} ELSE 0.5 END)
|
||
FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000)
|
||
AS weighted_poi
|
||
FROM domrf_kn_infrastructure i
|
||
JOIN domrf_kn_objects o
|
||
ON o.obj_id = i.obj_id
|
||
AND o.snapshot_date = i.snapshot_date
|
||
WHERE o.district_name = :dn
|
||
GROUP BY i.obj_id
|
||
)
|
||
SELECT AVG(weighted_poi) AS avg_score, COUNT(*) AS n
|
||
FROM per_obj
|
||
WHERE weighted_poi > 0
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row or (row["n"] or 0) < 3:
|
||
return None
|
||
return _f(row["avg_score"])
|
||
|
||
|
||
def _city_avg_poi_score(db: Session, *, region_code: int = 66) -> float | None:
|
||
"""Средний POI score по всему ЕКБ — для нормировки district_poi_score."""
|
||
weights_sql = " ".join(
|
||
[f"WHEN i.poi_category = '{cat}' THEN {w:.2f}" for cat, w in _POI_WEIGHTS.items()]
|
||
)
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH per_obj AS (
|
||
SELECT i.obj_id,
|
||
SUM(CASE {weights_sql} ELSE 0.5 END)
|
||
FILTER (WHERE i.distance_m IS NULL OR i.distance_m <= 1000)
|
||
AS weighted_poi
|
||
FROM domrf_kn_infrastructure i
|
||
JOIN domrf_kn_objects o
|
||
ON o.obj_id = i.obj_id
|
||
AND o.snapshot_date = i.snapshot_date
|
||
WHERE o.region_cd = :rc
|
||
AND o.district_name IS NOT NULL
|
||
GROUP BY i.obj_id
|
||
)
|
||
SELECT AVG(weighted_poi) AS avg_score
|
||
FROM per_obj
|
||
WHERE weighted_poi > 0
|
||
"""
|
||
),
|
||
{"rc": region_code},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
return _f(row["avg_score"]) if row else None
|
||
|
||
|
||
def _district_cadastre_baseline(db: Session, *, district_name: str) -> dict[str, Any]:
|
||
"""Медианная кадастровая стоимость ₽/м² жилых строений в районе через
|
||
spatial-join cad_buildings → ekb_districts_geom. Возвращает None полей,
|
||
если в районе нет cad_buildings со cost_value.
|
||
|
||
Используется как cross-check для market price из rosreestr_deals:
|
||
cadastre_vs_market_pct > +50% (рынок сильно дороже кадастра, переоценка)
|
||
или < -30% (рынок дешевле кадастра, аномалия) → warning badge на UI.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH district_geom AS (
|
||
SELECT geom
|
||
FROM ekb_districts_geom
|
||
WHERE district_name = :dn
|
||
LIMIT 1
|
||
),
|
||
buildings_in AS (
|
||
SELECT
|
||
cb.cost_value / NULLIF(cb.area, 0) AS price_per_m2
|
||
FROM cad_buildings cb
|
||
JOIN district_geom dg
|
||
ON ST_Intersects(dg.geom, cb.geom)
|
||
WHERE cb.cost_value IS NOT NULL
|
||
AND cb.area IS NOT NULL
|
||
AND cb.area >= 100
|
||
-- floors хранится как TEXT (встречаются '1-2', '2-3') —
|
||
-- считаем только чистые числа ≥3, либо purpose-fallback.
|
||
AND ((cb.floors ~ '^[0-9]+$' AND cb.floors::int >= 3)
|
||
OR cb.purpose ILIKE '%многокв%')
|
||
AND (cb.cost_value / NULLIF(cb.area, 0))
|
||
BETWEEN 5000 AND 500000
|
||
)
|
||
SELECT
|
||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2)
|
||
AS median_per_m2,
|
||
COUNT(*)::bigint AS n
|
||
FROM buildings_in
|
||
"""
|
||
),
|
||
{"dn": district_name},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row or row["n"] == 0:
|
||
return {"median_per_m2": None, "buildings_n": 0}
|
||
return {
|
||
"median_per_m2": _f(row["median_per_m2"]),
|
||
"buildings_n": int(row["n"]),
|
||
}
|
||
|
||
|
||
def _current_mortgage_rate(db: Session) -> tuple[float | None, str | None]:
|
||
"""Последняя средневзвешенная ставка ИЖК из cbr_mortgage_series.
|
||
|
||
ВАЖНО: возвращаем СРЕДНЕВЗВЕШЕННУЮ С льготами (семейная/IT/ДВ-ипотека) —
|
||
это ~7-8%. РЫНОЧНАЯ ставка без льгот в БД отсутствует (она ~20% по
|
||
публикациям ЦБ Янв 2026, но в наших cbr_mortgage_series этой серии нет).
|
||
|
||
Старый ILIKE '%ипотечн%жилищн%' случайно матчил «долю ипотечных кредитов
|
||
на ИЖС» (5.57% на ИЖС — НЕ ставка). Теперь строго matchим
|
||
'Средневзвешенная ставка по ипотечным жилищным' + 'в рублях, %'.
|
||
"""
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT value, period
|
||
FROM cbr_mortgage_series
|
||
WHERE title ILIKE 'Средневзвешенная ставка по ипотечным жилищным%'
|
||
AND title ILIKE '%в рублях, %'
|
||
AND value IS NOT NULL
|
||
AND value BETWEEN 1 AND 30 -- защита от мусорных
|
||
ORDER BY period DESC
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
if not row:
|
||
return None, None
|
||
return _f(row["value"]), row["period"]
|
||
|
||
|
||
def _active_competitors_count(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
) -> tuple[int, str]:
|
||
"""N активно строящихся ЖК для нормировки velocity. Каскадный fallback:
|
||
1) (район + класс) — самый узкий
|
||
2) (район) без класса — если первый дал <2
|
||
3) весь регион — если второй дал <2
|
||
Возвращает (count, scope_used). Min 1 чтобы не делить на 0."""
|
||
|
||
def _q(where_extras: str, params: dict[str, Any]) -> int:
|
||
n = db.execute(
|
||
text(
|
||
f"""
|
||
SELECT COUNT(*) FROM domrf_kn_objects
|
||
WHERE region_cd = :rc
|
||
AND site_status = 'Строящиеся'
|
||
{where_extras}
|
||
"""
|
||
),
|
||
params,
|
||
).scalar()
|
||
return int(n or 0)
|
||
|
||
# Tier 1: район + класс (через PostGIS-полигоны district_name)
|
||
if target_class:
|
||
n = _q(
|
||
"AND district_name = :dn AND obj_class = :cls",
|
||
{"rc": region_code, "dn": district_name, "cls": target_class},
|
||
)
|
||
if n >= 2:
|
||
return n, "district+class"
|
||
|
||
# Tier 2: район (без класса — могут быть ЖК где obj_class NULL)
|
||
n = _q(
|
||
"AND district_name = :dn",
|
||
{"rc": region_code, "dn": district_name},
|
||
)
|
||
if n >= 2:
|
||
return n, "district"
|
||
|
||
# Tier 3: весь регион (когда район по сути не покрыт скрапером)
|
||
n = _q("", {"rc": region_code})
|
||
if n >= 1:
|
||
return n, "region"
|
||
|
||
return 1, "fallback_singleton"
|
||
|
||
|
||
def _elasticity_coef(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
) -> dict[str, Any]:
|
||
"""Fit log-log regression LN(realised) ~ LN(price_avg) on sale_graph
|
||
observations for the same район+class. Returns elasticity (slope), R²,
|
||
n. Falls back to FALLBACK_ELASTICITY if data thin or regression weak."""
|
||
where_class = "AND o.obj_class = :cls" if target_class else ""
|
||
params: dict[str, Any] = {"rc": region_code, "dn": district_name}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH obj_pool AS (
|
||
SELECT o.obj_id
|
||
FROM domrf_kn_objects o
|
||
WHERE o.region_cd = :rc
|
||
AND o.district_name = :dn
|
||
{where_class}
|
||
),
|
||
pts AS (
|
||
SELECT LN(sg.realised)::float8 AS y,
|
||
LN(sg.price_avg)::float8 AS x
|
||
FROM domrf_kn_sale_graph sg
|
||
JOIN obj_pool p ON p.obj_id = sg.obj_id
|
||
WHERE sg.type = 'apartments'
|
||
AND sg.realised IS NOT NULL AND sg.realised > 0
|
||
AND sg.price_avg IS NOT NULL AND sg.price_avg > 0
|
||
AND sg.report_month >= NOW() - INTERVAL '36 months'
|
||
)
|
||
SELECT
|
||
regr_slope(y, x) AS slope,
|
||
regr_r2(y, x) AS r2,
|
||
COUNT(*) AS n
|
||
FROM pts
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
n = int(row["n"]) if row and row["n"] is not None else 0
|
||
slope = _f(row["slope"]) if row else None
|
||
r2 = _f(row["r2"]) if row else None
|
||
if n >= 30 and slope is not None and r2 is not None and r2 >= 0.1 and slope < 0:
|
||
return {
|
||
"elasticity": round(slope, 4),
|
||
"r2": round(r2, 4),
|
||
"n": n,
|
||
"source": "regression",
|
||
}
|
||
return {
|
||
"elasticity": FALLBACK_ELASTICITY,
|
||
"r2": r2 or 0.0,
|
||
"n": n,
|
||
"source": "fallback",
|
||
}
|
||
|
||
|
||
def _elasticity_per_bucket_coef(
|
||
db: Session,
|
||
*,
|
||
region_code: int,
|
||
district_name: str,
|
||
target_class: str | None,
|
||
fallback: dict[str, Any],
|
||
) -> dict[str, dict[str, Any]]:
|
||
"""Per-bucket эластичность (Tier 3): группируем sale_graph-наблюдения по
|
||
«доминирующему bucket» каждого ЖК (mode total_area из domrf_kn_flats),
|
||
регрессия log-log для каждой группы. Студии vs 80+ м² реагируют на цену
|
||
по-разному.
|
||
|
||
Returns: dict[bucket_pretty → {elasticity, r2, n, source}]. Если в bucket'е
|
||
меньше 30 точек или регрессия слабая (R²<0.05 либо positive slope) — берём
|
||
общую эластичность из `fallback` со source='fallback_global'.
|
||
"""
|
||
where_class = "AND o.obj_class = :cls" if target_class else ""
|
||
params: dict[str, Any] = {"rc": region_code, "dn": district_name}
|
||
if target_class:
|
||
params["cls"] = target_class
|
||
rows = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH obj_pool AS (
|
||
SELECT o.obj_id
|
||
FROM domrf_kn_objects o
|
||
WHERE o.region_cd = :rc
|
||
AND o.district_name = :dn
|
||
{where_class}
|
||
),
|
||
obj_bucket AS (
|
||
-- Доминирующий bucket каждого ЖК = MODE по семантике
|
||
-- квартиры (is_studio + rooms). Раньше брали медиану
|
||
-- total_area — ЖК с mix studios+3к попадали в bucket
|
||
-- "1-к" как среднее, что искажало эластичность. MODE
|
||
-- по типу квартиры сохраняет семантику: ЖК с mix даёт
|
||
-- bucket = самый массовый тип.
|
||
--
|
||
-- Fallback на total_area для строк с NULL rooms (быват
|
||
-- в kn-API для нестандартных квартир).
|
||
SELECT
|
||
f.obj_id,
|
||
MODE() WITHIN GROUP (ORDER BY (
|
||
CASE
|
||
WHEN f.is_studio = true THEN '1-Студия'
|
||
WHEN f.rooms = 1 THEN '2-1-к'
|
||
WHEN f.rooms = 2 THEN '3-2-к'
|
||
WHEN f.rooms = 3 THEN '4-3-к'
|
||
WHEN f.rooms >= 4 THEN '5-80+ м²'
|
||
WHEN f.total_area < 30 THEN '1-Студия'
|
||
WHEN f.total_area < 45 THEN '2-1-к'
|
||
WHEN f.total_area < 60 THEN '3-2-к'
|
||
WHEN f.total_area < 80 THEN '4-3-к'
|
||
ELSE '5-80+ м²'
|
||
END
|
||
)) AS bucket
|
||
FROM domrf_kn_flats f
|
||
JOIN obj_pool p ON p.obj_id = f.obj_id
|
||
WHERE f.total_area IS NOT NULL
|
||
AND f.total_area BETWEEN 15 AND 200
|
||
GROUP BY f.obj_id
|
||
),
|
||
pts AS (
|
||
SELECT
|
||
ob.bucket,
|
||
LN(sg.realised)::float8 AS y,
|
||
LN(sg.price_avg)::float8 AS x
|
||
FROM domrf_kn_sale_graph sg
|
||
JOIN obj_bucket ob ON ob.obj_id = sg.obj_id
|
||
WHERE sg.type = 'apartments'
|
||
AND sg.realised IS NOT NULL AND sg.realised > 0
|
||
AND sg.price_avg IS NOT NULL AND sg.price_avg > 0
|
||
AND sg.report_month >= NOW() - INTERVAL '36 months'
|
||
)
|
||
SELECT bucket,
|
||
regr_slope(y, x) AS slope,
|
||
regr_r2(y, x) AS r2,
|
||
COUNT(*)::bigint AS n
|
||
FROM pts
|
||
GROUP BY bucket
|
||
"""
|
||
),
|
||
params,
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
out: dict[str, dict[str, Any]] = {}
|
||
fallback_e = float(fallback["elasticity"])
|
||
by_bucket = {r["bucket"]: r for r in rows}
|
||
for bucket_id, bucket_pretty in _BUCKET_PRETTY.items():
|
||
r = by_bucket.get(bucket_id)
|
||
n_b = int(r["n"]) if r and r["n"] is not None else 0
|
||
slope = _f(r["slope"]) if r else None
|
||
r2 = _f(r["r2"]) if r else None
|
||
if n_b >= 30 and slope is not None and r2 is not None and r2 >= 0.05 and slope < 0:
|
||
out[bucket_pretty] = {
|
||
"elasticity": round(slope, 4),
|
||
"r2": round(r2, 4),
|
||
"n": n_b,
|
||
"source": "regression",
|
||
}
|
||
else:
|
||
out[bucket_pretty] = {
|
||
"elasticity": fallback_e,
|
||
"r2": round(r2, 4) if r2 is not None else 0.0,
|
||
"n": n_b,
|
||
"source": "fallback_global",
|
||
}
|
||
return out
|
||
|
||
|
||
def recommend_mix(
|
||
db: Session,
|
||
*,
|
||
district_name: str,
|
||
area_total_m2: float | None = None,
|
||
target_class: str | None = None,
|
||
months_window: int = 24,
|
||
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.
|
||
# Дефолт 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 = "sale_graph" if sale_graph_vel_pm is not None else "rosreestr_fallback"
|
||
|
||
elast = _elasticity_coef(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
)
|
||
elasticity = elast["elasticity"]
|
||
if elast["source"] == "fallback":
|
||
warnings.append(
|
||
f"Эластичность цена↔темп взята по умолчанию ({elasticity})"
|
||
f" — sale_graph даёт n={elast['n']}, R²={round(elast['r2'], 2)}"
|
||
" (недостаточно для регрессии)."
|
||
)
|
||
|
||
# Tier 3: per-bucket эластичность. Регрессия sale_graph по
|
||
# «доминирующему bucket» каждого ЖК. Если для bucket'а данных мало —
|
||
# подставляем общую elasticity. Малые сегменты (1-2 студии в районе)
|
||
# таким образом не выкидываются — используем общую кривую.
|
||
elast_per_bucket = _elasticity_per_bucket_coef(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
fallback=elast,
|
||
)
|
||
|
||
# 5b-1) N активных конкурентов с каскадным fallback (район+класс →
|
||
# район → регион). Используется как divisor в rosreestr-fallback ветке.
|
||
competitors, competitors_scope = _active_competitors_count(
|
||
db,
|
||
region_code=region_code,
|
||
district_name=district_row["district_name"],
|
||
target_class=target_class_for_geo,
|
||
)
|
||
if competitors_scope == "fallback_singleton":
|
||
warnings.append(
|
||
f"Не нашлось активно строящихся ЖК ни в районе {district_row['district_name']}"
|
||
f" ни в регионе {region_code} — нормировка отключена (как для монополиста)."
|
||
)
|
||
elif competitors_scope != "district+class":
|
||
# Информативное сообщение о расширении scope при недостатке локальных данных.
|
||
scope_label = {
|
||
"district": f"районе {district_row['district_name']} (без класса)",
|
||
"region": f"регионе {region_code} (вне района)",
|
||
}.get(competitors_scope, competitors_scope)
|
||
warnings.append(
|
||
f"Конкурентов класса '{target_class or '*'}' в районе мало —"
|
||
f" нормировка по {competitors} ЖК в {scope_label}."
|
||
)
|
||
|
||
# 5b-2) market_vel_pm = «что продаёт ОДИН активный ЖК района за месяц».
|
||
# ИСТОЧНИК ИСТИНЫ — sale_graph (median realised per ЖК). При отсутствии —
|
||
# rosreestr-fallback: city-wide deals/mo / N_competitors → per-ЖК proxy.
|
||
# Это критично: per-ЖК baseline должен иметь правильную размерность
|
||
# (~3-7 кв/мес для ЕКБ ЖК), иначе months_to_sellout получается
|
||
# нереалистично коротким.
|
||
if sale_graph_vel_pm is not None:
|
||
market_vel_pm = sale_graph_vel_pm
|
||
else:
|
||
warnings.append(
|
||
"Нет sale_graph данных для этого района и класса —"
|
||
" темп считается по rosreestr-сделкам ÷ конкуренты (грубее)."
|
||
)
|
||
market_vel_pm = (
|
||
(total_deals / max(effective_window, 1) / max(competitors, 1))
|
||
if total_deals and competitors
|
||
else 0.0
|
||
)
|
||
|
||
# 5b-2.5) Per-bucket market velocity = market_vel_pm × share / 100.
|
||
# Аллоцируем единый per-ЖК baseline на размерные сегменты по shares
|
||
# (одинаковая модель для sale_graph и rosreestr_fallback). Студии/1к
|
||
# получат больший абсолютный темп если их share высокая в районе.
|
||
bucket_market_velocities = {
|
||
b["bucket"]: market_vel_pm * (b["share_pct"] / 100.0) for b in buckets
|
||
}
|
||
|
||
# 5b-2.5) Дополнительные district-specific signals (Tier 2):
|
||
# sat_factor — насколько зрелый рынок района (median sold% активных
|
||
# ЖК). >50% = зрелый, новый проект имеет место, +bonus.
|
||
# <20% = свежий, много инвентаря, -penalty.
|
||
# trend_factor — recent_6mo / prior_6mo realised. Clamp 0.7..2.0 чтобы
|
||
# экстремум не разрушал расчёты.
|
||
# poi_factor — weighted POI density района / city avg. ±5% на цены.
|
||
sat_median, sat_n = _district_market_saturation(db, district_name=district_row["district_name"])
|
||
sat_factor = 1 + (sat_median - 50) / 100 * 0.3 if sat_median is not None else 1.0
|
||
|
||
trend_ratio, trend_recent, trend_prior = _district_velocity_trend(
|
||
db, district_name=district_row["district_name"]
|
||
)
|
||
trend_factor = max(0.7, min(2.0, trend_ratio)) if trend_ratio else 1.0
|
||
|
||
poi_score = _district_poi_score(db, district_name=district_row["district_name"])
|
||
city_avg_poi = _city_avg_poi_score(db, region_code=region_code)
|
||
|
||
# Cadastre cross-check: медианная кадастровая стоимость ₽/м² района через
|
||
# cad_buildings → ekb_districts spatial-join. Аномалии (рынок vs кадастр)
|
||
# выводятся как warning-цена в RecommendVelocityPanel.
|
||
cadastre = _district_cadastre_baseline(db, district_name=district_row["district_name"])
|
||
poi_factor = (
|
||
1 + (poi_score - city_avg_poi) / max(city_avg_poi, 1) * 0.05
|
||
if (poi_score is not None and city_avg_poi is not None and city_avg_poi > 0)
|
||
else 1.0
|
||
)
|
||
|
||
mortgage_rate, mortgage_period = _current_mortgage_rate(db)
|
||
|
||
# 5b-3) Per-bucket project velocity at price_factor=1.0:
|
||
# bucket_market_v = market_vel_pm × bucket.share/100 — доля per-ЖК
|
||
# темпа, аллоцированная на размерный сегмент.
|
||
# market_vel_pm УЖЕ per-ЖК (median sale_graph либо
|
||
# rosreestr/N_competitors), доп. нормировка не нужна.
|
||
# project_velocity = bucket_market_v × sat_factor × trend_factor
|
||
# sat — зрелый рынок ускоряет; trend — текущая
|
||
# динамика (горит/остывает).
|
||
# adjusted = project_velocity × price_factor^elasticity
|
||
# months_to_sellout = units_planned / adjusted
|
||
# Цена тоже корректируется на poi_factor (развитость района = премиум).
|
||
pf_pow = price_factor**elasticity if price_factor > 0 else 1.0
|
||
macro_velocity_mult = sat_factor * trend_factor
|
||
total_units = 0
|
||
for b in buckets:
|
||
bucket_market_v = bucket_market_velocities.get(b["bucket"], 0.0)
|
||
bucket_velocity = round(bucket_market_v * macro_velocity_mult, 4)
|
||
b["velocity_per_month"] = bucket_velocity
|
||
# Per-bucket эластичность: ключ — pretty-имя (b["bucket"] уже pretty).
|
||
be = elast_per_bucket.get(b["bucket"]) or {}
|
||
bucket_elasticity = float(be.get("elasticity", elasticity))
|
||
bucket_pf_pow = price_factor**bucket_elasticity if price_factor > 0 else 1.0
|
||
b["elasticity"] = bucket_elasticity
|
||
b["elasticity_r2"] = be.get("r2", 0.0)
|
||
b["elasticity_n"] = be.get("n", 0)
|
||
b["elasticity_source"] = be.get("source", "fallback_global")
|
||
# POI-корректировка на цену (на ВСЕ p25/median/p75)
|
||
b["price_median_per_m2"] = round(b["price_median_per_m2"] * poi_factor, 2)
|
||
b["price_p25_per_m2"] = round(b["price_p25_per_m2"] * poi_factor, 2)
|
||
b["price_p75_per_m2"] = round(b["price_p75_per_m2"] * poi_factor, 2)
|
||
if b["units_planned"] and bucket_velocity > 0:
|
||
# Revenue тоже пересчитываем после POI-correction (linear scale).
|
||
if b["revenue_planned_rub"] is not None:
|
||
b["revenue_planned_rub"] = round(b["revenue_planned_rub"] * poi_factor, 2)
|
||
adjusted_velocity = bucket_velocity * bucket_pf_pow
|
||
b["months_to_sellout"] = (
|
||
round(b["units_planned"] / adjusted_velocity, 1) if adjusted_velocity > 0 else None
|
||
)
|
||
total_units += b["units_planned"]
|
||
else:
|
||
b["months_to_sellout"] = None
|
||
# Итог revenue + weighted_avg_price после POI-correction (linear scale).
|
||
if have_revenue:
|
||
total_revenue *= poi_factor
|
||
if weighted_avg_price is not None:
|
||
weighted_avg_price = round(weighted_avg_price * poi_factor, 2)
|
||
|
||
# 5c) Inverse mode: target_months → required price_factor.
|
||
# Tier 3: используем weighted-by-units эластичность (per-bucket эластичности
|
||
# → агрегатная только когда нужна одна цифра). При smooth-buckets разница
|
||
# с глобальной невелика, но если bucket-mix сильно перекошен в одну сторону —
|
||
# weighted-эластичность точнее отражает поведение портфеля.
|
||
required_price_factor: float | None = None
|
||
weighted_elasticity = elasticity
|
||
if total_units > 0:
|
||
weighted_elasticity = (
|
||
sum(
|
||
(b.get("elasticity") or elasticity) * (b.get("units_planned") or 0) for b in buckets
|
||
)
|
||
/ total_units
|
||
)
|
||
if target_months and total_units > 0:
|
||
base_total_velocity = sum(b["velocity_per_month"] or 0 for b in buckets)
|
||
if base_total_velocity > 0 and weighted_elasticity != 0:
|
||
required_velocity = total_units / target_months
|
||
ratio = required_velocity / base_total_velocity
|
||
try:
|
||
required_price_factor = round(ratio ** (1.0 / weighted_elasticity), 4)
|
||
except Exception:
|
||
required_price_factor = None
|
||
if required_price_factor and required_price_factor < 0.7:
|
||
warnings.append(
|
||
f"Целевой срок {target_months} мес требует скидки"
|
||
f" >{round((1 - required_price_factor) * 100)}% — рассмотри"
|
||
" сдвиг ассортимента в сторону ликвидных бакетов."
|
||
)
|
||
|
||
# 5d) Liquidity score (0-100): % units sold within 24 months.
|
||
liquidity_24mo: float | None = None
|
||
if total_units > 0:
|
||
sold_24mo = 0.0
|
||
for b in buckets:
|
||
mts = b["months_to_sellout"]
|
||
up = b["units_planned"] or 0
|
||
if up <= 0 or mts is None or mts <= 0:
|
||
continue
|
||
frac = min(1.0, 24.0 / mts)
|
||
sold_24mo += frac * up
|
||
liquidity_24mo = round(sold_24mo / total_units * 100, 1)
|
||
|
||
# 5e) Aggregate KPIs. Total months_to_sellout считаем как сумму
|
||
# bucket-уровневых adjusted velocities (каждая со своим pf_pow по своей
|
||
# эластичности) — иначе перекос в bucket-mix искажает агрегат.
|
||
months_to_sellout_total: float | None = None
|
||
base_total_v = sum(b["velocity_per_month"] or 0 for b in buckets)
|
||
adjusted_total_v = 0.0
|
||
for b in buckets:
|
||
v = b.get("velocity_per_month") or 0
|
||
be = b.get("elasticity")
|
||
bpf = price_factor**be if (be is not None and price_factor > 0) else pf_pow
|
||
adjusted_total_v += v * bpf
|
||
if total_units > 0 and adjusted_total_v > 0:
|
||
months_to_sellout_total = round(total_units / adjusted_total_v, 1)
|
||
avg_ticket = (
|
||
round(total_revenue / total_units, 2) if (have_revenue and total_units > 0) else None
|
||
)
|
||
|
||
# 6) Comparable ЖК — same district (parsed from addr) and class
|
||
cmp_rows = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH latest_agg AS (
|
||
SELECT obj_id, MAX(snapshot_date) AS snap
|
||
FROM domrf_kn_sales_agg
|
||
WHERE type = 'apartments'
|
||
GROUP BY obj_id
|
||
)
|
||
SELECT o.obj_id, o.comm_name, o.dev_name, o.obj_class, o.flat_count,
|
||
a.perc AS sold_perc
|
||
FROM domrf_kn_objects o
|
||
LEFT JOIN latest_agg la ON la.obj_id = o.obj_id
|
||
LEFT JOIN domrf_kn_sales_agg a
|
||
ON a.obj_id = la.obj_id
|
||
AND a.snapshot_date = la.snap
|
||
AND a.type = 'apartments'
|
||
WHERE o.region_cd = :rc
|
||
AND o.district_name = :dn
|
||
AND (CAST(:cls AS TEXT) IS NULL OR o.obj_class = :cls)
|
||
ORDER BY o.flat_count DESC NULLS LAST
|
||
LIMIT 5
|
||
"""
|
||
),
|
||
{
|
||
"rc": region_code,
|
||
"dn": district_row["district_name"],
|
||
"cls": target_class_for_geo,
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
|
||
# 7) Headline для CEO — одна строка с тремя главными цифрами
|
||
headline_parts: list[str] = []
|
||
if have_revenue:
|
||
headline_parts.append(f"{round(total_revenue / 1_000_000, 1)} млн ₽")
|
||
if months_to_sellout_total:
|
||
headline_parts.append(f"за ~{months_to_sellout_total:.1f} мес")
|
||
if avg_ticket:
|
||
headline_parts.append(f"ср. чек {round(avg_ticket / 1_000_000, 1)} М ₽")
|
||
if base_total_v > 0:
|
||
# Tempo = sum bucket-adjusted velocities (каждая со своим pf_pow по своей
|
||
# эластичности). Это согласовано с months_to_sellout_total выше.
|
||
tempo = adjusted_total_v if adjusted_total_v > 0 else base_total_v * pf_pow
|
||
headline_parts.append(
|
||
f"темп {tempo:.2f} кв/мес" if tempo < 1 else f"темп {tempo:.1f} кв/мес"
|
||
)
|
||
if liquidity_24mo is not None:
|
||
headline_parts.append(f"ликвидность {liquidity_24mo:.0f}/100")
|
||
headline = " · ".join(headline_parts) if headline_parts else None
|
||
|
||
return {
|
||
"scope": {
|
||
"district": district_row["district_name"],
|
||
"district_zk_count": district_row["zk_count"],
|
||
"district_median_price_per_m2": district_median,
|
||
"district_factor": round(district_factor, 4),
|
||
"class_multiplier": round(class_multiplier, 4),
|
||
"class_multiplier_source": class_multiplier_source,
|
||
"target_class": target_class,
|
||
"months_window": months_window,
|
||
"effective_window_months": effective_window,
|
||
"region_code": region_code,
|
||
"total_deals": total_deals if bucket_rows else 0,
|
||
"market_velocity_per_month": (
|
||
round(market_vel_pm, 3) if market_vel_pm is not None else None
|
||
),
|
||
"velocity_source": velocity_source,
|
||
"velocity_observations": vel["observations"],
|
||
"velocity_objects": vel["objects_count"],
|
||
"competitors_count": competitors,
|
||
"competitors_scope": competitors_scope,
|
||
"saturation_median": sat_median,
|
||
"saturation_n": sat_n,
|
||
"sat_factor": round(sat_factor, 4),
|
||
"velocity_trend_ratio": (round(trend_ratio, 2) if trend_ratio is not None else None),
|
||
"trend_recent_units": trend_recent,
|
||
"trend_prior_units": trend_prior,
|
||
"trend_factor": round(trend_factor, 4),
|
||
"poi_score": round(poi_score, 1) if poi_score is not None else None,
|
||
"poi_score_city_avg": (round(city_avg_poi, 1) if city_avg_poi is not None else None),
|
||
"poi_factor": round(poi_factor, 4),
|
||
"mortgage_rate_pct": mortgage_rate,
|
||
"mortgage_rate_period": mortgage_period,
|
||
"elasticity": elasticity,
|
||
"elasticity_r2": elast["r2"],
|
||
"elasticity_n": elast["n"],
|
||
"elasticity_source": elast["source"],
|
||
"elasticity_weighted": (round(weighted_elasticity, 4) if total_units > 0 else None),
|
||
"elasticity_per_bucket": elast_per_bucket,
|
||
# Окна источников данных — для прозрачности и UI-tooltip:
|
||
# share_window_months — окно по rosreestr_deals для bucket-shares
|
||
# и market velocity (input months_window, может расшириться до 27).
|
||
# elasticity_window_months — окно по domrf_kn_sale_graph (фиксировано
|
||
# 36 мес — sale_graph есть с 2023г, шире окно даёт устойчивее регрессию).
|
||
"share_window_months": effective_window,
|
||
"elasticity_window_months": 36,
|
||
"cadastre_median_per_m2": (
|
||
round(cadastre["median_per_m2"], 0)
|
||
if cadastre["median_per_m2"] is not None
|
||
else None
|
||
),
|
||
"cadastre_buildings_n": cadastre["buildings_n"],
|
||
"cadastre_vs_market_pct": (
|
||
round(
|
||
(district_median - cadastre["median_per_m2"])
|
||
/ cadastre["median_per_m2"]
|
||
* 100.0,
|
||
1,
|
||
)
|
||
if (cadastre["median_per_m2"] and cadastre["median_per_m2"] > 0 and district_median)
|
||
else None
|
||
),
|
||
"price_factor_applied": round(price_factor, 4),
|
||
"required_price_factor": required_price_factor,
|
||
"target_months": target_months,
|
||
"data_caveat": (
|
||
"MVP: bucket-распределение город-wide (регион 66). Район влияет"
|
||
" только на ценовой коэффициент. v2 добавит per-district demand"
|
||
" при заведении PostGIS-полигонов."
|
||
),
|
||
},
|
||
"buckets": buckets,
|
||
"summary": {
|
||
"total_revenue_rub": round(total_revenue, 2) if have_revenue else None,
|
||
"weighted_avg_price_per_m2": weighted_avg_price,
|
||
"total_units_planned": total_units if total_units > 0 else None,
|
||
"months_to_sellout_total": months_to_sellout_total,
|
||
"avg_ticket_rub": avg_ticket,
|
||
"liquidity_score_24mo": liquidity_24mo,
|
||
"headline": headline,
|
||
"warnings": warnings,
|
||
},
|
||
"comparables": [
|
||
{
|
||
"obj_id": r["obj_id"],
|
||
"comm_name": r["comm_name"],
|
||
"dev_name": r["dev_name"],
|
||
"obj_class": r["obj_class"],
|
||
"flat_count": r["flat_count"],
|
||
"sold_perc": _f(r["sold_perc"]),
|
||
}
|
||
for r in cmp_rows
|
||
],
|
||
}
|
||
|
||
|
||
# ── 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
|