add predict

This commit is contained in:
lekss361 2026-04-27 21:28:02 +03:00
parent 12b1eb8169
commit ecc0dbafd5
3 changed files with 360 additions and 0 deletions

View file

@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.schemas.recommend import RecommendMixInput, RecommendMixOutput
from app.services import analytics_queries as q
router = APIRouter()
@ -180,3 +181,26 @@ def prinzip_objects(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, A
# Funnel-эндпойнты (CRM-данные) перенесены в backend/app/api/v1/admin_leads.py:
# /api/v1/admin/leads/funnel/{monthly,by-source,by-object} с X-Admin-Token.
# ---- Recommender (Уровень 1, rule-based) -----------------------------------
@router.post("/recommend/mix", response_model=RecommendMixOutput)
def recommend_mix(
payload: RecommendMixInput,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Rule-based квартирография для участка в указанном районе ЕКБ.
На вход district_name (8 районов ЕКБ), опционально area_total_m2 и
target_class. На выход распределение по 5 бакетам комнатности с
ожидаемыми ценами/площадями/выручкой и список comparable ЖК.
"""
return q.recommend_mix(
db,
district_name=payload.district_name,
area_total_m2=payload.area_total_m2,
target_class=payload.target_class,
months_window=payload.months_window,
)

View file

@ -0,0 +1,46 @@
"""IO contracts for the rule-based квартирография recommender.
POST /api/v1/analytics/recommend/mix
"""
from typing import Any, Literal
from pydantic import BaseModel, Field
ClassLiteral = Literal["Comfort", "Comfort+", "Business", "Elite"]
class RecommendMixInput(BaseModel):
district_name: str = Field(..., min_length=2, max_length=80)
area_total_m2: float | None = Field(default=None, ge=100, le=500_000)
target_class: ClassLiteral | None = None
months_window: int = Field(default=12, ge=3, le=36)
class RecommendBucket(BaseModel):
bucket: str
share_pct: float
deal_count: int
area_avg_m2: float
area_median_m2: float
price_median_per_m2: float
price_p25_per_m2: float
price_p75_per_m2: float
units_planned: int | None = None
revenue_planned_rub: float | None = None
class RecommendComparable(BaseModel):
obj_id: int
comm_name: str | None = None
dev_name: str | None = None
obj_class: str | None = None
flat_count: int | None = None
sold_perc: float | None = None
class RecommendMixOutput(BaseModel):
scope: dict[str, Any]
buckets: list[RecommendBucket]
summary: dict[str, Any]
comparables: list[RecommendComparable]

View file

@ -955,3 +955,293 @@ def prinzip_objects_with_velocity(db: Session) -> list[dict[str, Any]]:
}
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+ м²",
}
def recommend_mix(
db: Session,
*,
district_name: str,
area_total_m2: float | None = None,
target_class: str | None = None,
months_window: int = 12,
region_code: int = 66,
) -> 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
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 from yandex_realty_zk price ranges (price_from)
class_multiplier = 1.0
if target_class:
cls_row = (
db.execute(
text(
"""
SELECT
AVG(price_from) FILTER (WHERE obj_class = :cls) AS class_avg,
AVG(price_from) AS overall_avg
FROM yandex_realty_zk
WHERE price_from IS NOT NULL AND price_from > 0
"""
),
{"cls": target_class},
)
.mappings()
.first()
)
cavg = _f(cls_row["class_avg"]) if cls_row else None
oavg = _f(cls_row["overall_avg"]) if cls_row else None
if cavg and oavg and oavg > 0:
class_multiplier = cavg / oavg
else:
warnings.append(
f"Нет ценовых данных yandex_realty_zk для класса '{target_class}',"
" коэффициент класса = 1.0"
)
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months
bucket_rows = (
db.execute(
text(
"""
WITH bucketed AS (
SELECT CASE
WHEN area < 30 THEN '1-Студия'
WHEN area < 45 THEN '2-1-к'
WHEN area < 60 THEN '3-2-к'
WHEN area < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
area,
price_per_sqm
FROM rosreestr_deals
WHERE region_code = :rc
AND doc_type = 'ДДУ'
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
-- 001 = земельные участки, 002 = нежилые помещения.
AND realestate_type_code = '002001003000'
AND area > 10
AND area <= 200 -- отсечь выбросы (коммерческие площади)
AND price_per_sqm BETWEEN 30000 AND 1000000
AND period_start_date >= NOW()
- (:months_window || ' months')::INTERVAL
)
SELECT bucket,
COUNT(*)::bigint AS deals,
AVG(area) AS area_avg,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median,
PERCENTILE_CONT(0.5) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_median,
PERCENTILE_CONT(0.25) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_p25,
PERCENTILE_CONT(0.75) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_p75
FROM bucketed
GROUP BY bucket
ORDER BY bucket
"""
),
{"rc": region_code, "months_window": months_window},
)
.mappings()
.all()
)
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" за {months_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
# 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.addr ILIKE '%' || :dn || '%'
AND (:cls::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},
)
.mappings()
.all()
)
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),
"target_class": target_class,
"months_window": months_window,
"region_code": region_code,
"total_deals": total_deals if bucket_rows else 0,
"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,
"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
],
}