fix(competitors): size-weight avg velocity by flats_total (#949 audit, option B)
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m32s
Deploy / build-worker (push) Successful in 3m10s
Deploy / deploy (push) Successful in 1m56s

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.
This commit is contained in:
Light1YT 2026-06-04 14:05:20 +05:00
parent b4eb6a8ad5
commit 6a32acb3aa
2 changed files with 44 additions and 6 deletions

View file

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

View file

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