fix(site-finder): раскрыть proxy-fraction в EKB-median velocity-бенчмарке (#2445-A4) (#2448)
Some checks failed
Deploy / changes (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled

This commit is contained in:
bot-backend 2026-07-05 18:54:42 +00:00
parent c8c96a87ee
commit b40995780b
2 changed files with 152 additions and 26 deletions

View file

@ -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"])
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

View file

@ -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)