fix(competitors): size-weight avg velocity by flats_total (#949 audit, option B)
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:
parent
b4eb6a8ad5
commit
6a32acb3aa
2 changed files with 44 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue