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`):
|
Источники (см. `data/sql/68_schema_objective.sql`):
|
||||||
- `objective_lots` — per-flat текущее состояние (status, is_sold, area_pd,
|
- `objective_lots` — per-flat текущее состояние (status, is_sold, area_pd,
|
||||||
rooms_int, district, premise_kind, sales_start_date).
|
rooms_int, district, premise_kind, sales_start_date) +
|
||||||
- `objective_lots_history` — weekly-снапшоты per-flat: is_sold, contract_date,
|
`contract_date` («Дата договора») — реальная дата сделки,
|
||||||
area_pd — time-series для velocity/sell-through.
|
заполнена у 100% проданных лотов. Используем её и для
|
||||||
|
кумулятивного стока, и для продаж-в-окне (velocity /
|
||||||
|
absorption / sell-through). `objective_lots_history`
|
||||||
|
(weekly-снапшоты) НЕ используем: глубина ~17 дней не даёт
|
||||||
|
корректного окна продаж (см. #949).
|
||||||
- elasticity (price_sensitivity) — переиспользуем
|
- elasticity (price_sensitivity) — переиспользуем
|
||||||
`analytics_queries._elasticity_coef` (objective_corpus_room_month, log-log регрессия).
|
`analytics_queries._elasticity_coef` (objective_corpus_room_month, log-log регрессия).
|
||||||
|
|
||||||
|
|
@ -279,48 +283,32 @@ _STOCK_SQL = text(
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Продажи за окно — из objective_lots_history (weekly timeline). «Продано в окне»
|
# Продажи за окно — напрямую из objective_lots по contract_date («Дата договора»).
|
||||||
# = лот, у которого в окне появилась contract_date ИЛИ is_sold стал TRUE. Берём
|
# «Продано в окне» = лот с contract_date внутри окна. contract_date — реальная дата
|
||||||
# по одному событию на лот (MIN snapshot где он впервые помечен проданным),
|
# сделки и заполнен у 100% проданных лотов, поэтому источник надёжен и не требует
|
||||||
# чтобы не считать один и тот же лот многократно из-за повторных снапшотов.
|
# истории. (Раньше считали через objective_lots_history-снапшоты, но history глубиной
|
||||||
# area_pd берём из того же history-снапшота (последний known area для лота).
|
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
|
||||||
|
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
|
||||||
|
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
|
||||||
|
# area_pd берём из самого objective_lots (текущий per-flat area).
|
||||||
_SALES_WINDOW_SQL = text(
|
_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
|
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
|
||||||
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)
|
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(
|
def _query_sales_window(
|
||||||
db: Session, params: Mapping[str, Any]
|
db: Session, params: Mapping[str, Any]
|
||||||
) -> tuple[int, float, dict[str, int]]:
|
) -> 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 (берём как
|
GROUP BY ROLLUP: строка с rooms_int IS NULL — это grand-total (берём как
|
||||||
units/area), остальные строки — разбивка по комнатности (для liquidity /
|
units/area), остальные строки — разбивка по комнатности (для liquidity /
|
||||||
|
|
|
||||||
|
|
@ -482,3 +482,81 @@ class TestComputeMarketMetricsThinData:
|
||||||
assert m.n_lots == 0
|
assert m.n_lots == 0
|
||||||
assert m.confidence == "low"
|
assert m.confidence == "low"
|
||||||
assert m.as_dict()["unit_velocity"] is None
|
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