From 6efefc814e8c41c21ce1715829d38db45b5543a7 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sun, 5 Jul 2026 23:36:05 +0500 Subject: [PATCH] =?UTF-8?q?fix(site-finder):=20=D1=80=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D1=8C=20proxy-fraction=20=D0=B2=20EKB-median?= =?UTF-8?q?=20velocity-=D0=B1=D0=B5=D0=BD=D1=87=D0=BC=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=20(#2445-A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _get_ekb_median COALESCE(deals_total_vol_m2, deals_total_count*45.0) фабрикует м²-объём для строк без реальных Объектив-данных, молча засоряя city-wide знаменатель нормализации, по которому считается velocity_score КАЖДОГО участка, без сигнала качества. Расширяем существующий SF#1871-P2 disclosure-паттерн (уже применён для rosreestr_fallback) на этот бенчмарк: _get_ekb_median теперь возвращает (median, proxy_row_fraction), поверхностно в VelocityResult.ekb_median_proxy_fraction. Fraction и median считаются над ОДНИМ CTE (та же HAVING >= 3 гейт-выборка) — disclosure не вводит в заблуждение. Аддитивное поле loose-dict ответа → codegen не требуется. Refs #2445 --- backend/app/services/site_finder/velocity.py | 86 +++++++++++++++--- backend/tests/test_velocity.py | 92 +++++++++++++++++--- 2 files changed, 152 insertions(+), 26 deletions(-) diff --git a/backend/app/services/site_finder/velocity.py b/backend/app/services/site_finder/velocity.py index cc5aa6df..a63a999a 100644 --- a/backend/app/services/site_finder/velocity.py +++ b/backend/app/services/site_finder/velocity.py @@ -95,6 +95,11 @@ class VelocityResult: 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), а темп выбытия одного участника рынка — им и @@ -120,6 +125,7 @@ class VelocityResult: "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 @@ -307,9 +313,16 @@ def compute_velocity( # SF#1871 P2: surface objective-покрытие в payload (доля конкурентов с маппингом+данными). coverage_pct = round(mapped_ratio * 100, 1) - ekb_median = ( - _get_ekb_median(db, months_window=months_window) or _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH - ) + # 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) sample_no_data = sorted( [ @@ -342,6 +355,7 @@ def compute_velocity( ekb_median=ekb_median, sample_competitors=sample_no_data, objective_coverage_pct=coverage_pct, + ekb_median_proxy_fraction=ekb_median_proxy_fraction, ) if rr_result is not None: return rr_result @@ -363,6 +377,7 @@ def compute_velocity( 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 ) @@ -470,6 +485,7 @@ def compute_velocity( ekb_median=ekb_median, sample_competitors=sample_no_data, objective_coverage_pct=coverage_pct, + ekb_median_proxy_fraction=ekb_median_proxy_fraction, ) if rr_result is not None: return rr_result @@ -503,6 +519,7 @@ def compute_velocity( 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 ) @@ -580,6 +597,7 @@ def compute_velocity( 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, ) @@ -592,6 +610,7 @@ def _compute_rosreestr_fallback( ekb_median: float, sample_competitors: list[dict[str, Any]], objective_coverage_pct: float = 0.0, + ekb_median_proxy_fraction: float = 0.0, ) -> VelocityResult | None: """Fallback velocity через rosreestr_deals JOIN по cad_quarter участка. @@ -681,13 +700,17 @@ def _compute_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, ) -def _get_ekb_median(db: Session, months_window: int = 6) -> float | None: +def _get_ekb_median(db: Session, months_window: int = 6) -> tuple[float, float] | None: """ЕКБ-wide медиана monthly velocity (м²/мес) per ЖК — benchmark. Источник: objective_corpus_room_month (актуальные данные, обновляется еженедельно). @@ -697,6 +720,24 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None: Учитываются только ЖК с ≥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(): @@ -704,23 +745,38 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None: db.execute( text( """ - WITH per_project AS ( + WITH source_rows 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 + 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 + 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 + 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 """ ), @@ -734,6 +790,10 @@ def _get_ekb_median(db: Session, months_window: int = 6) -> float | None: # SAVEPOINT auto-rollback'нут return None - if row and row["median"] is not None: - return float(row["median"]) - 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 diff --git a/backend/tests/test_velocity.py b/backend/tests/test_velocity.py index c93b06c8..414d2df2 100644 --- a/backend/tests/test_velocity.py +++ b/backend/tests/test_velocity.py @@ -126,7 +126,7 @@ def test_healthy_sales_returns_result(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -146,7 +146,7 @@ def test_few_competitors_low_confidence(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -164,7 +164,7 @@ def test_medium_confidence(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -184,6 +184,9 @@ def test_ekb_median_fallback_used_when_none(): assert result is not None assert result.ekb_median_sqm == _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH + # Audit #2445 A4: DB-fallback (no benchmark data at all) uses the calibrated + # constant, not a contaminated median -> proxy_fraction is 0.0 (nothing to disclose). + assert result.ekb_median_proxy_fraction == pytest.approx(0.0) def test_score_capped_at_1(): @@ -196,7 +199,7 @@ def test_score_capped_at_1(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -219,7 +222,7 @@ def test_score_zero_when_no_sales_sqm(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -247,6 +250,7 @@ def test_as_dict_structure(): objective_coverage_pct=66.7, proxy_used=False, proxy_sqm_per_deal=None, + ekb_median_proxy_fraction=0.25, ) d = vr.as_dict() assert "competitors_count" in d @@ -265,6 +269,8 @@ def test_as_dict_structure(): assert d["objective_coverage_pct"] == pytest.approx(66.7) assert d["proxy_used"] is False assert d["proxy_sqm_per_deal"] is None + # Audit #2445 A4: ekb_median_proxy_fraction disclosure field, rounded to 3dp. + assert d["ekb_median_proxy_fraction"] == pytest.approx(0.25) # RANK 1: project_absorption_sqm_per_month присутствует (None по умолчанию). assert "project_absorption_sqm_per_month" in d assert d["project_absorption_sqm_per_month"] is None @@ -279,7 +285,7 @@ def test_sample_competitors_top5(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -306,7 +312,7 @@ def test_by_room_bucket_aggregation(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -330,7 +336,7 @@ def test_by_room_bucket_empty_when_no_bucket_data(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -356,7 +362,7 @@ def test_mapping_confidence_gate_in_sales_query(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -376,6 +382,66 @@ def test_fallback_constant_is_conservative(): assert _EKB_MEDIAN_FALLBACK_SQM_PER_MONTH == pytest.approx(750.0) +def test_ekb_median_proxy_fraction_surfaced_on_objective_path(): + """Audit #2445 A4: когда _get_ekb_median сигнализирует, что часть строк + city-wide бенчмарка (COALESCE(deals_total_vol_m2, count*45.0)) фабриковалась, + эта доля должна долететь до VelocityResult.ekb_median_proxy_fraction — + а не проглатываться молча (проверяем и dataclass-поле, и as_dict()). + """ + n = 6 + comp_rows = [_comp_row(i) for i in range(1, n + 1)] + sales_rows = [_sales_row(i, total_sqm=4500.0, months=6) for i in range(1, n + 1)] + db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows) + + # 40% строк city-wide медианы — proxy (count*45.0), не реальный deals_total_vol_m2. + with patch( + "app.services.site_finder.velocity._get_ekb_median", + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.4), + ): + result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) + + assert result is not None + assert result.velocity_source == "objective" + assert result.ekb_median_proxy_fraction == pytest.approx(0.4) + assert result.as_dict()["ekb_median_proxy_fraction"] == pytest.approx(0.4) + + +def test_ekb_median_proxy_fraction_surfaced_on_rosreestr_fallback(): + """Audit #2445 A4: proxy_fraction бенчмарка пробрасывается и через + rosreestr_fallback путь (ekb_median посчитан ДО вызова fallback, contamination + в denominator остаётся релевантной вне зависимости от источника velocity).""" + comp_rows = [_comp_row(i) for i in range(1, 3)] + # Ниже _OBJECTIVE_COVERAGE_MIN_RATIO -> forces rosreestr_fallback path. + sales_rows = [_sales_row(1, total_sqm=0.0, months=0, has_mapping=False)] + db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows) + + rr_row = MagicMock() + start_d = datetime.date.fromisoformat("2025-01-01") + end_d = datetime.date.fromisoformat("2025-06-01") + rr_row.__getitem__ = lambda self, k: { + "total_deals": 60, + "period_start": start_d, + "period_end": end_d, + }[k] + rr_result_mock = MagicMock() + rr_result_mock.mappings.return_value.first.return_value = rr_row + + # db.execute called: comp / sales / (rosreestr) — patch execute to append rr result. + original_side_effect = list(db.execute.side_effect) + db.execute.side_effect = [*original_side_effect, rr_result_mock] + + with patch( + "app.services.site_finder.velocity._get_ekb_median", + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.6), + ): + result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT, cad_quarter="66:41:0702048") + + assert result is not None + assert result.velocity_source == "rosreestr_fallback" + assert result.ekb_median_proxy_fraction == pytest.approx(0.6) + assert result.as_dict()["ekb_median_proxy_fraction"] == pytest.approx(0.6) + + def test_objective_path_surfaces_coverage_no_proxy(): """SF#1871 P2 Fix2: objective-путь → proxy_used=False, coverage% посчитан, proxy_sqm=None.""" n = 6 @@ -385,7 +451,7 @@ def test_objective_path_surfaces_coverage_no_proxy(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -449,7 +515,7 @@ def test_objective_path_sets_project_absorption_per_competitor(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -469,7 +535,7 @@ def test_objective_path_project_absorption_none_when_no_sales(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT) @@ -525,7 +591,7 @@ def test_sample_competitors_include_by_room_bucket(): with patch( "app.services.site_finder.velocity._get_ekb_median", - return_value=_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, + return_value=(_EKB_MEDIAN_FALLBACK_SQM_PER_MONTH, 0.0), ): result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)