673 lines
31 KiB
Python
673 lines
31 KiB
Python
"""Velocity-score — темп продаж конкурентов вокруг участка.
|
||
|
||
Per #34 D2: утилизация objective_corpus_room_month (еженедельно обновляемые данные).
|
||
Ранее использовался domrf_kn_sale_graph (последнее обновление 2026-01, устарел).
|
||
Главный demand-сигнал «продастся ли» — среднемесячный объём продаж
|
||
конкурирующих ЖК в радиусе radius_km от участка, нормированный к
|
||
ЕКБ-медиане по данным Objective.
|
||
|
||
Fallback (SF#17): если Objective coverage <50% конкурентов в радиусе,
|
||
использует rosreestr_deals JOIN по cad_quarter участка (100% coverage по кварталам).
|
||
|
||
Foundation: domrf_kn_objects (lat/lon, comm_name, obj_class, region_cd),
|
||
objective_complex_mapping (domrf_obj_id ↔ objective_complex_name),
|
||
objective_corpus_room_month (project_name, deals_total_vol_m2,
|
||
deals_total_count, report_month).
|
||
Fallback: rosreestr_deals (quarter_cad_number, deal_count, period_start_date).
|
||
|
||
Linkage: domrf_kn_objects.obj_id
|
||
→ objective_complex_mapping.domrf_obj_id (gated: is_reviewed/manual/score≥0.85)
|
||
→ objective_complex_mapping.objective_complex_name
|
||
→ objective_corpus_room_month.project_name
|
||
|
||
OBJ-2 (#307): маппинги фильтруются по confidence (_MAPPING_CONFIDENCE_GATE) —
|
||
unreviewed low-score auto-matches (#1331/#1333 backfill) исключаются как
|
||
false-positive risk.
|
||
"""
|
||
|
||
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
|
||
|
||
# Порог: если доля конкурентов с Objective-маппингом < этого значения,
|
||
# пытаемся rosreestr_fallback.
|
||
_OBJECTIVE_COVERAGE_MIN_RATIO: float = 0.50
|
||
|
||
# OBJ-2 (#307): gate objective_complex_mapping by confidence перед использованием
|
||
# в velocity. Fuzzy-trgm backfill (#1331/#1333) добавил ~115 auto-matched строк с
|
||
# is_reviewed=false и низким match_score (вплоть до 0.50-0.625) — false-positive
|
||
# risk, который раздувал/искажал velocity конкурентов.
|
||
#
|
||
# Принимаем mapping только если:
|
||
# - is_reviewed = TRUE (человек подтвердил), ИЛИ
|
||
# - match_method = 'manual' (ручной маппинг, score обычно NULL), ИЛИ
|
||
# - match_score >= 0.85 (AUTO_ACCEPT_THRESHOLD — high-confidence auto,
|
||
# vault: fuzzy_trgm 0.85+ надёжен для auto-use).
|
||
#
|
||
# Строгий gate только на is_reviewed=true дал бы 2 строки из 303 → обнулил бы
|
||
# velocity-покрытие; 0.85-порог сохраняет 264/303 EKB-маппингов, отбрасывая 39
|
||
# низкоуверенных. Совпадает с AUTO_ACCEPT_THRESHOLD из objective_backfill.py.
|
||
_MAPPING_CONFIDENCE_GATE: str = (
|
||
"(cm.is_reviewed = TRUE OR cm.match_method = 'manual' OR cm.match_score >= 0.85)"
|
||
)
|
||
|
||
|
||
@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
|
||
by_room_bucket: dict[str, dict[str, Any]] # агрегат по комнатности
|
||
# True если данные есть (objective или rosreestr_fallback).
|
||
# False → нет данных ни из одного источника.
|
||
velocity_data_available: bool = True
|
||
# Источник данных: objective (основной), rosreestr_fallback (по кадастровому кварталу),
|
||
# none (нет данных).
|
||
velocity_source: Literal["objective", "rosreestr_fallback", "none"] = "objective"
|
||
|
||
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,
|
||
"by_room_bucket": self.by_room_bucket,
|
||
"velocity_data_available": self.velocity_data_available,
|
||
"velocity_source": self.velocity_source,
|
||
}
|
||
|
||
|
||
def compute_velocity(
|
||
db: Session,
|
||
parcel_geom_wkt: str,
|
||
radius_km: float = 3.0,
|
||
obj_class: str | None = None,
|
||
months_window: int = 6,
|
||
cad_quarter: str | None = None,
|
||
) -> VelocityResult | None:
|
||
"""Вычислить velocity-score для участка.
|
||
|
||
Алгоритм:
|
||
1. Найти ЖК-конкуренты в радиусе radius_km (через lat/lon ST_DWithin).
|
||
2. Взять objective_corpus_room_month за последние months_window месяцев
|
||
через objective_complex_mapping (domrf_obj_id → project_name).
|
||
3. Если Objective coverage < 50% конкурентов → rosreestr_fallback:
|
||
считаем сделки DDU/ДКП в cad_quarter участка за окно.
|
||
4. Нормировать на ЕКБ-медиану → score 0..1.
|
||
|
||
Параметры:
|
||
cad_quarter: кадастровый квартал участка (первые 3 сегмента cad_num,
|
||
например "66:41:0702048"). Используется только для fallback.
|
||
|
||
Возвращает 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 ""
|
||
# SAVEPOINT per query: failure rollbacks ТОЛЬКО savepoint, не outer tx.
|
||
# db.rollback() здесь НЕЛЬЗЯ — он orphan'ит outer SessionTransaction
|
||
# (см. PR #155 bot review — SQLAlchemy 2.0 begin_nested context cleanup).
|
||
try:
|
||
with db.begin_nested():
|
||
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])
|
||
# SAVEPOINT auto-rollbacks через __exit__ context manager.
|
||
# Outer tx остаётся clean — caller продолжает работать без cascade.
|
||
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: objective_corpus_room_month за последние N месяцев ───────────
|
||
# Linkage: domrf_obj_id → objective_complex_mapping → project_name →
|
||
# objective_corpus_room_month.
|
||
# deals_total_vol_m2 = DDU + DKP м² за месяц (primary signal).
|
||
# deals_total_count > 0 — фильтрует месяцы без сделок.
|
||
# LEFT JOIN с objective_complex_mapping: конкуренты без маппинга не
|
||
# выпадают — они включаются в список но с total_sqm=NULL (→ 0.0).
|
||
# GROUP BY domrf_obj_id чтобы сохранить совместимость с caller (obj_id key).
|
||
try:
|
||
with db.begin_nested():
|
||
sales_rows = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH all_competitors AS (
|
||
SELECT unnest(CAST(:obj_ids AS int[])) AS obj_id
|
||
),
|
||
mapped AS (
|
||
SELECT cm.domrf_obj_id AS obj_id,
|
||
cm.objective_complex_name
|
||
FROM objective_complex_mapping cm
|
||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||
AND {_MAPPING_CONFIDENCE_GATE}
|
||
)
|
||
SELECT
|
||
ac.obj_id,
|
||
SUM(COALESCE(crm.deals_total_vol_m2,
|
||
crm.deals_total_count * 45.0)) AS total_sqm,
|
||
COUNT(DISTINCT crm.report_month) AS months_with_data,
|
||
MIN(crm.report_month) AS period_start,
|
||
MAX(crm.report_month) AS period_end,
|
||
CASE WHEN m.obj_id IS NOT NULL THEN TRUE
|
||
ELSE FALSE END AS has_mapping
|
||
FROM all_competitors ac
|
||
LEFT JOIN mapped m ON m.obj_id = ac.obj_id
|
||
LEFT JOIN objective_corpus_room_month crm
|
||
ON crm.project_name = m.objective_complex_name
|
||
AND crm.report_month >= (
|
||
CURRENT_DATE - CAST(:window_interval AS interval))
|
||
AND crm.deals_total_count > 0
|
||
GROUP BY ac.obj_id, m.obj_id
|
||
"""
|
||
),
|
||
{
|
||
"obj_ids": obj_ids,
|
||
"window_interval": f"{months_window} months",
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
except Exception:
|
||
logger.exception("velocity: objective sales query failed for obj_ids=%s", obj_ids[:5])
|
||
# SAVEPOINT auto-rollback'нут — outer tx clean
|
||
return None
|
||
|
||
if not sales_rows:
|
||
return None
|
||
|
||
# ── Step 2a: проверка Objective coverage ─────────────────────────────────
|
||
# Считаем: mapped_with_data — конкуренты с маппингом И реальными данными.
|
||
# Если mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO → rosreestr_fallback.
|
||
n_total_comps = len(obj_ids)
|
||
mapped_with_data = [
|
||
r for r in sales_rows if bool(r["has_mapping"]) and (r["total_sqm"] or 0.0) > 0
|
||
]
|
||
mapped_ratio = len(mapped_with_data) / n_total_comps if n_total_comps > 0 else 0.0
|
||
|
||
ekb_median = (
|
||
_get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH
|
||
)
|
||
n_comps = len(comp_rows)
|
||
sample_no_data = sorted(
|
||
[
|
||
{
|
||
"obj_id": oid,
|
||
**competitor_meta[oid],
|
||
"total_sqm_period": 0.0,
|
||
"by_room_bucket": {},
|
||
}
|
||
for oid in obj_ids[:5]
|
||
if oid in competitor_meta
|
||
],
|
||
key=lambda x: x["distance_m"], # type: ignore[index]
|
||
)
|
||
|
||
if mapped_ratio < _OBJECTIVE_COVERAGE_MIN_RATIO:
|
||
logger.info(
|
||
"velocity: objective coverage %.0f%% (<%d%%) for %d competitors;"
|
||
" trying rosreestr_fallback cad_quarter=%s",
|
||
mapped_ratio * 100,
|
||
int(_OBJECTIVE_COVERAGE_MIN_RATIO * 100),
|
||
n_total_comps,
|
||
cad_quarter,
|
||
)
|
||
rr_result = _compute_rosreestr_fallback(
|
||
db=db,
|
||
cad_quarter=cad_quarter,
|
||
months_window=months_window,
|
||
n_comps=n_comps,
|
||
ekb_median=ekb_median,
|
||
sample_competitors=sample_no_data,
|
||
)
|
||
if rr_result is not None:
|
||
return rr_result
|
||
# Rosreestr тоже пуст — возвращаем none-state.
|
||
logger.info("velocity: rosreestr_fallback also empty for cad_quarter=%s", cad_quarter)
|
||
return VelocityResult(
|
||
competitors_count=n_comps,
|
||
monthly_velocity_sqm=0.0,
|
||
ekb_median_sqm=ekb_median,
|
||
velocity_score=0.0,
|
||
confidence="low",
|
||
months_observed=0,
|
||
period_start="",
|
||
period_end="",
|
||
sample_competitors=sample_no_data,
|
||
by_room_bucket={},
|
||
velocity_data_available=False,
|
||
velocity_source="none",
|
||
)
|
||
|
||
# ── Step 2b: разбивка по комнатности (room_bucket) ───────────────────────
|
||
# Тот же маппинг domrf_obj_id → project_name. Агрегируем по room_bucket
|
||
# для отображения структуры спроса в UI.
|
||
try:
|
||
with db.begin_nested():
|
||
bucket_rows = (
|
||
db.execute(
|
||
text(
|
||
f"""
|
||
WITH mapped AS (
|
||
SELECT cm.domrf_obj_id AS obj_id,
|
||
cm.objective_complex_name
|
||
FROM objective_complex_mapping cm
|
||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||
AND {_MAPPING_CONFIDENCE_GATE}
|
||
)
|
||
SELECT
|
||
m.obj_id,
|
||
crm.room_bucket,
|
||
SUM(crm.deals_total_count) AS units_sold,
|
||
SUM(COALESCE(crm.deals_total_vol_m2,
|
||
crm.deals_total_count * 45.0)) AS sqm_sold
|
||
FROM objective_corpus_room_month crm
|
||
JOIN mapped m ON m.objective_complex_name = crm.project_name
|
||
WHERE crm.report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
|
||
AND crm.deals_total_count > 0
|
||
GROUP BY m.obj_id, crm.room_bucket
|
||
"""
|
||
),
|
||
{
|
||
"obj_ids": obj_ids,
|
||
"window_interval": f"{months_window} months",
|
||
},
|
||
)
|
||
.mappings()
|
||
.all()
|
||
)
|
||
except Exception:
|
||
logger.warning("velocity: bucket breakdown query failed, continuing without it")
|
||
bucket_rows = []
|
||
|
||
# Агрегируем по room_bucket поверх всех конкурентов.
|
||
by_bucket_agg: dict[str, dict[str, Any]] = {}
|
||
per_comp_buckets: dict[int, dict[str, int]] = {}
|
||
|
||
for row in bucket_rows:
|
||
bucket = str(row["room_bucket"])
|
||
oid = int(row["obj_id"])
|
||
units = int(row["units_sold"] or 0)
|
||
sqm = float(row["sqm_sold"] or 0.0)
|
||
|
||
if bucket not in by_bucket_agg:
|
||
by_bucket_agg[bucket] = {"units": 0, "sqm": 0.0, "complexes": set()}
|
||
by_bucket_agg[bucket]["units"] += units
|
||
by_bucket_agg[bucket]["sqm"] += sqm
|
||
by_bucket_agg[bucket]["complexes"].add(oid)
|
||
|
||
if oid not in per_comp_buckets:
|
||
per_comp_buckets[oid] = {}
|
||
per_comp_buckets[oid][bucket] = units
|
||
|
||
by_room_bucket: dict[str, dict[str, Any]] = {
|
||
bucket: {
|
||
"units": data["units"],
|
||
"sqm": round(data["sqm"], 0),
|
||
"complexes_count": len(data["complexes"]),
|
||
}
|
||
for bucket, data in by_bucket_agg.items()
|
||
}
|
||
|
||
# Считаем только по строкам с маппингом — unmapped строки дают total_sqm=NULL.
|
||
mapped_sales_rows = [r for r in sales_rows if bool(r["has_mapping"])]
|
||
|
||
total_sqm = sum(float(r["total_sqm"] or 0.0) for r in mapped_sales_rows)
|
||
months_observed = max((int(r["months_with_data"] or 0) for r in mapped_sales_rows), default=0)
|
||
# Активные mapped-конкуренты: маппинг + реальные продажи в окне (months_with_data>0).
|
||
# LEFT JOIN на crm даёт строку has_mapping=TRUE с total_sqm=NULL→0, months=0 для
|
||
# замаппленных-но-непродающих ЖК — их НЕ считаем при делении/нормализации (#1354, #1382).
|
||
active_sales_rows = [
|
||
r
|
||
for r in mapped_sales_rows
|
||
if int(r["months_with_data"] or 0) > 0 and float(r["total_sqm"] or 0.0) > 0
|
||
]
|
||
period_start_dates = [r["period_start"] for r in mapped_sales_rows if r["period_start"]]
|
||
period_end_dates = [r["period_end"] for r in mapped_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 ""
|
||
|
||
# Если mapped-конкурентов нет данных — partial coverage → fallback.
|
||
if months_observed == 0 or total_sqm <= 0:
|
||
logger.info(
|
||
"velocity: %d competitors found, %d mapped, but no sales data in window;"
|
||
" trying rosreestr_fallback",
|
||
len(obj_ids),
|
||
len(mapped_sales_rows),
|
||
)
|
||
rr_result = _compute_rosreestr_fallback(
|
||
db=db,
|
||
cad_quarter=cad_quarter,
|
||
months_window=months_window,
|
||
n_comps=n_comps,
|
||
ekb_median=ekb_median,
|
||
sample_competitors=sample_no_data,
|
||
)
|
||
if rr_result is not None:
|
||
return rr_result
|
||
sample_partial = sorted(
|
||
[
|
||
{
|
||
"obj_id": oid,
|
||
**competitor_meta[oid],
|
||
"total_sqm_period": 0.0,
|
||
"by_room_bucket": {},
|
||
}
|
||
for oid in obj_ids
|
||
if oid in competitor_meta
|
||
],
|
||
key=lambda x: x["total_sqm_period"], # type: ignore[arg-type]
|
||
reverse=True,
|
||
)[:5]
|
||
return VelocityResult(
|
||
competitors_count=n_comps,
|
||
monthly_velocity_sqm=0.0,
|
||
ekb_median_sqm=ekb_median,
|
||
velocity_score=0.0,
|
||
confidence="low",
|
||
months_observed=0,
|
||
period_start="",
|
||
period_end="",
|
||
sample_competitors=sample_partial,
|
||
by_room_bucket={},
|
||
velocity_data_available=False,
|
||
velocity_source="none",
|
||
)
|
||
|
||
# Среднемесячный объём = Σ(объём_i / месяцев_i) по активным конкурентам (#1354).
|
||
# Делить суммарный объём на max(месяцев) нельзя: при разнородных окнах
|
||
# (старые ЖК 6 мес, новые 1-2 мес) это размазывает объём новых по всему окну
|
||
# и занижает совокупную месячную скорость района.
|
||
monthly_velocity = sum(
|
||
float(r["total_sqm"] or 0.0) / int(r["months_with_data"]) for r in active_sales_rows
|
||
)
|
||
|
||
# ── Step 3: нормализация → 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 — только конкуренты с реальными продажами в окне (#1382).
|
||
# mapped-но-непродающие ЖК (has_mapping=TRUE, total_sqm=0) исключены: иначе
|
||
# знаменатель раздувается, а числитель их не учитывает → systematic занижение.
|
||
n_with_sales = len(active_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 4: confidence ───────────────────────────────────────────────────
|
||
mapped_conf: Literal["high", "medium", "low"]
|
||
if n_comps >= 10 and months_observed >= 5:
|
||
mapped_conf = "high"
|
||
elif n_comps >= 5 and months_observed >= 3:
|
||
mapped_conf = "medium"
|
||
else:
|
||
mapped_conf = "low"
|
||
|
||
# ── Step 5: top-5 конкурентов по объёму продаж ───────────────────────────
|
||
sales_by_id: dict[int, float] = {
|
||
int(r["obj_id"]): float(r["total_sqm"] or 0.0) for r in mapped_sales_rows
|
||
}
|
||
sample = sorted(
|
||
[
|
||
{
|
||
"obj_id": oid,
|
||
**competitor_meta[oid],
|
||
"total_sqm_period": round(sales_by_id.get(oid, 0.0), 0),
|
||
"by_room_bucket": per_comp_buckets.get(oid, {}),
|
||
}
|
||
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=mapped_conf,
|
||
months_observed=months_observed,
|
||
period_start=period_start,
|
||
period_end=period_end,
|
||
sample_competitors=sample,
|
||
by_room_bucket=by_room_bucket,
|
||
velocity_data_available=True,
|
||
velocity_source="objective",
|
||
)
|
||
|
||
|
||
def _compute_rosreestr_fallback(
|
||
db: Session,
|
||
cad_quarter: str | None,
|
||
months_window: int,
|
||
n_comps: int,
|
||
ekb_median: float,
|
||
sample_competitors: list[dict[str, Any]],
|
||
) -> VelocityResult | None:
|
||
"""Fallback velocity через rosreestr_deals JOIN по cad_quarter участка.
|
||
|
||
Считает суммарное число сделок DDU/ДКП в кадастровом квартале за окно months_window.
|
||
Velocity = deal_count / months_window (сделок/мес). Нет разбивки по room_bucket
|
||
(rosreestr не даёт комнатность).
|
||
|
||
Возвращает None если cad_quarter не задан или данных нет.
|
||
"""
|
||
if not cad_quarter:
|
||
return None
|
||
|
||
try:
|
||
with db.begin_nested():
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
SELECT
|
||
SUM(deal_count) AS total_deals,
|
||
MIN(period_start_date) AS period_start,
|
||
MAX(period_start_date) AS period_end
|
||
FROM rosreestr_deals
|
||
WHERE quarter_cad_number = :cad_quarter
|
||
AND period_start_date >= (CURRENT_DATE - CAST(:window_interval AS interval))
|
||
AND doc_type IN ('ДДУ', 'ДКП')
|
||
"""
|
||
),
|
||
{
|
||
"cad_quarter": cad_quarter,
|
||
"window_interval": f"{months_window} months",
|
||
},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
except Exception:
|
||
logger.warning("velocity: rosreestr_fallback query failed for cad_quarter=%s", cad_quarter)
|
||
return None
|
||
|
||
if row is None or not row["total_deals"] or int(row["total_deals"]) == 0:
|
||
return None
|
||
|
||
total_deals = int(row["total_deals"])
|
||
# Сделок/мес — грубый аналог velocity (без м², только count).
|
||
# Умножаем на 45 м² (эмпирика) для совместимости с м²/мес единицами.
|
||
avg_area_per_deal = 45.0 # м² — консервативная оценка для апартаментов ЕКБ
|
||
monthly_velocity_sqm = (total_deals * avg_area_per_deal) / months_window
|
||
|
||
# Нормализация относительно ekb_median (один ЖК × 2).
|
||
velocity_score = min(1.0, max(0.0, monthly_velocity_sqm / (ekb_median * 2.0)))
|
||
|
||
# Confidence — rosreestr данные менее детализированы, чем Objective.
|
||
rr_confidence: Literal["high", "medium", "low"]
|
||
if total_deals >= 50:
|
||
rr_confidence = "medium" # max medium для rosreestr — нет комнатности
|
||
else:
|
||
rr_confidence = "low"
|
||
|
||
period_start_date = row["period_start"]
|
||
period_end_date = row["period_end"]
|
||
period_start = period_start_date.strftime("%Y-%m") if period_start_date else ""
|
||
period_end = period_end_date.strftime("%Y-%m") if period_end_date else ""
|
||
|
||
logger.info(
|
||
"velocity: rosreestr_fallback success cad_quarter=%s"
|
||
" total_deals=%d window=%dm velocity=%.1f sqm/mon",
|
||
cad_quarter,
|
||
total_deals,
|
||
months_window,
|
||
monthly_velocity_sqm,
|
||
)
|
||
|
||
return VelocityResult(
|
||
competitors_count=n_comps,
|
||
monthly_velocity_sqm=monthly_velocity_sqm,
|
||
ekb_median_sqm=ekb_median,
|
||
velocity_score=velocity_score,
|
||
confidence=rr_confidence,
|
||
months_observed=months_window,
|
||
period_start=period_start,
|
||
period_end=period_end,
|
||
sample_competitors=sample_competitors,
|
||
by_room_bucket={}, # rosreestr не даёт room_bucket
|
||
velocity_data_available=True,
|
||
velocity_source="rosreestr_fallback",
|
||
)
|
||
|
||
|
||
def _get_ekb_median(db: Session, months_window: int = 6) -> float | None:
|
||
"""ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark.
|
||
|
||
Источник: objective_corpus_room_month (актуальные данные, обновляется еженедельно).
|
||
Ранее использовался domrf_kn_sale_graph с фильтром region_cd=66.
|
||
objective_corpus_room_month не имеет region_cd — данные Objective'а
|
||
покрывают primarily ЕКБ, что для baseline допустимо.
|
||
|
||
Учитываются только ЖК с ≥3 месяцами данных за окно (стабильный сигнал).
|
||
Fallback к _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH если нет данных в БД.
|
||
"""
|
||
try:
|
||
with db.begin_nested():
|
||
row = (
|
||
db.execute(
|
||
text(
|
||
"""
|
||
WITH per_project AS (
|
||
SELECT
|
||
project_name,
|
||
SUM(COALESCE(deals_total_vol_m2,
|
||
deals_total_count * 45.0)) AS total_sqm,
|
||
COUNT(DISTINCT report_month) AS months_data
|
||
FROM objective_corpus_room_month
|
||
WHERE report_month >= (CURRENT_DATE - CAST(:window_interval AS interval))
|
||
AND deals_total_count > 0
|
||
GROUP BY project_name
|
||
HAVING COUNT(DISTINCT report_month) >= 3
|
||
),
|
||
per_project_velocity AS (
|
||
SELECT total_sqm / months_data AS velocity
|
||
FROM per_project
|
||
)
|
||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median
|
||
FROM per_project_velocity
|
||
"""
|
||
),
|
||
{"window_interval": f"{months_window} months"},
|
||
)
|
||
.mappings()
|
||
.first()
|
||
)
|
||
except Exception:
|
||
logger.warning("velocity: ekb_median query failed, using fallback")
|
||
# SAVEPOINT auto-rollback'нут
|
||
return None
|
||
|
||
if row and row["median"] is not None:
|
||
return float(row["median"])
|
||
return None
|