gendesign/backend/app/services/site_finder/velocity.py
lekss361 62836d9740 feat(site-finder): D2 velocity-score from domrf_kn_sale_graph (#34 sub-PR 1/2)
compute_velocity service queries competitor sales в радиусе 3км:
- ST_MakePoint(longitude, latitude) — domrf_kn_objects не имеет geom column
- JOIN domrf_kn_sale_graph за 6 мес (area_sq primary, realised*45 fallback)
- Normalize vs ЕКБ-wide median → velocity_score 0..1
- confidence: high/medium/low (competitors_count + months_observed)
- Top 5 sample competitors для UI

Integration: analyze_parcel.response['velocity'] top-level field.

Schema corrections vs spec:
- obj_name → comm_name
- region_code → region_cd
- contracted (INT) → area_sq (м²)

Tests: 102/102 pass.
Vault: Module_Velocity_Service.md NEW.

Closes #144 (sub-PR 2 frontend закроет #34)
2026-05-15 01:19:40 +03:00

318 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Velocity-score — темп продаж конкурентов вокруг участка.
Per #34 D2: утилизация domrf_kn_sale_graph (15876 строк).
Главный demand-сигнал «продастся ли» — среднемесячный объём продаж
конкурирующих ЖК в радиусе radius_km от участка, нормированный к
ЕКБ-медиане по region_cd=66.
Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd),
domrf_kn_sale_graph (obj_id, report_month, area_sq, realised, type).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Fallback если в БД нет данных за окно months_window.
# Эмпирика по ЕКБ: ~4 500 м²/мес на один ЖК (apartments, 2024-2025).
_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 4500.0
@dataclass(frozen=True)
class VelocityResult:
"""Результат расчёта velocity-score для участка."""
competitors_count: int
monthly_velocity_sqm: float # avg м²/мес по конкурентам в радиусе
ekb_median_sqm: float # benchmark ЕКБ для нормализации
velocity_score: float # 0..1 — отношение к benchmark
confidence: Literal["high", "medium", "low"]
months_observed: int # фактически использованных месяцев
period_start: str # YYYY-MM
period_end: str # YYYY-MM
sample_competitors: list[dict[str, Any]] # top-5 для UI
def as_dict(self) -> dict[str, Any]:
return {
"competitors_count": self.competitors_count,
"monthly_velocity_sqm": round(self.monthly_velocity_sqm, 1),
"ekb_median_sqm": round(self.ekb_median_sqm, 1),
"velocity_score": round(self.velocity_score, 3),
"confidence": self.confidence,
"months_observed": self.months_observed,
"period": {"start": self.period_start, "end": self.period_end},
"sample_competitors": self.sample_competitors,
}
def compute_velocity(
db: Session,
parcel_geom_wkt: str,
radius_km: float = 3.0,
obj_class: str | None = None,
months_window: int = 6,
) -> VelocityResult | None:
"""Вычислить velocity-score для участка.
Алгоритм:
1. Найти ЖК-конкуренты в радиусе radius_km (через lat/lon ST_DWithin).
2. Взять sale_graph за последние months_window месяцев (latest snapshot).
3. Посчитать суммарный объём (area_sq > 0, иначе realised * avg_area).
4. Нормировать на ЕКБ-медиану → score 0..1.
Возвращает None если parcel_geom_wkt невалиден или конкурентов нет.
"""
# ── Step 1: конкуренты по lat/lon в радиусе ──────────────────────────────
# DISTINCT ON (obj_id) ORDER BY snapshot_date DESC — latest snapshot only.
# obj_class в domrf_kn_objects заполнен слабо (много NULL); фильтруем
# только если явно передан.
class_filter = "AND o.obj_class = :obj_class" if obj_class else ""
try:
comp_rows = (
db.execute(
text(
f"""
WITH latest_obj AS (
SELECT DISTINCT ON (obj_id)
obj_id,
comm_name,
dev_name,
obj_class,
latitude,
longitude,
district_name
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL
AND region_cd = 66
{class_filter}
ORDER BY obj_id, snapshot_date DESC NULLS LAST
)
SELECT
o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.district_name,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:parcel_wkt, 4326))::geography,
:radius_m
)
ORDER BY distance_m ASC
LIMIT 200
"""
),
{
"parcel_wkt": parcel_geom_wkt,
"radius_m": radius_km * 1000.0,
"obj_class": obj_class,
},
)
.mappings()
.all()
)
except Exception:
logger.exception("velocity: competitor query failed for wkt=%s", parcel_geom_wkt[:80])
return None
if not comp_rows:
return None
obj_ids: list[int] = [int(r["obj_id"]) for r in comp_rows]
competitor_meta: dict[int, dict[str, Any]] = {
int(r["obj_id"]): {
"name": r["comm_name"],
"dev_name": r["dev_name"],
"obj_class": r["obj_class"],
"district_name": r["district_name"],
"distance_m": round(float(r["distance_m"]), 0),
}
for r in comp_rows
}
# ── Step 2: sale_graph за последние N месяцев (latest snapshot per obj) ──
# area_sq = м² за месяц (primary). Если NULL — realised * 45 м² heuristic.
# type = 'apartments' — только жильё.
try:
sales_rows = (
db.execute(
text(
"""
WITH latest_sg AS (
SELECT DISTINCT ON (obj_id, report_month)
obj_id,
report_month,
area_sq,
realised
FROM domrf_kn_sale_graph
WHERE obj_id = ANY(:obj_ids)
AND type = 'apartments'
AND report_month >= (CURRENT_DATE - :window_interval::interval)
ORDER BY obj_id, report_month, snapshot_date DESC NULLS LAST
)
SELECT
obj_id,
SUM(
COALESCE(area_sq, realised * 45.0)
) AS total_sqm,
COUNT(DISTINCT report_month) AS months_with_data,
MIN(report_month) AS period_start,
MAX(report_month) AS period_end
FROM latest_sg
WHERE area_sq > 0 OR realised > 0
GROUP BY obj_id
"""
),
{
"obj_ids": obj_ids,
"window_interval": f"{months_window} months",
},
)
.mappings()
.all()
)
except Exception:
logger.exception("velocity: sale_graph query failed for obj_ids=%s", obj_ids[:5])
return None
if not sales_rows:
return None
total_sqm = sum(float(r["total_sqm"] or 0.0) for r in sales_rows)
months_observed = max((int(r["months_with_data"] or 0) for r in sales_rows), default=0)
period_start_dates = [r["period_start"] for r in sales_rows if r["period_start"]]
period_end_dates = [r["period_end"] for r in sales_rows if r["period_end"]]
period_start = min(period_start_dates).strftime("%Y-%m") if period_start_dates else ""
period_end = max(period_end_dates).strftime("%Y-%m") if period_end_dates else ""
if months_observed == 0 or total_sqm <= 0:
return None
# Среднемесячный объём в расчёте: суммарный по всем конкурентам / месяцев.
# Чем больше конкурентов с данными — тем весомее результат.
monthly_velocity = total_sqm / months_observed
# ── Step 3: ЕКБ-медиана ──────────────────────────────────────────────────
ekb_median = (
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
)
# ── Step 4: нормализация → score 0..1 ────────────────────────────────────
# Логика: сравниваем суммарный velocity радиуса с «нормой» одного ЖК.
# Если в радиусе продаётся N × ekb_median → рынок горячий.
# Нормируем: score = min(1.0, total_velocity / (n_competitors × ekb_median × 2))
# Cap 2×median = «насыщен». Итоговый score 0..1.
n_with_sales = len(sales_rows)
denominator = n_with_sales * ekb_median * 2.0 if n_with_sales > 0 else ekb_median * 2.0
velocity_score = min(1.0, max(0.0, monthly_velocity / denominator))
# ── Step 5: confidence ───────────────────────────────────────────────────
n_comps = len(comp_rows)
if n_comps >= 10 and months_observed >= 5:
confidence: Literal["high", "medium", "low"] = "high"
elif n_comps >= 5 and months_observed >= 3:
confidence = "medium"
else:
confidence = "low"
# ── Step 6: top-5 конкурентов по объёму продаж ───────────────────────────
sales_by_id: dict[int, float] = {
int(r["obj_id"]): float(r["total_sqm"] or 0.0) for r in sales_rows
}
sample = sorted(
[
{
"obj_id": oid,
**competitor_meta[oid],
"total_sqm_period": round(sales_by_id.get(oid, 0.0), 0),
}
for oid in obj_ids
if oid in competitor_meta
],
key=lambda x: x["total_sqm_period"],
reverse=True,
)[:5]
return VelocityResult(
competitors_count=n_comps,
monthly_velocity_sqm=monthly_velocity,
ekb_median_sqm=ekb_median,
velocity_score=velocity_score,
confidence=confidence,
months_observed=months_observed,
period_start=period_start,
period_end=period_end,
sample_competitors=sample,
)
def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
"""ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark.
Учитываются только ЖК с ≥3 месяцами данных за окно (стабильный сигнал).
Fallback к _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH если нет данных в БД.
"""
try:
row = (
db.execute(
text(
"""
WITH latest_sg AS (
SELECT DISTINCT ON (obj_id, report_month)
obj_id,
area_sq,
realised,
report_month
FROM domrf_kn_sale_graph sg
WHERE sg.type = 'apartments'
AND sg.report_month >= (CURRENT_DATE - :window_interval::interval)
AND EXISTS (
SELECT 1 FROM domrf_kn_objects o
WHERE o.obj_id = sg.obj_id
AND o.region_cd = 66
)
ORDER BY obj_id, report_month, snapshot_date DESC NULLS LAST
),
per_obj AS (
SELECT
obj_id,
SUM(COALESCE(area_sq, realised * 45.0)) AS total_sqm,
COUNT(DISTINCT report_month) AS months_data
FROM latest_sg
WHERE area_sq > 0 OR realised > 0
GROUP BY obj_id
HAVING COUNT(DISTINCT report_month) >= 3
),
per_obj_velocity AS (
SELECT total_sqm / months_data AS velocity
FROM per_obj
)
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median
FROM per_obj_velocity
"""
),
{"window_interval": f"{months_window} months"},
)
.mappings()
.first()
)
except Exception:
logger.warning("velocity: ekb_median query failed, using fallback")
return None
if row and row["median"] is not None:
return float(row["median"])
return None