fix(market-metrics): window sales by contract_date (#949 CRITICAL) #1036

Merged
bot-backend merged 1 commit from fix/949-absorption-contract-date into main 2026-06-04 05:46:57 +00:00
2 changed files with 106 additions and 40 deletions

View file

@ -8,9 +8,13 @@ forecasting-эпики (#950/#952) и relevance-модель (#949 PR B).
Источники (см. `data/sql/68_schema_objective.sql`):
- `objective_lots` per-flat текущее состояние (status, is_sold, area_pd,
rooms_int, district, premise_kind, sales_start_date).
- `objective_lots_history` weekly-снапшоты per-flat: is_sold, contract_date,
area_pd time-series для velocity/sell-through.
rooms_int, district, premise_kind, sales_start_date) +
`contract_date` («Дата договора») реальная дата сделки,
заполнена у 100% проданных лотов. Используем её и для
кумулятивного стока, и для продаж-в-окне (velocity /
absorption / sell-through). `objective_lots_history`
(weekly-снапшоты) НЕ используем: глубина ~17 дней не даёт
корректного окна продаж (см. #949).
- elasticity (price_sensitivity) переиспользуем
`analytics_queries._elasticity_coef` (objective_corpus_room_month, log-log регрессия).
@ -279,48 +283,32 @@ _STOCK_SQL = text(
"""
)
# Продажи за окно — из objective_lots_history (weekly timeline). «Продано в окне»
# = лот, у которого в окне появилась contract_date ИЛИ is_sold стал TRUE. Берём
# по одному событию на лот (MIN snapshot где он впервые помечен проданным),
# чтобы не считать один и тот же лот многократно из-за повторных снапшотов.
# area_pd берём из того же history-снапшота (последний known area для лота).
# Продажи за окно — напрямую из objective_lots по contract_date («Дата договора»).
# «Продано в окне» = лот с contract_date внутри окна. contract_date — реальная дата
# сделки и заполнен у 100% проданных лотов, поэтому источник надёжен и не требует
# истории. (Раньше считали через objective_lots_history-снапшоты, но history глубиной
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
# area_pd берём из самого objective_lots (текущий per-flat area).
_SALES_WINDOW_SQL = text(
"""
WITH hist AS (
SELECT
h.objective_lot_id,
h.snapshot_date,
h.area_pd,
(h.is_sold IS TRUE OR h.contract_date IS NOT NULL) AS sold_flag
FROM objective_lots_history h
JOIN objective_lots ol
ON ol.objective_lot_id = h.objective_lot_id
WHERE ol.premise_kind = :premise_kind
AND (
CAST(:district AS text) IS NULL
OR ol.district = CAST(:district AS text)
)
AND (
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
)
AND h.snapshot_date >= CURRENT_DATE - CAST(:window_interval AS interval)
),
first_sold AS (
SELECT DISTINCT ON (objective_lot_id)
objective_lot_id,
ol2.rooms_int,
hist.area_pd
FROM hist
JOIN objective_lots ol2 USING (objective_lot_id)
WHERE hist.sold_flag
ORDER BY objective_lot_id, hist.snapshot_date ASC
)
SELECT
COUNT(*) AS units_sold_window,
COALESCE(SUM(area_pd), 0) AS area_sold_window,
rooms_int
FROM first_sold
FROM objective_lots ol
WHERE ol.premise_kind = :premise_kind
AND (
CAST(:district AS text) IS NULL
OR ol.district = CAST(:district AS text)
)
AND (
CAST(:has_obj_ids AS boolean) IS FALSE
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
)
AND ol.contract_date IS NOT NULL
AND ol.contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
GROUP BY ROLLUP (rooms_int)
"""
)
@ -451,7 +439,7 @@ def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
def _query_sales_window(
db: Session, params: Mapping[str, Any]
) -> tuple[int, float, dict[str, int]]:
"""Продажи за окно из history. Возвращает (units, area_m2, {bucket: units}).
"""Продажи за окно по contract_date. Возвращает (units, area_m2, {bucket: units}).
GROUP BY ROLLUP: строка с rooms_int IS NULL это grand-total (берём как
units/area), остальные строки разбивка по комнатности (для liquidity /

View file

@ -482,3 +482,81 @@ class TestComputeMarketMetricsThinData:
assert m.n_lots == 0
assert m.confidence == "low"
assert m.as_dict()["unit_velocity"] is None
class TestSalesWindowSource:
"""#949 REOPENED: продажи-в-окне считаются по objective_lots.contract_date,
а НЕ по objective_lots_history-снапшотам.
Корень бага: history глубиной ~17 дней у каждого сейчас-проданного лота есть
sold-снапшот в окне «продажи в окне» схлопывались в весь кумулятивный сток
(Автовокзал 6mo: ~33 245 ед. вместо реальных ~2 308), завышая absorption /
velocity / months_of_supply / liquidity / demand_concentration. Фикс: считать по
contract_date в окне он реальная дата сделки и заполнен у 100% проданных лотов.
"""
def test_sales_window_queries_contract_date_not_history(self) -> None:
db = _mock_db(_FULL_STOCK, _FULL_SALES)
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
compute_market_metrics(db, district="Автовокзал", window_months=6)
sales_sql = _executed_sql(db, 1)
# Окно теперь по contract_date из objective_lots — без зависимости от history.
assert "objective_lots_history" not in sales_sql
assert "ol.contract_date IS NOT NULL" in sales_sql
assert "ol.contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)" in sales_sql
assert "FROM objective_lots ol" in sales_sql
# Контракт окна сохранён: ROLLUP по комнатности + interval-bind через CAST.
assert "GROUP BY ROLLUP (rooms_int)" in sales_sql
assert ":window_interval::" not in sales_sql
def test_lots_sold_outside_window_not_counted(self) -> None:
"""Регрессия бага: лоты, проданные ДАВНО (contract_date вне окна), но всё ещё
присутствующие как проданный сток, НЕ попадают в продажи-в-окне.
Раньше (history-подход) такой лот считался «проданным в окне» из-за свежего
снапшота раздувал absorption. Теперь WHERE по contract_date их отсекает на
стороне БД, поэтому в окно попадают только реально-свежие сделки.
Сток: 41 091 проданных кумулятивно (как на проде), но в окне только 2 308
реальных новых сделок по contract_date. absorption обязан считаться от 2 308,
а НЕ от кумулятивного стока реалистичный (~доли в месяц), не 235%/мес.
"""
# Кумулятивный сток (cumulative — корректен для n_sold / sell_through / confidence).
stock = {
"n_lots": 50_000,
"n_sold": 41_091,
"n_available": 8_909,
"obj_count": 40,
"n_long_unsold": 1_000,
}
# Окно (contract_date-фильтр на стороне БД) отдаёт ТОЛЬКО реально-свежие сделки.
# Давно-проданные лоты (contract_date вне окна) сюда не попадают вовсе.
real_window_units = 2_308
sales = [
{
"units_sold_window": real_window_units,
"area_sold_window": 110_000.0,
"rooms_int": None,
},
{"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"}):
m = compute_market_metrics(db, district="Автовокзал", window_months=6)
# velocity = 2 308 / 6, НЕ 41 091 / 6.
assert m.unit_velocity == real_window_units / 6
# absorption = (2 308 / 6) / 8 909 ≈ 0.043/мес — реалистично (доли единицы),
# а НЕ ~2.35 (235%/мес), как давал баг от кумулятивного стока.
assert m.absorption_rate is not None
assert m.absorption_rate < 0.1
assert round(m.absorption_rate, 4) == round((real_window_units / 6) / 8_909, 4)
# months_of_supply = 8 909 / (2 308 / 6) ≈ 23 мес — конечное, осмысленное.
assert m.months_of_supply is not None
assert 20.0 < m.months_of_supply < 25.0
# Кумулятив остаётся кумулятивным (не окно): n_sold / sell_through нетронуты.
assert m.n_sold == 41_091
assert m.sell_through_pct is not None
assert round(m.sell_through_pct, 1) == round(41_091 / 50_000 * 100, 1)