fix(market_metrics): disambiguate ROLLUP grand-total via GROUPING() (#1214)
_SALES_WINDOW_SQL делал GROUP BY ROLLUP (rooms_int), rooms_int nullable (ETL пишет NULL для «неопределённого типа», sales_series.py:399 явно обрабатывает None). Проданный лот с rooms_int IS NULL даёт ДВЕ строки rooms_int IS NULL (NULL-группа + grand-total итог), неразличимые в Python (оба if r["rooms_int"] is None). MixedAggregate-план PG16 эмитит grand-total ПЕРВЫМ (среди hash-строк), NULL-группа после → loop затирает units_total частичным счётом (живой тест на PG16: 2000 → 200). Эффект: unit_velocity / absorption_rate занижены, months_of_supply завышен → base_pace в demand_supply_forecast неверный (recommendation.py:586) → reports/scoring врёт. Patch: - SQL: добавить GROUPING(rooms_int) AS is_total (=1 для grand-total). - Python: ветвить по is_total, NULL-комнатную группу класть в by_room['unknown'] (отдельный бакет), аккумулировать через += вместо assign (защита от будущих NULL-вариантов). - Тесты: моки получили "is_total" поле (1 для grand-total, 0 иначе). 71/71 market_metrics тестов зелёные. ruff clean. Closes #1214
This commit is contained in:
parent
8f3e461959
commit
bc2d393b05
2 changed files with 51 additions and 16 deletions
|
|
@ -320,7 +320,14 @@ _SALES_WINDOW_SQL = text(
|
|||
SELECT
|
||||
COUNT(*) AS units_sold_window,
|
||||
COALESCE(SUM(area_pd), 0) AS area_sold_window,
|
||||
rooms_int
|
||||
rooms_int,
|
||||
-- #1214: ROLLUP grand-total и NULL-группа дают rooms_int IS NULL обе.
|
||||
-- Различаем через GROUPING(): =1 для grand-total, =0 для NULL-комнатной
|
||||
-- группы. Без этого один проданный лот с rooms_int IS NULL даёт ДВЕ
|
||||
-- строки rooms_int IS NULL (NULL-группа + итог), и MixedAggregate-план
|
||||
-- эмитит итог ПЕРВЫМ → NULL-группа затирает units_total частичным
|
||||
-- счётом → unit_velocity/absorption занижены, MoS завышен.
|
||||
GROUPING(rooms_int) AS is_total
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
AND (
|
||||
|
|
@ -477,9 +484,13 @@ def _query_sales_window(
|
|||
) -> tuple[int, float, dict[str, int]]:
|
||||
"""Продажи за окно по contract_date. Возвращает (units, area_m2, {bucket: units}).
|
||||
|
||||
GROUP BY ROLLUP: строка с rooms_int IS NULL — это grand-total (берём как
|
||||
units/area), остальные строки — разбивка по комнатности (для liquidity /
|
||||
demand_concentration). На ошибке/пусто → (0, 0.0, {}).
|
||||
GROUP BY ROLLUP с GROUPING(rooms_int) AS is_total (#1214):
|
||||
• is_total=1 → grand-total (units/area за все комнаты);
|
||||
• is_total=0 и rooms_int IS NULL → разбивка для лотов БЕЗ rooms — кладём
|
||||
в by_room['unknown'] (а не путаем с total);
|
||||
• is_total=0 и rooms_int не NULL → разбивка по конкретной комнатности.
|
||||
by_room аккумулирует через += чтобы при будущих доп.NULL-вариантах не
|
||||
затирать прежние счётчики. На ошибке/пусто → (0, 0.0, {}).
|
||||
"""
|
||||
try:
|
||||
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
||||
|
|
@ -495,12 +506,17 @@ def _query_sales_window(
|
|||
for r in rows:
|
||||
cnt = int(r["units_sold_window"] or 0)
|
||||
area = float(r["area_sold_window"] or 0.0)
|
||||
if r["rooms_int"] is None:
|
||||
# ROLLUP grand-total.
|
||||
if int(r["is_total"]) == 1:
|
||||
# ROLLUP grand-total — единственная строка с GROUPING=1.
|
||||
units_total = cnt
|
||||
area_total = area
|
||||
elif r["rooms_int"] is None:
|
||||
# Лоты с rooms_int IS NULL (ETL пишет NULL для «неопределённого типа»)
|
||||
# — отдельный бакет, не путаем с total.
|
||||
by_room["unknown"] = by_room.get("unknown", 0) + cnt
|
||||
else:
|
||||
by_room[_room_bucket(int(r["rooms_int"]))] = cnt
|
||||
bucket = _room_bucket(int(r["rooms_int"]))
|
||||
by_room[bucket] = by_room.get(bucket, 0) + cnt
|
||||
return units_total, area_total, by_room
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -341,12 +341,13 @@ _FULL_STOCK = {
|
|||
"obj_count": 3,
|
||||
"n_long_unsold": 42,
|
||||
}
|
||||
# ROLLUP: grand-total (rooms_int=None) + per-room buckets.
|
||||
# ROLLUP с GROUPING(rooms_int) AS is_total (#1214):
|
||||
# is_total=1 → grand-total строка; is_total=0 → разбивка по rooms_int.
|
||||
_FULL_SALES = [
|
||||
{"units_sold_window": 60, "area_sold_window": 2880.0, "rooms_int": None},
|
||||
{"units_sold_window": 30, "area_sold_window": 900.0, "rooms_int": 1},
|
||||
{"units_sold_window": 20, "area_sold_window": 1200.0, "rooms_int": 2},
|
||||
{"units_sold_window": 10, "area_sold_window": 780.0, "rooms_int": 0},
|
||||
{"units_sold_window": 60, "area_sold_window": 2880.0, "rooms_int": None, "is_total": 1},
|
||||
{"units_sold_window": 30, "area_sold_window": 900.0, "rooms_int": 1, "is_total": 0},
|
||||
{"units_sold_window": 20, "area_sold_window": 1200.0, "rooms_int": 2, "is_total": 0},
|
||||
{"units_sold_window": 10, "area_sold_window": 780.0, "rooms_int": 0, "is_total": 0},
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -544,7 +545,9 @@ class TestComputeMarketMetricsThinData:
|
|||
"n_long_unsold": 5,
|
||||
}
|
||||
# только grand-total строка с 0 продаж
|
||||
sales = [{"units_sold_window": 0, "area_sold_window": 0.0, "rooms_int": None}]
|
||||
sales = [
|
||||
{"units_sold_window": 0, "area_sold_window": 0.0, "rooms_int": None, "is_total": 1}
|
||||
]
|
||||
db = _mock_db(stock, sales)
|
||||
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
|
||||
m = compute_market_metrics(db, district="Тихий")
|
||||
|
|
@ -622,10 +625,26 @@ class TestSalesWindowSource:
|
|||
"units_sold_window": real_window_units,
|
||||
"area_sold_window": 110_000.0,
|
||||
"rooms_int": None,
|
||||
"is_total": 1,
|
||||
},
|
||||
{
|
||||
"units_sold_window": 1_000,
|
||||
"area_sold_window": 40_000.0,
|
||||
"rooms_int": 1,
|
||||
"is_total": 0,
|
||||
},
|
||||
{
|
||||
"units_sold_window": 800,
|
||||
"area_sold_window": 44_000.0,
|
||||
"rooms_int": 2,
|
||||
"is_total": 0,
|
||||
},
|
||||
{
|
||||
"units_sold_window": 508,
|
||||
"area_sold_window": 26_000.0,
|
||||
"rooms_int": 3,
|
||||
"is_total": 0,
|
||||
},
|
||||
{"units_sold_window": 1_000, "area_sold_window": 40_000.0, "rooms_int": 1},
|
||||
{"units_sold_window": 800, "area_sold_window": 44_000.0, "rooms_int": 2},
|
||||
{"units_sold_window": 508, "area_sold_window": 26_000.0, "rooms_int": 3},
|
||||
]
|
||||
db = _mock_db(stock, sales)
|
||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue