fix(site-finder): normalize supply room_bucket vocabulary to velocity side (#1229)
best_layouts._SUPPLY_BATCH_SQL эмитил {studio,euro-1,euro-2,1,2,3,4+},
а _INLINE_VELOCITY_SQL читает {студия,1,2,3,4+} из
objective_corpus_room_month (prod check: 'euro-*' rows отсутствуют).
Эффект: rooms=2 + area<50 уходили в euro-1/euro-2 supply-стороной →
выпадали из знаменателя bucket '2' → sold_pct_of_supply двушек
завышен, is_oversold ложно True. (rb='euro-*') dead lookups в supply_map.
Patch: убраны euro-* WHEN в supply CASE. SF-08 euro-биннинг отложен
до момента когда velocity-сторона начнёт его отдавать. +2 regression
теста (bucket match, string guard). 35 best_layouts тестов зелёные.
Closes #1229
This commit is contained in:
parent
780dd5640c
commit
703d3905b8
2 changed files with 59 additions and 5 deletions
|
|
@ -148,17 +148,20 @@ _INLINE_VELOCITY_SQL = text("""
|
|||
# ── SQL: supply по (room_bucket, area_bin) за последний снимок ───────────────
|
||||
# Один батч-запрос вместо N — возвращает map (rb, ab) → count.
|
||||
# room_bucket и area_bin вычисляются в SQL аналогично layout_signature.py.
|
||||
# Fix #1229: bucket'ы нормализованы под velocity-вокабуляр objective_corpus_room_month
|
||||
# = {studio, 1, 2, 3, 4+}. Раньше supply отдельно отдавал euro-1/euro-2 для rooms=2
|
||||
# (area<35 / area<50) — эти ключи никогда не матчились с velocity (Объектив
|
||||
# euro-форматов не выделяет, см. _INLINE_VELOCITY_SQL passthrough room_bucket).
|
||||
# Последствия: 1) supply '2' искусственно занижен (rooms=2 + area<50 уходили в
|
||||
# euro-*) → sold_pct/is_oversold двушек завышены; 2) euro-* строки supply
|
||||
# становились dead lookup. SF-08 euro-биннинг откладываем до момента, когда его
|
||||
# начнёт отдавать velocity-сторона.
|
||||
|
||||
_SUPPLY_BATCH_SQL = text("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN f.is_studio = TRUE OR f.flat_type = 'Квартира-студия' THEN 'studio'
|
||||
WHEN f.rooms = 0 THEN 'studio'
|
||||
-- Fix SF-08: euro-форматы — DOM.РФ маркирует малогабаритные квартиры как 2-комн.
|
||||
-- rooms=2 + area<35 → euro-1 (студия с отдельной кухней ~26м²)
|
||||
-- rooms=2 + area<50 → euro-2 (~35-50м², евро-двушка)
|
||||
WHEN f.rooms = 2 AND f.total_area < 35 THEN 'euro-1'
|
||||
WHEN f.rooms = 2 AND f.total_area < 50 THEN 'euro-2'
|
||||
WHEN f.rooms IN (1, 2, 3) THEN f.rooms::text
|
||||
WHEN f.rooms >= 4 THEN '4+'
|
||||
ELSE '1'
|
||||
|
|
|
|||
|
|
@ -342,6 +342,57 @@ def test_total_sold_in_window_matches_deals_window() -> None:
|
|||
assert resp.top_layouts[0].total_sold_in_window == int(deals)
|
||||
|
||||
|
||||
# ── Тесты Fix #1229: supply / velocity bucket vocabulary match ────────────────
|
||||
|
||||
|
||||
def test_supply_velocity_buckets_match_for_2_rooms() -> None:
|
||||
"""Fix #1229: supply bucket для 2-комн матчит velocity '2' независимо от площади.
|
||||
|
||||
Раньше supply отдельно вычислял euro-1/euro-2 для rooms=2 + area<50 → эти
|
||||
строки выпадали из знаменателя bucket '2' → sold_pct/is_oversold двушек
|
||||
были завышены. После фикса вся supply rooms=2 идёт в '2' и sold_pct
|
||||
рассчитывается от полного supply.
|
||||
|
||||
Здесь моделируем что весь supply для '2' уже агрегирован SQL-стороной в
|
||||
один (rb='2', ab='40-60') ряд: 20 deals_window против 50 supply → 40% sold.
|
||||
"""
|
||||
deals = 20.0
|
||||
id_rows = [_obj_id_row(7)]
|
||||
vel_rows = [_vel_row("2", deals_window=deals, avg_area=45.0, obj_ids=[7])]
|
||||
supply_rows = [_supply_row("2", "40-60", 50)]
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=id_rows,
|
||||
vel_rows=vel_rows,
|
||||
supply_rows=supply_rows,
|
||||
)
|
||||
req = _request(time_window="last_quarter")
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert len(resp.top_layouts) == 1
|
||||
row = resp.top_layouts[0]
|
||||
assert row.room_bucket == "2"
|
||||
assert row.supply_units_in_radius == 50
|
||||
# 20 / 50 * 100 = 40.0 (раньше при euro-* разделении знаменатель был меньше
|
||||
# → sold_pct > 40 или is_oversold=True).
|
||||
assert row.sold_pct_of_supply == pytest.approx(40.0, rel=1e-3)
|
||||
assert row.is_oversold is False
|
||||
|
||||
|
||||
def test_supply_does_not_emit_euro_buckets() -> None:
|
||||
"""Fix #1229: supply SQL больше НЕ содержит литералов 'euro-1' / 'euro-2'.
|
||||
|
||||
Regression guard: если кто-то восстанавливает SF-08 euro-биннинг в supply
|
||||
без согласования с velocity-стороной (objective_corpus_room_month отдаёт
|
||||
{studio,1,2,3,4+}, не euro-*), sold_pct двушек снова поедет.
|
||||
"""
|
||||
from app.services.site_finder.best_layouts import _SUPPLY_BATCH_SQL
|
||||
|
||||
sql_text = str(_SUPPLY_BATCH_SQL.text)
|
||||
assert "'euro-1'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-1 — see #1229"
|
||||
assert "'euro-2'" not in sql_text, "_SUPPLY_BATCH_SQL вернул euro-2 — see #1229"
|
||||
|
||||
|
||||
# ── Тесты _cap_and_redistribute (Fix SF-09 review) ───────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue