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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
from app.schemas.recommend import RecommendMixInput, RecommendMixOutput
|
||||||
from app.services import analytics_queries as q
|
from app.services import analytics_queries as q
|
||||||
|
|
||||||
router = APIRouter()
|
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:
|
# Funnel-эндпойнты (CRM-данные) перенесены в backend/app/api/v1/admin_leads.py:
|
||||||
# /api/v1/admin/leads/funnel/{monthly,by-source,by-object} с X-Admin-Token.
|
# /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
|
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