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
|
SELECT
|
||||||
COUNT(*) AS units_sold_window,
|
COUNT(*) AS units_sold_window,
|
||||||
COALESCE(SUM(area_pd), 0) AS area_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
|
FROM objective_lots ol
|
||||||
WHERE ol.premise_kind = :premise_kind
|
WHERE ol.premise_kind = :premise_kind
|
||||||
AND (
|
AND (
|
||||||
|
|
@ -477,9 +484,13 @@ def _query_sales_window(
|
||||||
) -> tuple[int, float, dict[str, int]]:
|
) -> tuple[int, float, dict[str, int]]:
|
||||||
"""Продажи за окно по contract_date. Возвращает (units, area_m2, {bucket: units}).
|
"""Продажи за окно по contract_date. Возвращает (units, area_m2, {bucket: units}).
|
||||||
|
|
||||||
GROUP BY ROLLUP: строка с rooms_int IS NULL — это grand-total (берём как
|
GROUP BY ROLLUP с GROUPING(rooms_int) AS is_total (#1214):
|
||||||
units/area), остальные строки — разбивка по комнатности (для liquidity /
|
• is_total=1 → grand-total (units/area за все комнаты);
|
||||||
demand_concentration). На ошибке/пусто → (0, 0.0, {}).
|
• 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:
|
try:
|
||||||
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
||||||
|
|
@ -495,12 +506,17 @@ def _query_sales_window(
|
||||||
for r in rows:
|
for r in rows:
|
||||||
cnt = int(r["units_sold_window"] or 0)
|
cnt = int(r["units_sold_window"] or 0)
|
||||||
area = float(r["area_sold_window"] or 0.0)
|
area = float(r["area_sold_window"] or 0.0)
|
||||||
if r["rooms_int"] is None:
|
if int(r["is_total"]) == 1:
|
||||||
# ROLLUP grand-total.
|
# ROLLUP grand-total — единственная строка с GROUPING=1.
|
||||||
units_total = cnt
|
units_total = cnt
|
||||||
area_total = area
|
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:
|
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
|
return units_total, area_total, by_room
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,12 +341,13 @@ _FULL_STOCK = {
|
||||||
"obj_count": 3,
|
"obj_count": 3,
|
||||||
"n_long_unsold": 42,
|
"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 = [
|
_FULL_SALES = [
|
||||||
{"units_sold_window": 60, "area_sold_window": 2880.0, "rooms_int": None},
|
{"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},
|
{"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},
|
{"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},
|
{"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,
|
"n_long_unsold": 5,
|
||||||
}
|
}
|
||||||
# только grand-total строка с 0 продаж
|
# только 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)
|
db = _mock_db(stock, sales)
|
||||||
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
|
with patch(_ELAST, return_value={"elasticity": -1.5, "source": "fallback"}):
|
||||||
m = compute_market_metrics(db, district="Тихий")
|
m = compute_market_metrics(db, district="Тихий")
|
||||||
|
|
@ -622,10 +625,26 @@ class TestSalesWindowSource:
|
||||||
"units_sold_window": real_window_units,
|
"units_sold_window": real_window_units,
|
||||||
"area_sold_window": 110_000.0,
|
"area_sold_window": 110_000.0,
|
||||||
"rooms_int": None,
|
"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)
|
db = _mock_db(stock, sales)
|
||||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue