From 6a32acb3aaab9d760a1bb1aaca12c6b0db7185be Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 4 Jun 2026 14:05:20 +0500 Subject: [PATCH] fix(competitors): size-weight avg velocity by flats_total (#949 audit, option B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit weighted_avg_velocity was a naive mean despite the name — a 500-flat ЖК weighed the same as a 20-flat one. Now count-weighted by flats_total (sql.md AVG principle): Σ(velocity*flats_total)/Σ(flats_total). Competitors with unknown flats_total are excluded from weights; if sizes are unknown for ALL, graceful fallback to the simple mean (den>0 guard). Field name + API contract UNCHANGED (zero consumer ripple — traced: only CompetitorsSummary, no frontend ref). Tests: equal sizes → weighted==naive (existing 6.0 stays); NEW test with 500-flat@40 + 20-flat@2 → 38.54 (not naive 21.0), proving the weighting. --- .../app/services/site_finder/competitors.py | 15 ++++++-- .../tests/api/v1/test_parcel_competitors.py | 35 ++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index 53cd55d7..fddfcac2 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -559,9 +559,20 @@ def get_competitors( # ── 6. Summary ─────────────────────────────────────────────────────────── active_count = sum(1 for c in competitors if c.is_active) - total_velocity = sum(c.velocity_per_month for c in competitors) n = len(competitors) - weighted_avg_velocity = round(total_velocity / n, 2) if n > 0 else 0.0 + # #949 audit fix: size-weight velocity by flats_total (count-weighted AVG, + # sql.md principle) — ЖК на 500 квартир должен весить больше, чем на 20, а не + # наравне (раньше было наивное среднее вопреки имени weighted_*). Конкуренты с + # неизвестным flats_total исключаются из весов; если размеры неизвестны У ВСЕХ — + # graceful fallback на простое среднее (den>0 guard = NULLIF-эквивалент). + weight_num = sum(c.velocity_per_month * c.flats_total for c in competitors if c.flats_total) + weight_den = sum(c.flats_total for c in competitors if c.flats_total) + if weight_den > 0: + weighted_avg_velocity = round(weight_num / weight_den, 2) + elif n > 0: + weighted_avg_velocity = round(sum(c.velocity_per_month for c in competitors) / n, 2) + else: + weighted_avg_velocity = 0.0 summary = CompetitorsSummary( total_competitors=n, diff --git a/backend/tests/api/v1/test_parcel_competitors.py b/backend/tests/api/v1/test_parcel_competitors.py index 6e16499b..63ca61e4 100644 --- a/backend/tests/api/v1/test_parcel_competitors.py +++ b/backend/tests/api/v1/test_parcel_competitors.py @@ -169,7 +169,7 @@ def test_competitors_summary_calc() -> None: summary = resp.json()["summary"] assert summary["total_competitors"] == 3 assert summary["active_count"] == 2 # sales + construction - # avg velocity = (10+6+2)/3 = 6.0 + # equal flats_total (200 each) → size-weighted AVG == naive: (10+6+2)/3 = 6.0 assert summary["weighted_avg_velocity"] == pytest.approx(6.0) assert summary["radius_km"] == pytest.approx(1.0) assert summary["time_window"] == "last_quarter" @@ -177,6 +177,33 @@ def test_competitors_summary_calc() -> None: app.dependency_overrides.clear() +def test_competitors_weighted_velocity_by_size() -> None: + """weighted_avg_velocity взвешен по flats_total — крупный ЖК весит больше (#949).""" + rows = [ + _obj_row(obj_id=1, velocity=40.0, flat_count=500), + _obj_row(obj_id=2, velocity=2.0, flat_count=20), + ] + db = _make_db(coord=_coord_row(), obj_rows=rows) + + from app.core.db import get_db + + app.dependency_overrides[get_db] = _override_db(db) + try: + client = TestClient(app) + resp = client.post( + "/api/v1/parcels/66:41:0303161:5/competitors", + json={"radius_km": 1.0, "time_window": "last_quarter"}, + ) + assert resp.status_code == 200, resp.text + wav = resp.json()["summary"]["weighted_avg_velocity"] + # size-weighted = (40*500 + 2*20) / (500+20) = 20040/520 ≈ 38.54, + # НЕ наивное среднее (40+2)/2 = 21.0 — доказывает взвешивание по flats_total. + assert wav == pytest.approx(38.54, abs=0.05) + assert wav != pytest.approx(21.0, abs=0.1) + finally: + app.dependency_overrides.clear() + + def test_competitors_exclude_obj_ids() -> None: """exclude_obj_ids исключает указанные ЖК из результата.""" rows = [ @@ -356,9 +383,9 @@ def test_competitors_avg_price_populated() -> None: ) assert resp.status_code == 200, resp.text comp = resp.json()["competitors"][0] - assert comp["avg_price_per_m2"] == pytest.approx( - 150_000.0 - ), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" + assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), ( + "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter" + ) finally: app.dependency_overrides.clear()