fix(market-metrics): window sales by contract_date (#949 CRITICAL) #1036
2 changed files with 106 additions and 40 deletions
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue