From 2b3759af6a28c3a389c24c979029f6e26a1aa2ae Mon Sep 17 00:00:00 2001 From: Light1YT Date: Thu, 4 Jun 2026 10:46:50 +0500 Subject: [PATCH] fix(market-metrics): count window sales by contract_date, not 17-day history (#949 CRITICAL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REOPENED. _SALES_WINDOW_SQL derived "sales in window" from objective_lots_history snapshots, but history is only ~17 days deep — every currently-sold lot had a sold-snapshot in the window, so window-sales collapsed into the entire cumulative sold stock (Автовокзал 6mo: 33,245 vs real ~2,308). Inflated absorption_rate (~235%/mo with confidence=high), months_of_supply, unit_velocity, liquidity, demand_concentration → contaminated forecast #950/#952. Count window sales directly from objective_lots by contract_date in the window (the real sale date — present on 100% of sold lots: 41,091/41,091). Return contract of _query_sales_window unchanged (units/area/by-room ROLLUP); downstream formulas untouched. Removed the now-dead objective_lots_history JOIN/CTE. Regression test: lots sold outside window (contract_date out of range) not counted (41,091 cumulative vs 2,308 window → absorption 2.35→0.04). 288 tests green. Verification = prod compute_market_metrics(Автовокзал) post-deploy. Refs #949 --- .../services/site_finder/market_metrics.py | 68 +++++++--------- .../site_finder/test_market_metrics.py | 78 +++++++++++++++++++ 2 files changed, 106 insertions(+), 40 deletions(-) diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index 9a58c5f7..290fa56b 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -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 / diff --git a/backend/tests/services/site_finder/test_market_metrics.py b/backend/tests/services/site_finder/test_market_metrics.py index b16ee75c..d1e5df84 100644 --- a/backend/tests/services/site_finder/test_market_metrics.py +++ b/backend/tests/services/site_finder/test_market_metrics.py @@ -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)