add predict
This commit is contained in:
parent
12b1eb8169
commit
ecc0dbafd5
3 changed files with 360 additions and 0 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
46
backend/app/schemas/recommend.py
Normal file
46
backend/app/schemas/recommend.py
Normal 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]
|
||||
|
|
@ -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
|
||||
],
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue