"""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 (DB-error / пустой _get_ekb_median). # Источник (audit #1871): реальная медиана monthly velocity по ЕКБ — 593-766 м²/мес на # один ЖК. Берём верхнюю границу 750.0 — консервативно (безопаснее переоценки рынка: # чем больше знаменатель нормализации, тем ниже velocity_score). Прежнее 4500.0 было # завышено ~7.6× и латентно занижало score при срабатывании fallback. _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH: float = 750.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" # SF#1871 P2: честность покрытия / proxy-disclosure (additive — фронт/PDF не ломаются). # objective_coverage_pct — доля конкурентов с Objective-маппингом И данными (mapped_ratio×100). # 0.0 на early/none-путях, где покрытие неизвестно. objective_coverage_pct: float = 0.0 # proxy_used — True когда объём фабриковался через count×45 (rosreestr_fallback путь), # а не брался из реального deals_total_vol_m2 Objective. proxy_used: bool = False # proxy_sqm_per_deal — допущение «м² на сделку» для fallback (45.0); None на objective/none. proxy_sqm_per_deal: float | None = None # Audit #2445 A4: доля report_month-строк ЕКБ-бенчмарка (_get_ekb_median), где # deals_total_vol_m2 IS NULL и объём фабриковался через count×45.0 (тот же proxy, # что и rosreestr_fallback, но внутри city-wide нормализующей медианы). 0.0 — # медиана целиком из реальных deals_total_vol_m2 (или бенчмарк не считался/fallback-константа). ekb_median_proxy_fraction: float = 0.0 # RANK 1 (DCF velocity schedule): абсорбция ОДНОГО типового конкурента района, # м²/мес = monthly_velocity_sqm / max(n_with_sales, 1). Это НЕ суммарная скорость # района (monthly_velocity_sqm), а темп выбытия одного участника рынка — им и # драйвится окно продаж нового проекта в финмодели (он один продавец из многих). # None на rosreestr_fallback (квартальный count без per-project декомпозиции — # per-project absorption там ill-defined и НЕ должен драйвить график) и на # degenerate/zero путях. project_absorption_sqm_per_month: float | None = None # #2464 cluster B: True когда запрос конкурентов (Step 1, `LIMIT 200`) упёрся # в лимит — competitors_count тогда "минимум 200" (реальных ЖК в радиусе может # быть больше), а не честный total. Тот же silent-cap класс, что чинили в # utility_infrastructure_loader (`features_truncated`, epic #2445 A2); здесь — # флаг, а не второй COUNT(*): 200 конкурентов-ЖК в 3км радиусе — крайне # редкий edge (плотная застройка), доп. geo-запрос на каждый analyze того не # стоит — честная disclosure флагом дешевле. competitors_truncated: bool = False 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, "objective_coverage_pct": self.objective_coverage_pct, "proxy_used": self.proxy_used, "proxy_sqm_per_deal": self.proxy_sqm_per_deal, "ekb_median_proxy_fraction": round(self.ekb_median_proxy_fraction, 3), "project_absorption_sqm_per_month": ( round(self.project_absorption_sqm_per_month, 1) if self.project_absorption_sqm_per_month is not None else None ), "competitors_truncated": self.competitors_truncated, } 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); фильтруем # только если явно передан. #38: при NULL реального класса используем # obj_class_fallback (yandex_match / price_inference) — реальный obj_class # в приоритете (COALESCE), поведение для размеченных ЖК не меняется. class_filter = ( "AND COALESCE(o.obj_class, o.obj_class_fallback) = :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, -- #38: эффективный класс — реальный, иначе fallback COALESCE(obj_class, obj_class_fallback) AS 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 # SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными). coverage_pct = round(mapped_ratio * 100, 1) # Audit #2445 A4: _get_ekb_median возвращает (median, proxy_row_fraction) — доля # строк бенчмарка, где объём фабриковался через count*45.0. При DB-fallback # (None — нет данных) proxy_fraction неизвестна → 0.0 (используем calibrated # константу, а не contaminated медиану, так что disclosure не нужен). _ekb_median_result = _get_ekb_median(db, months_window=months_window) if _ekb_median_result is not None: ekb_median, ekb_median_proxy_fraction = _ekb_median_result else: ekb_median = _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH ekb_median_proxy_fraction = 0.0 n_comps = len(comp_rows) # #2464 cluster B: Step 1 query капнут `LIMIT 200` (см. выше) — если выборка # уперлась в лимит, реальных конкурентов в радиусе может быть больше. n_comps_truncated = n_comps >= 200 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, objective_coverage_pct=coverage_pct, ekb_median_proxy_fraction=ekb_median_proxy_fraction, competitors_truncated=n_comps_truncated, ) 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", objective_coverage_pct=coverage_pct, proxy_used=False, proxy_sqm_per_deal=None, ekb_median_proxy_fraction=ekb_median_proxy_fraction, project_absorption_sqm_per_month=None, # degenerate path — ill-defined competitors_truncated=n_comps_truncated, ) # ── 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, objective_coverage_pct=coverage_pct, ekb_median_proxy_fraction=ekb_median_proxy_fraction, competitors_truncated=n_comps_truncated, ) 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", objective_coverage_pct=coverage_pct, proxy_used=False, proxy_sqm_per_deal=None, ekb_median_proxy_fraction=ekb_median_proxy_fraction, project_absorption_sqm_per_month=None, # degenerate path — ill-defined competitors_truncated=n_comps_truncated, ) # Среднемесячный объём = Σ(объём_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)) # RANK 1: абсорбция ОДНОГО типового конкурента = суммарная скорость района / # число продающих ЖК. Драйвит окно продаж нового проекта в DCF (он один из # многих продавцов, а НЕ поглощает весь рынок). Только на objective-пути, где # есть per-project декомпозиция; иначе None. project_absorption: float | None = ( monthly_velocity / max(n_with_sales, 1) if n_with_sales >= 1 and monthly_velocity > 0.0 else None ) # ── 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", objective_coverage_pct=coverage_pct, proxy_used=False, proxy_sqm_per_deal=None, ekb_median_proxy_fraction=ekb_median_proxy_fraction, project_absorption_sqm_per_month=project_absorption, competitors_truncated=n_comps_truncated, ) 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]], objective_coverage_pct: float = 0.0, ekb_median_proxy_fraction: float = 0.0, competitors_truncated: bool = False, ) -> 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", objective_coverage_pct=objective_coverage_pct, proxy_used=True, # объём = deal_count × avg_area_per_deal (фабрикуется) proxy_sqm_per_deal=avg_area_per_deal, # Audit #2445 A4: пробрасываем proxy-долю бенчмарка (ekb_median посчитан # ДО вызова этого fallback) — даже здесь denominator может быть частично # contaminated count*45.0-строками. ekb_median_proxy_fraction=ekb_median_proxy_fraction, # RANK 1: квартальный total deal-count БЕЗ per-project декомпозиции — # per-project absorption ill-defined → None, НЕ драйвит DCF-график. project_absorption_sqm_per_month=None, competitors_truncated=competitors_truncated, ) def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, 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 если нет данных в БД. Audit #2445 A4: report_month-строки с deals_total_count>0, но deals_total_vol_m2 IS NULL, фабрикуют объём через COALESCE(..., count*45.0) — тот же proxy-приём, что и в rosreestr_fallback (avg_area_per_deal), только здесь он был необъявленным и контаминировал city-wide бенчмарк, к которому нормализуется velocity_score КАЖДОГО участка. Строки не исключаются (иначе продающийся, но без м²-данных ЖК просто выпадает из бенчмарка — тоже искажение), но доля таких строк считается и возвращается вызывающему коду для disclosure (см. VelocityResult.ekb_median_proxy_fraction), а не проглатывается молча как чистые данные. Возвращает (median, proxy_row_fraction) либо None если данных нет. proxy_row_fraction — доля (project, room, month)-строк (среди вошедших в медиану project'ов, ≥3 отчётных месяца), где объём взят из count*45.0, а не из реального deals_total_vol_m2. Считается на уровне строк objective_corpus_room_month (таблица хранит строку per project×room×month — см. data/sql/100_fix_mv_layout_velocity_weighted_avg.sql), НЕ на уровне report_month, чтобы не менять исходный HAVING-гейт «≥3 месяца данных». """ try: with db.begin_nested(): row = ( db.execute( text( """ WITH source_rows AS ( SELECT project_name, report_month, COALESCE(deals_total_vol_m2, deals_total_count * 45.0) AS row_sqm, (deals_total_vol_m2 IS NULL) AS is_proxy_row FROM objective_corpus_room_month WHERE report_month >= (CURRENT_DATE - CAST(:window_interval AS interval)) AND deals_total_count > 0 ), per_project AS ( SELECT project_name, SUM(row_sqm) AS total_sqm, COUNT(DISTINCT report_month) AS months_data, COUNT(*) AS rows_data, COUNT(*) FILTER (WHERE is_proxy_row) AS proxy_rows FROM source_rows GROUP BY project_name HAVING COUNT(DISTINCT report_month) >= 3 ), per_project_velocity AS ( SELECT total_sqm / months_data AS velocity, rows_data, proxy_rows FROM per_project ) SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY velocity) AS median, COALESCE(SUM(proxy_rows), 0) AS proxy_rows_total, COALESCE(SUM(rows_data), 0) AS rows_total 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 is None or row["median"] is None: return None rows_total = int(row["rows_total"] or 0) proxy_rows_total = int(row["proxy_rows_total"] or 0) proxy_fraction = (proxy_rows_total / rows_total) if rows_total > 0 else 0.0 return float(row["median"]), proxy_fraction