fix(objective): physflat-dedup current-state consumers + honest n_sold window (#1964)
objective_lots — current-state UPSERT (UNIQUE objective_lot_id, 5 snapshot_date), но Объектив присваивает ОДНОМУ физлоту (project,corpus,section,floor,lot_number) несколько lot_id за пере-листинги → таблица раздута ~2.91× (прод: 1.75M квартир- строк vs 603k физлотов). История — в отдельной objective_lots_history (не трогаем). STEP 1: миграция 175 — VIEW v_objective_lots_latest (DISTINCT ON physflat-ключ, последний снапшот). ol.* стабилен (51==51 колонок). DRY-RUN на проде: 603 049 квартир vs 1 753 283 raw. STEP 2: репойнт current-state консьюмеров objective_lots → v_objective_lots_latest: - supply_layers._L1_OPEN_SQL (L1 открытое предложение → дефицит-форсайт; прод: Юго-Западный комфорт 58 606 → 12 620) - competitors._SOLD_COUNT_SQL (+ комментарий: COUNT(DISTINCT lot_id) СОХРАНЁН для fan-out-защиты маппинга, не COUNT(*) — view гарантирует physflat-дедуп, DISTINCT гарантирует mapping-fan-out-safety) - parcels.py obj_pricing CTE (карточка конкурента units_sold/available) - special_indices._ARTIFICIAL_DEMAND_SQL - parcels.py district price block + geo-radius median (sample_size/n) - concepts._OBJECTIVE_MEDIAN_SQL (гейт n≥10) - landing KPI3 (% квартир с ценой) - admin_scrape coverage: `lots` оставлен сырым (ETL-fidelity vs SQLite), добавлен `lots_physflat` #1959 inline-дедуп в market_metrics НЕ рефакторим — добавлен комментарий об общем physflat-ключе с view. STEP 3: report_assembler._deal_count теперь = unit_velocity × window_months (оконные продажи), НЕ кумулятивный n_sold. confidence_engine помечает фактор «за 6 мес» и гейтит порогами окна (high≥50) — кумулятив (прод EKB ~380 921) делал гейт бессмысленным и подпись лживой; оконное (~24 876 за 6 мес) честно. Тесты: +guard'ы (L1/sold-count читают view, deal_count оконный). 963 passed.
This commit is contained in:
parent
91edef1524
commit
e67cb721bf
13 changed files with 181 additions and 33 deletions
|
|
@ -684,12 +684,18 @@ def objective_coverage(
|
|||
"""
|
||||
from app.services.objective_etl import get_sqlite_info
|
||||
|
||||
# #1964: `lots` = СЫРОЙ COUNT(*) — намеренно. Этот счётчик сравнивается с
|
||||
# SQLite-источником Антона (ETL-fidelity: «свежее ли SQLite, чем БД»), а SQLite
|
||||
# хранит сырые строки → дедуп-view сломал бы сравнение. Рядом отдаём
|
||||
# `lots_physflat` (v_objective_lots_latest — по физлоту) для прозрачности
|
||||
# инфляции (~2.91×: сырых ~1.75M vs физлотов ~603k).
|
||||
pg_row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM objective_lots) AS lots,
|
||||
(SELECT COUNT(*) FROM v_objective_lots_latest) AS lots_physflat,
|
||||
(SELECT COUNT(*) FROM objective_corpus_room_month) AS crm,
|
||||
(SELECT COUNT(*) FROM objective_complex_mapping) AS mappings,
|
||||
(SELECT MAX(snapshot_date) FROM objective_lots) AS last_lot_snapshot,
|
||||
|
|
@ -706,6 +712,7 @@ def objective_coverage(
|
|||
return {
|
||||
"pg": {
|
||||
"lots": int(pg_row["lots"] or 0),
|
||||
"lots_physflat": int(pg_row["lots_physflat"] or 0),
|
||||
"corp_room_month": int(pg_row["crm"] or 0),
|
||||
"mappings": int(pg_row["mappings"] or 0),
|
||||
"last_lot_snapshot": pg_row["last_lot_snapshot"].isoformat()
|
||||
|
|
|
|||
|
|
@ -72,11 +72,14 @@ _DISTRICT_FOR_POINT_SQL = text(
|
|||
|
||||
# Медиана цены продажи жилья из объявлений Objective по району + размер выборки.
|
||||
# price_per_m2_rub — РУБЛИ. Санитарный диапазон отсекает мусор. CAST psycopg v3.
|
||||
# #1964: читаем v_objective_lots_latest (physflat-дедуп view) — сырой objective_lots
|
||||
# раздут ~2.91× (мульти lot_id на физлот) → sample_size и гейт n≥:_MIN_OBJECTIVE_SAMPLE
|
||||
# были по пере-листингам, а не по физическим квартирам; вес медианы тоже смещался.
|
||||
_OBJECTIVE_MEDIAN_SQL = text(
|
||||
"""
|
||||
SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price_per_m2_rub) AS median_ppm2,
|
||||
COUNT(*) AS sample_size
|
||||
FROM objective_lots
|
||||
FROM v_objective_lots_latest
|
||||
WHERE district = CAST(:dn AS text)
|
||||
AND price_per_m2_rub IS NOT NULL
|
||||
AND price_per_m2_rub BETWEEN CAST(:lo AS numeric) AND CAST(:hi AS numeric)
|
||||
|
|
|
|||
|
|
@ -69,12 +69,14 @@ def _query_stats(db: Session) -> dict[str, Any] | None:
|
|||
deals_total = int(row_deals or 0)
|
||||
|
||||
# KPI 3: % objective_lots с ценой
|
||||
# #1964: physflat-дедуп view — доля квартир с ценой по ФИЗИЧЕСКИМ лотам, а не
|
||||
# по пере-листингам (сырой objective_lots раздут ~2.91× мульти lot_id/физлот).
|
||||
row_price = db.execute(
|
||||
text(
|
||||
"SELECT"
|
||||
" COUNT(*) FILTER (WHERE price_per_m2_rub IS NOT NULL) * 100.0"
|
||||
" / NULLIF(COUNT(*), 0)"
|
||||
" FROM objective_lots"
|
||||
" FROM v_objective_lots_latest"
|
||||
)
|
||||
).scalar()
|
||||
price_coverage_pct = round(float(row_price or 0.0), 1)
|
||||
|
|
|
|||
|
|
@ -1874,6 +1874,11 @@ def analyze_parcel(
|
|||
-- AVG/COUNT per domrf_obj_id независимы между группами, поэтому
|
||||
-- ограничение scope даёт байт-идентичные значения для тех obj_id,
|
||||
-- что реально джойнятся ниже (далёкие медианы раньше считались зря).
|
||||
-- #1964: читаем из v_objective_lots_latest (physflat-дедуп view) —
|
||||
-- сырой objective_lots раздут ~2.91× (мульти lot_id на физлот) →
|
||||
-- units_sold/units_available карточки конкурента были завышены.
|
||||
-- View = 1 строка на физлот; маппинг прод 1:1 по domrf_obj_id
|
||||
-- (303 строки = 303 distinct) → COUNT(*) корректен.
|
||||
SELECT
|
||||
cm.domrf_obj_id,
|
||||
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
|
||||
|
|
@ -1884,7 +1889,8 @@ def analyze_parcel(
|
|||
WHERE ol.price_per_m2_rub IS NOT NULL
|
||||
) AS lots_with_price
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
|
||||
JOIN v_objective_lots_latest ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)
|
||||
GROUP BY cm.domrf_obj_id
|
||||
)
|
||||
|
|
@ -2535,6 +2541,10 @@ def analyze_parcel(
|
|||
dp_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
-- #1964: physflat-дедуп view вместо сырого objective_lots
|
||||
-- (раздут ~2.91× мульти lot_id на физлот) → sample_size и
|
||||
-- вес медианы теперь по физическим квартирам, не по
|
||||
-- пере-листингам.
|
||||
SELECT
|
||||
MIN(price_per_m2_rub) AS price_min,
|
||||
MAX(price_per_m2_rub) AS price_max,
|
||||
|
|
@ -2542,7 +2552,7 @@ def analyze_parcel(
|
|||
ORDER BY price_per_m2_rub
|
||||
) AS price_median,
|
||||
COUNT(*) AS sample_size
|
||||
FROM objective_lots
|
||||
FROM v_objective_lots_latest
|
||||
WHERE district = CAST(:dn AS text)
|
||||
AND price_per_m2_rub IS NOT NULL
|
||||
AND price_per_m2_rub BETWEEN 30000 AND 600000
|
||||
|
|
@ -2903,13 +2913,16 @@ def analyze_parcel(
|
|||
grp_row = (
|
||||
db.execute(
|
||||
text("""
|
||||
-- #1964: physflat-дедуп view (сырой objective_lots
|
||||
-- раздут ~2.91× мульти lot_id на физлот) → n и вес
|
||||
-- медианы по физическим квартирам, гейт n≥10 честный.
|
||||
SELECT
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
|
||||
AS median,
|
||||
count(*) AS n,
|
||||
count(DISTINCT c.id) AS n_complexes
|
||||
FROM complexes c
|
||||
JOIN objective_lots ol
|
||||
JOIN v_objective_lots_latest ol
|
||||
ON ol.complex_id = c.id
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
WHERE c.latitude IS NOT NULL
|
||||
|
|
|
|||
|
|
@ -152,17 +152,31 @@ def _as_dict_list(items: Sequence[Any] | None) -> list[dict[str, Any]]:
|
|||
|
||||
|
||||
def _deal_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
|
||||
"""Число сделок (продаж) за окно — для deal_count #990. PURE.
|
||||
"""Число сделок (продаж) ЗА ОКНО — для deal_count #990. PURE.
|
||||
|
||||
Предпочитаем `market_metrics.n_sold` (§9.2 — прямой счётчик проданных лотов выборки);
|
||||
fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
|
||||
сделок — пошло бы в analog_count). Нет сигнала → None (#990 → тянет в low).
|
||||
#1964: НЕ берём `market_metrics.n_sold` — это КУМУЛЯТИВНЫЙ all-time проданный сток
|
||||
(десятки-сотни тысяч), а #990/confidence_engine помечает фактор «за {window} мес»
|
||||
(6) и гейтит порогами 6-мес окна (high≥50). n_sold ≫ 50 ВСЕГДА → гейт бессмыслен и
|
||||
подпись врёт. Берём оконное число = unit_velocity × window_months, где unit_velocity
|
||||
(§9.2) = units_sold_window / window_months из _SALES_WINDOW_SQL (он
|
||||
contract_date-windowed) → произведение восстанавливает units_sold за окно. Так
|
||||
подпись «за 6 мес» честная и порог 50/15 осмысленный (прод EKB: ~24 876 за 6 мес
|
||||
vs ~380 921 кумулятивно).
|
||||
|
||||
unit_velocity отсутствует (None / нет market_metrics) → None (#990 трактует как
|
||||
«нет данных», тянет в low). window_months ≤ 0 / нет → None (нечем масштабировать).
|
||||
Fallback на `analyze.market_pulse.competitors_total` НЕ берём (это число ЖК, не
|
||||
сделок — пошло бы в analog_count).
|
||||
"""
|
||||
if market_metrics is not None:
|
||||
n_sold = market_metrics.get("n_sold")
|
||||
if isinstance(n_sold, int):
|
||||
return n_sold
|
||||
return None
|
||||
if market_metrics is None:
|
||||
return None
|
||||
unit_velocity = market_metrics.get("unit_velocity")
|
||||
window_months = market_metrics.get("window_months")
|
||||
if not isinstance(unit_velocity, int | float):
|
||||
return None
|
||||
if not isinstance(window_months, int) or window_months <= 0:
|
||||
return None
|
||||
return round(float(unit_velocity) * window_months)
|
||||
|
||||
|
||||
def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None) -> int | None:
|
||||
|
|
@ -239,7 +253,8 @@ def _deal_count_months(market_metrics: dict[str, Any] | None) -> int | None:
|
|||
"""Окно наблюдения для deal_count (мес) — для deal_count_months #990. PURE.
|
||||
|
||||
Читает тот же `market_metrics.window_months` (§9.2), что и `_history_months` —
|
||||
именно за это окно считается n_sold. Нет → None (#990 пропускает суффикс «за N мес»).
|
||||
#1964: именно за это окно `_deal_count` считает сделки (unit_velocity ×
|
||||
window_months). Нет → None (#990 пропускает суффикс «за N мес»).
|
||||
"""
|
||||
if market_metrics is not None:
|
||||
window = market_metrics.get("window_months")
|
||||
|
|
|
|||
|
|
@ -828,13 +828,16 @@ def _unavailable(key: str, *, reason: str) -> SpecialIndex:
|
|||
# `market_metrics._SALES_WINDOW_SQL`: bind `:has_district` (boolean) переключает
|
||||
# ветку, `:districts` (text[]) — список микро. «Продан» = is_sold IS TRUE ИЛИ
|
||||
# contract_date заполнена. psycopg v3: CAST, НИКОГДА :x::type.
|
||||
# #1964: читаем из v_objective_lots_latest (physflat-дедуп view) — сырой
|
||||
# objective_lots раздут ~2.91× (мульти lot_id на физлот); доля ипотеки —
|
||||
# отношение, к дублям устойчива, но n_sold/n_mortgage теперь честные (по физлотам).
|
||||
_ARTIFICIAL_DEMAND_SQL = text(
|
||||
"""
|
||||
WITH sold AS (
|
||||
SELECT
|
||||
ol.objective_lot_id,
|
||||
(ol.encumbrance_type IS NOT NULL OR ol.bank_name IS NOT NULL) AS has_mortgage
|
||||
FROM objective_lots ol
|
||||
FROM v_objective_lots_latest ol
|
||||
WHERE ol.premise_kind = CAST(:premise_kind AS text)
|
||||
AND (ol.is_sold IS TRUE OR ol.contract_date IS NOT NULL)
|
||||
AND (
|
||||
|
|
|
|||
|
|
@ -491,10 +491,18 @@ _AVG_PRICE_SQL = text("""
|
|||
# COUNT(DISTINCT ol.objective_lot_id) — дедуп на уровне лота, fan-out-safe.
|
||||
# (_OBJECTIVE_PRICE_FALLBACK не прецедент: percentile/median dup-нечувствительны.)
|
||||
#
|
||||
# #1964: objective_lots — current-state UPSERT, но НЕ «ровно одна строка на лот».
|
||||
# UNIQUE = objective_lot_id, а Объектив за пере-листинги присваивает ОДНОМУ
|
||||
# физлоту (project,corpus,section,floor,lot) НЕСКОЛЬКО objective_lot_id → таблица
|
||||
# раздута ~2.91× → flats_sold завышался. Читаем из v_objective_lots_latest
|
||||
# (physflat-дедуп view, по строке на физлот). В view objective_lot_id уникален
|
||||
# (603k строк = 603k distinct lot_id), поэтому COUNT(DISTINCT ol.objective_lot_id)
|
||||
# == COUNT(*) по физлотам — НО оставляем DISTINCT: маппинг 1:N по domrf_obj_id
|
||||
# (см. выше) при будущем fan-out задвоил бы строки view, COUNT(*) был бы не
|
||||
# fan-out-safe. DISTINCT даёт И physflat-дедуп (через view), И fan-out-защиту.
|
||||
#
|
||||
# premise_kind='квартира' — как в _STOCK_SQL: без него проданные паркинги/
|
||||
# кладовые/нежилое считались бы как проданные квартиры (over-count).
|
||||
# objective_lots — current-state UPSERT (ON CONFLICT (objective_lot_id) DO
|
||||
# UPDATE, ровно одна строка на лот) → snapshot-дедуп НЕ нужен (как в _STOCK_SQL).
|
||||
#
|
||||
# Дефолт premise_kind зеркалит market_metrics.compute_market_metrics
|
||||
# (premise_kind="квартира") — оба считают продажи жилых лотов.
|
||||
|
|
@ -508,7 +516,7 @@ _SOLD_COUNT_SQL = text("""
|
|||
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
||||
) AS flats_sold
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN objective_lots ol
|
||||
JOIN v_objective_lots_latest ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND ol.premise_kind = :premise_kind
|
||||
|
|
|
|||
|
|
@ -290,8 +290,11 @@ def _room_bucket(rooms_int: int | None) -> str:
|
|||
# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных
|
||||
# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated),
|
||||
# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп
|
||||
# здесь касается ТОЛЬКО выборки внутри метрик — дедуп самой сырой таблицы
|
||||
# objective_lots (для всех platform-консьюмеров) остаётся отдельной задачей #1964.
|
||||
# здесь касается ТОЛЬКО выборки внутри метрик; #1964 ввёл общий view
|
||||
# v_objective_lots_latest для ОСТАЛЬНЫХ current-state консьюмеров — он использует
|
||||
# ТОТ ЖЕ physflat-ключ/направления (project,corpus,section,floor,lot_number;
|
||||
# snapshot_date DESC, id DESC), что и этот inline DISTINCT ON (инвариант общий).
|
||||
# Этот блок НЕ рефакторим на view (zero behavior change, лишний риск).
|
||||
#
|
||||
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
|
||||
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр
|
||||
|
|
|
|||
|
|
@ -184,6 +184,12 @@ def _l2_confidence(n_objects_total: int, n_objects_with_free_flats: int) -> Conf
|
|||
# Доступно = выставлено и НЕ продано. is_sold распознаём И флагом, И статусом 'продан'
|
||||
# / наличием contract_date (Объектив заполняет неконсистентно — зеркало _STOCK_SQL).
|
||||
# Группируем по (district, class). class — lowercase в lots (м.68 коммент).
|
||||
#
|
||||
# #1964: читаем из v_objective_lots_latest (physflat-дедуп view), НЕ из сырого
|
||||
# objective_lots. Сырая таблица раздута ~2.91× (мульти objective_lot_id на один
|
||||
# физлот через пере-листинги) → units_available по району был завышен ~3-4.6×
|
||||
# (прод: Юго-Западный комфорт 58 606 → 12 620) → дефицит-форсайт переоценивал
|
||||
# открытое предложение L1. View отдаёт по строке на физический лот.
|
||||
_L1_OPEN_SQL = text(
|
||||
"""
|
||||
WITH lots AS (
|
||||
|
|
@ -196,7 +202,7 @@ _L1_OPEN_SQL = text(
|
|||
OR ol.contract_date IS NOT NULL
|
||||
OR LOWER(COALESCE(ol.status, '')) = 'продан'
|
||||
) AS sold_now
|
||||
FROM objective_lots ol
|
||||
FROM v_objective_lots_latest ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
|
|
|
|||
|
|
@ -449,9 +449,9 @@ def test_competitors_avg_price_populated() -> None:
|
|||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
comp = resp.json()["competitors"][0]
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
|
||||
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
||||
)
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(
|
||||
150_000.0
|
||||
), "avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
||||
# OBJ-3 #307: domrf-hit → price_source='domrf'.
|
||||
assert comp["price_source"] == "domrf"
|
||||
finally:
|
||||
|
|
@ -471,9 +471,7 @@ def test_competitors_objective_price_fallback_fills_gap() -> None:
|
|||
rows = [_obj_row(obj_id=1)]
|
||||
# domrf price пуст → fallback на objective median.
|
||||
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
|
||||
db = _make_db(
|
||||
coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows
|
||||
)
|
||||
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows)
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
|
|
@ -597,14 +595,30 @@ def test_sold_count_sql_is_fanout_safe() -> None:
|
|||
НЕ domrf_obj_id → один obj_id может иметь несколько mapping-строк (то же имя,
|
||||
разный objective_group) → JOIN по project_name задвоил бы лоты. DISTINCT по
|
||||
objective_lot_id дедуплицирует на уровне лота. Регресс к COUNT(*) → fail.
|
||||
|
||||
#1964: даже после перехода на v_objective_lots_latest (где objective_lot_id
|
||||
уникален → COUNT(DISTINCT)==COUNT(*) по физлотам) DISTINCT СОХРАНЯЕТСЯ: будущий
|
||||
fan-out маппинга задвоил бы строки view, и COUNT(*) перестал бы быть fan-out-safe.
|
||||
"""
|
||||
sql = _sold_sql_text()
|
||||
assert "COUNT(DISTINCT ol.objective_lot_id)" in sql, (
|
||||
"fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
|
||||
)
|
||||
assert (
|
||||
"COUNT(DISTINCT ol.objective_lot_id)" in sql
|
||||
), "fan-out guard: маппинг не unique по domrf_obj_id — нужен COUNT(DISTINCT lot)"
|
||||
assert "COUNT(*)" not in sql, "COUNT(*) задвоит лоты при 1:N маппинге"
|
||||
|
||||
|
||||
def test_sold_count_sql_reads_physflat_view() -> None:
|
||||
"""#1964: sold-count читает physflat-дедуп view, НЕ сырой objective_lots.
|
||||
|
||||
Сырой objective_lots раздут ~2.91× (мульти objective_lot_id на один физлот через
|
||||
пере-листинги) → flats_sold завышался. Репойнт на v_objective_lots_latest даёт по
|
||||
строке на физический лот. Регресс к `FROM objective_lots` (без _latest) → fail.
|
||||
"""
|
||||
sql = _sold_sql_text()
|
||||
assert "v_objective_lots_latest" in sql, "должен читать physflat-дедуп view (#1964)"
|
||||
assert "JOIN objective_lots " not in sql, "не должен джойнить сырой objective_lots"
|
||||
|
||||
|
||||
def test_sold_count_sql_filters_premise_kind() -> None:
|
||||
"""premise_kind-фильтр (как в MarketMetrics._STOCK_SQL) — без него проданные
|
||||
паркинги/кладовые/нежилое считались бы проданными квартирами (over-count)."""
|
||||
|
|
|
|||
|
|
@ -752,8 +752,18 @@ class TestSignalExtractionHelpers:
|
|||
assert _as_dict_or(_FakeMetrics(1.0, 2, 3))["obj_count"] == 2
|
||||
assert _as_dict_or(42) is None # мусор → None (graceful)
|
||||
|
||||
def test_deal_count_from_market_metrics(self) -> None:
|
||||
assert _deal_count({}, {"n_sold": 88}) == 88
|
||||
def test_deal_count_is_windowed_not_cumulative(self) -> None:
|
||||
# #1964: deal_count = unit_velocity × window_months (продажи ЗА ОКНО), НЕ
|
||||
# кумулятивный n_sold. confidence_engine помечает фактор «за N мес» и гейтит
|
||||
# порогами окна — кумулятивный n_sold (≫50) сделал бы гейт бессмысленным и
|
||||
# подпись лживой.
|
||||
assert _deal_count({}, {"unit_velocity": 8.2, "window_months": 18}) == round(8.2 * 18)
|
||||
# n_sold ИГНОРИРУЕТСЯ (кумулятив, не оконный) — даже когда присутствует.
|
||||
assert _deal_count({}, {"n_sold": 99000, "unit_velocity": 5.0, "window_months": 6}) == 30
|
||||
# нет unit_velocity / window / market_metrics → None (нет оконного сигнала)
|
||||
assert _deal_count({}, {"n_sold": 88}) is None
|
||||
assert _deal_count({}, {"unit_velocity": 8.2}) is None
|
||||
assert _deal_count({}, {"unit_velocity": 8.2, "window_months": 0}) is None
|
||||
assert _deal_count({}, None) is None
|
||||
|
||||
def test_analog_count_prefers_obj_count(self) -> None:
|
||||
|
|
|
|||
|
|
@ -229,6 +229,17 @@ class TestLayer1Open:
|
|||
assert "CAST(:districts AS text[])" in sql
|
||||
_assert_no_double_colon_cast(sql)
|
||||
|
||||
def test_sql_reads_physflat_view(self) -> None:
|
||||
# #1964: L1 открытое предложение читает physflat-дедуп view, НЕ сырой
|
||||
# objective_lots (раздут ~2.91× мульти lot_id/физлот → units_available был
|
||||
# завышен ~3-4.6× → дефицит-форсайт переоценивал L1). Регресс к
|
||||
# `FROM objective_lots` → fail.
|
||||
db = _mock_db([])
|
||||
compute_layer1_open(db, district="Автовокзал")
|
||||
sql = _executed_sql(db)
|
||||
assert "v_objective_lots_latest" in sql, "L1 должен читать physflat-дедуп view"
|
||||
assert "FROM objective_lots " not in sql, "L1 не должен читать сырой objective_lots"
|
||||
|
||||
def test_params(self) -> None:
|
||||
db = _mock_db([])
|
||||
compute_layer1_open(db, district="Автовокзал", premise_kind="квартира")
|
||||
|
|
|
|||
53
data/sql/175_v_objective_lots_latest.sql
Normal file
53
data/sql/175_v_objective_lots_latest.sql
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
-- Контекст: #1964 (эпик #1953) — physflat-дедуп current-state потребителей
|
||||
-- objective_lots через общий VIEW.
|
||||
--
|
||||
-- objective_lots — current-state UPSERT (UNIQUE objective_lot_id, ON CONFLICT DO
|
||||
-- UPDATE), НЕ snapshot-append: всего 5 distinct snapshot_date. Инфляция строк
|
||||
-- (~2.91×: прод 2026-06-28 — 1 753 283 сырых квартир-строки vs 603 049 физлотов)
|
||||
-- возникает потому, что Объектив за время пере-листингов присваивает ОДНОМУ
|
||||
-- физическому лоту (project_name, corpus_name, section, floor, lot_number)
|
||||
-- НЕСКОЛЬКО objective_lot_id. История лотов живёт в ОТДЕЛЬНОЙ objective_lots_history
|
||||
-- (её НЕ трогаем — это корректный time-series).
|
||||
--
|
||||
-- View отдаёт ТОТ ЖЕ набор колонок, что и objective_lots (ol.* — прод-проверка:
|
||||
-- 51 base cols == 51 view cols), по ОДНОЙ строке на физлот — последний снапшот
|
||||
-- (snapshot_date DESC, tie-break id DESC). objective_lot_id в результате уникален
|
||||
-- (603 049 строк = 603 049 distinct objective_lot_id, 0 NULL) → потребители,
|
||||
-- которым нужна fan-out-защита по маппингу (competitors._SOLD_COUNT_SQL), могут
|
||||
-- использовать COUNT(DISTINCT objective_lot_id) поверх view.
|
||||
--
|
||||
-- Ключ/направления DISTINCT ON совпадают с partial-индексом
|
||||
-- objective_lots_physflat_latest_idx (миграция 173, WHERE premise_kind='квартира')
|
||||
-- → для квартир-scoped потребителей планировщик берёт Index Scan + Unique без
|
||||
-- полной сортировки. Все current-state потребители #1964 — premise_kind='квартира'.
|
||||
-- Новый индекс НЕ добавляем (173 покрывает).
|
||||
--
|
||||
-- #1959 уже устранил инфляцию ВНУТРИ compute_market_metrics (inline DISTINCT ON в
|
||||
-- _STOCK_SQL/_SALES_WINDOW_SQL) и его 9 forecast/scoring-вызовов — НЕ дублируем.
|
||||
-- Этот view — для ОСТАЛЬНЫХ current-state консьюмеров (supply_layers L1, конкурент-
|
||||
-- карточки, district-цены, sample_size-гейты).
|
||||
--
|
||||
-- НЕ трогаем (time-series, корректны): sales_series._SOURCE_B_SQL,
|
||||
-- objective_lots_history, mv_sales_tracker_*.
|
||||
--
|
||||
-- Порядок: миграция ПЕРВОЙ (deploy first), затем backend-код, читающий view.
|
||||
-- Idempotent: CREATE OR REPLACE VIEW. Self-wrapped BEGIN/COMMIT.
|
||||
-- Прод DRY-RUN (BEGIN; CREATE OR REPLACE VIEW; SELECT count; ROLLBACK):
|
||||
-- view kvartira = 603 049 (vs 1 753 283 raw), колонки 51==51 ✓.
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE VIEW v_objective_lots_latest AS
|
||||
SELECT DISTINCT ON (project_name, corpus_name, section, floor, lot_number) ol.*
|
||||
FROM objective_lots ol
|
||||
ORDER BY project_name, corpus_name, section, floor, lot_number,
|
||||
snapshot_date DESC, id DESC;
|
||||
|
||||
COMMENT ON VIEW v_objective_lots_latest IS
|
||||
'#1964: physflat-deduped current state of objective_lots — one row per '
|
||||
'(project_name, corpus_name, section, floor, lot_number) at the latest '
|
||||
'snapshot (snapshot_date DESC, id DESC). objective_lots stores ~2.91x '
|
||||
'inflated rows (multiple objective_lot_id per physical flat across '
|
||||
're-listings); this view collapses to ~603k physical flats. History lives '
|
||||
'in objective_lots_history (do not use this view for time-series).';
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue