fix(forecast): segment + dedup demand/supply → индекс дефицита больше не −1.00 везде (#1959) #2018
10 changed files with 538 additions and 44 deletions
|
|
@ -73,7 +73,14 @@ from app.services.forecasting.demand_normalization import compute_demand_normali
|
|||
from app.services.forecasting.macro_coefficient import compute_macro_coefficient
|
||||
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
|
||||
from app.services.forecasting.regression import compute_rate_regime_sensitivity
|
||||
from app.services.forecasting.sales_series import SegmentSpec
|
||||
from app.services.forecasting.sales_series import (
|
||||
ROOM_AREA_BUCKET_1K,
|
||||
ROOM_AREA_BUCKET_2K,
|
||||
ROOM_AREA_BUCKET_3K,
|
||||
ROOM_AREA_BUCKET_LARGE,
|
||||
ROOM_AREA_BUCKET_STUDIO,
|
||||
SegmentSpec,
|
||||
)
|
||||
from app.services.site_finder.competitors import get_competitors
|
||||
from app.services.site_finder.future_supply import compute_future_supply_pressure
|
||||
from app.services.site_finder.market_metrics import compute_market_metrics
|
||||
|
|
@ -121,6 +128,51 @@ _CONFIDENCE_CAP: Confidence = "medium"
|
|||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
|
||||
|
||||
# #1959 VOCAB-мост (load-bearing): SegmentSpec.room_bucket приходит из what_to_build
|
||||
# в Source-B room_area-вокабуляре ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/
|
||||
# "3-к 60-80"/"80+ м²"). compute_market_metrics (после #1959) принимает ИМЕННО этот
|
||||
# вокабуляр напрямую (его room CASE = зеркало sales_series.room_area_bucket_of). Эта
|
||||
# white-list страхует от чужих/неизвестных меток: распознанные пробрасываем как есть,
|
||||
# неизвестные (или None) → None = «без room-фильтра» (district-wide темп, но НЕ тихий
|
||||
# 0-rows из-за опечатки в вокабуляре). Меняешь сетку room_buckets — обнови white-list.
|
||||
_FORECAST_ROOM_BUCKETS: frozenset[str] = frozenset(
|
||||
{
|
||||
ROOM_AREA_BUCKET_STUDIO,
|
||||
ROOM_AREA_BUCKET_1K,
|
||||
ROOM_AREA_BUCKET_2K,
|
||||
ROOM_AREA_BUCKET_3K,
|
||||
ROOM_AREA_BUCKET_LARGE,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _market_room_bucket(room_bucket: str | None) -> str | None:
|
||||
"""Перевести SegmentSpec.room_bucket в room-фильтр compute_market_metrics. PURE.
|
||||
|
||||
Вокабуляр уже выровнен (#1959): market_metrics принимает Source-B room_area-метки
|
||||
напрямую. Эта функция — VALIDATING pass-through: распознанная Source-B метка
|
||||
возвращается КАК ЕСТЬ; None / неизвестная метка → None (= «без room-фильтра»,
|
||||
district-wide темп). Без этой проверки опечатка/чужой вокабуляр дали бы тихий
|
||||
0-rows фильтр → метрики None → ячейка молча выпадала бы из ранкинга. PURE.
|
||||
|
||||
Args:
|
||||
room_bucket: room_bucket сегмента (ожидается Source-B вокабуляр) или None.
|
||||
|
||||
Returns:
|
||||
Та же Source-B метка (если в белом списке) либо None.
|
||||
"""
|
||||
if room_bucket is None:
|
||||
return None
|
||||
if room_bucket in _FORECAST_ROOM_BUCKETS:
|
||||
return room_bucket
|
||||
logger.warning(
|
||||
"demand_supply_forecast: unrecognized room_bucket %r — dropping room filter "
|
||||
"(district-wide pace). Expected Source-B vocab %s.",
|
||||
room_bucket,
|
||||
sorted(_FORECAST_ROOM_BUCKETS),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DemandSupplyForecast:
|
||||
|
|
@ -555,6 +607,15 @@ def compute_demand_supply_forecast(
|
|||
(она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО
|
||||
ради explainability-фразы — её β/x_pct в арифметику спроса НЕ входят.
|
||||
|
||||
#1959 ПОСЕГМЕНТНЫЙ баланс (фикс «−1.00 везде»): и СПРОС, и ПРЕДЛОЖЕНИЕ теперь
|
||||
сужаются под spec (obj_class/room_bucket), а не берутся district-wide:
|
||||
• base_pace = compute_market_metrics(obj_class, room_bucket).unit_velocity —
|
||||
дедуплицированный темп ИМЕННО сегмента (раньше один темп на все ячейки →
|
||||
каждая what_to_build-ячейка делила одно и то же → дефицит = −1.0 везде).
|
||||
• open-сток = market_metrics.n_available того же сегмента (дедуп). При заданном
|
||||
сегменте L2/L3 (hidden/future) ИСКЛЮЧАЮТСЯ из баланса — они класс/формат-
|
||||
агностичны, иначе один объём двоился бы по всем ячейкам (снова −1.0).
|
||||
|
||||
Один раз на вызов:
|
||||
• macro = get_monthly_macro(db) — для дефолтного rate-path (hold_last_rate).
|
||||
• base_pace = compute_market_metrics(...).unit_velocity (§9.2 ед./мес).
|
||||
|
|
@ -565,9 +626,10 @@ def compute_demand_supply_forecast(
|
|||
• rate_future = rate_path[h] (или hold_last_rate).
|
||||
• §9.4 compute_demand_normalization(rate_future=rate_future) → norm (β внутри).
|
||||
• projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда).
|
||||
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future;
|
||||
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
|
||||
open + hidden_release + future − поглощённое спросом (clamp ≥0).
|
||||
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future
|
||||
(для НЕзаданного сегмента); при заданном сегменте open = n_available,
|
||||
hidden/future = 0. Чистое предложение = open + hidden_release + future −
|
||||
поглощённое спросом (clamp ≥0).
|
||||
• balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
|
||||
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
|
||||
• §9.7 future_competitors = top-N get_competitors(horizon_months=h).
|
||||
|
|
@ -600,7 +662,19 @@ def compute_demand_supply_forecast(
|
|||
)
|
||||
|
||||
# ── Один раз: §9.2 наблюдаемый темп (base_pace) ────────────────────────────
|
||||
metrics = compute_market_metrics(db, district=district, premise_kind=premise_kind)
|
||||
# #1959: ПОСЕГМЕНТНЫЙ темп — фильтруем market_metrics по obj_class + room_bucket
|
||||
# сегмента. Раньше брался district-wide unit_velocity (один темп на ВСЕ ячейки
|
||||
# what_to_build → дефицит вырождался в −1.0 везде). room_bucket идёт из Source-B
|
||||
# вокабуляра what_to_build ("Студии 15-30"/…); market_metrics теперь принимает
|
||||
# его напрямую (room CASE = зеркало room_area_bucket_of) → переводчик-валидатор
|
||||
# (см. _market_room_bucket) только страхует от неизвестных меток.
|
||||
metrics = compute_market_metrics(
|
||||
db,
|
||||
district=district,
|
||||
premise_kind=premise_kind,
|
||||
obj_class=spec.obj_class,
|
||||
room_bucket=_market_room_bucket(spec.room_bucket),
|
||||
)
|
||||
base_pace = metrics.unit_velocity
|
||||
|
||||
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
|
||||
|
|
@ -616,6 +690,15 @@ def compute_demand_supply_forecast(
|
|||
# значение).
|
||||
confounded = _series_confounded(macro_coef, sensitivity)
|
||||
|
||||
# #1959: сегмент «задан», если сужена хотя бы одна предметная ось (класс/формат).
|
||||
# Тогда ПРЕДЛОЖЕНИЕ берём ПОСЕГМЕНТНОЕ — open = market_metrics.n_available
|
||||
# (дедуплицированный сток ИМЕННО этого сегмента), а L2/L3 (hidden/future)
|
||||
# ИСКЛЮЧАЕМ из баланса: они класс/формат-агностичны (нет room-оси) → один и тот
|
||||
# же объём прибавился бы КАЖДОЙ ячейке → двойной счёт и снова вырождение к −1.0.
|
||||
# Незаданный сегмент (оба None) → прежний district-wide путь (open из §9.3 view +
|
||||
# hidden + future), обратная совместимость для агрегатных вызовов.
|
||||
segmented = spec.obj_class is not None or spec.room_bucket is not None
|
||||
|
||||
out: list[DemandSupplyForecast] = []
|
||||
for h in horizon_list:
|
||||
out.append(
|
||||
|
|
@ -629,6 +712,8 @@ def compute_demand_supply_forecast(
|
|||
rate_future=effective_rate_path.get(h),
|
||||
base_pace=base_pace,
|
||||
market_confidence=metrics.confidence,
|
||||
segment_open_units=metrics.n_available,
|
||||
segmented=segmented,
|
||||
macro_coef=macro_coef,
|
||||
sensitivity_phrase=sensitivity.phrase,
|
||||
confounded=confounded,
|
||||
|
|
@ -663,6 +748,8 @@ def _forecast_for_horizon(
|
|||
rate_future: float | None,
|
||||
base_pace: float | None,
|
||||
market_confidence: Confidence,
|
||||
segment_open_units: int,
|
||||
segmented: bool,
|
||||
macro_coef: Any,
|
||||
sensitivity_phrase: str | None,
|
||||
confounded: bool,
|
||||
|
|
@ -680,16 +767,28 @@ def _forecast_for_horizon(
|
|||
projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon)
|
||||
|
||||
# ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое (− поглощённое) ───
|
||||
# fsp читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного
|
||||
# сегмента — ещё и open/hidden/future как раньше).
|
||||
fsp = compute_future_supply_pressure(
|
||||
db, district=district, horizon_months=horizon, premise_kind=premise_kind
|
||||
)
|
||||
hidden_fraction = _hidden_release_fraction(horizon)
|
||||
hidden_release = fsp.hidden_units * hidden_fraction
|
||||
future_online = fsp.future_units_by_horizon
|
||||
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
|
||||
projected_supply = _project_supply(
|
||||
fsp.open_units, hidden_release, future_online, projected_demand
|
||||
)
|
||||
if segmented:
|
||||
# #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента
|
||||
# (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future)
|
||||
# класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке,
|
||||
# один и тот же объём двоится по всем сегментам → дефицит снова липнет к −1.0.
|
||||
# Поэтому в посегментном балансе hidden/future = 0 (в dataclass их тоже не
|
||||
# показываем, чтобы explain не вводил в заблуждение «этот сегмент имеет L2/L3»).
|
||||
open_units = segment_open_units
|
||||
hidden_release = 0.0
|
||||
future_online = 0.0
|
||||
else:
|
||||
open_units = fsp.open_units
|
||||
hidden_fraction = _hidden_release_fraction(horizon)
|
||||
hidden_release = fsp.hidden_units * hidden_fraction
|
||||
future_online = fsp.future_units_by_horizon
|
||||
gross_supply = _gross_supply(open_units, hidden_release, future_online)
|
||||
projected_supply = _project_supply(open_units, hidden_release, future_online, projected_demand)
|
||||
|
||||
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
|
||||
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
|
||||
|
|
@ -737,7 +836,7 @@ def _forecast_for_horizon(
|
|||
demand_norm_coefficient=norm_coefficient,
|
||||
macro_coefficient=macro_coefficient,
|
||||
projected_demand_units=projected_demand,
|
||||
open_units=fsp.open_units,
|
||||
open_units=open_units,
|
||||
hidden_release_units=hidden_release,
|
||||
future_online_units=future_online,
|
||||
projected_supply_units=projected_supply,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ _CLASS_TO_FORECAST: dict[str, str] = {
|
|||
"Comfort+": "комфорт",
|
||||
"Business": "бизнес",
|
||||
"Elite": "бизнес",
|
||||
"Economy": "эконом",
|
||||
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
|
||||
"Economy": "стандарт",
|
||||
}
|
||||
|
||||
# ── #983 named-константы (§10/§16) ─────────────────────────────────────────────
|
||||
|
|
@ -145,8 +146,9 @@ def map_class(target_class: str | None) -> str | None:
|
|||
"""Live target_class → forecast obj_class. PURE. None/неизвестное → None.
|
||||
|
||||
Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite — в «бизнес»,
|
||||
Economy — в «эконом» (3 массовых класса §9.7). None → None = «без фильтра
|
||||
класса», вызывающий отдаёт движку дефолтную сетку классов.
|
||||
Economy — в «стандарт» (#1959: нижний массовый класс objective_lots —
|
||||
«стандарт», эконома в данных нет). 3 массовых класса §9.7. None → None =
|
||||
«без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов.
|
||||
"""
|
||||
if target_class is None:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -88,6 +88,14 @@ _STRONG_DEFICIT_THRESHOLD: float = 0.25
|
|||
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
|
||||
_SIGNAL_BALANCE_EPS: float = 0.05
|
||||
|
||||
# #1959 honesty-guard: если ВСЕ ячейки сетки прижаты к ±1.0 (дефицит не различает
|
||||
# сегменты — degenerate-fallback, как было до фикса посегментации), категоричные
|
||||
# вердикты «строить»/«избегать» вводят в заблуждение (на самом деле сигнал не
|
||||
# дискриминирует). Тогда вместо вердикта показываем честную плашку. |di| ≥ этого
|
||||
# порога считаем «прижато к пределу шкалы».
|
||||
_DEGENERATE_CLAMP_THRESHOLD: float = 0.999
|
||||
_DEGENERATE_SEGMENT_NOTE: str = "недостаточно гранулярных данных для посегментного вывода"
|
||||
|
||||
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
|
||||
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
||||
|
|
@ -532,33 +540,71 @@ def _extract_mix(product_tz: dict[str, Any]) -> list[dict[str, Any]]:
|
|||
"""
|
||||
explicit = product_tz.get("mix")
|
||||
if isinstance(explicit, list):
|
||||
return [_enrich_mix_entry(m) for m in explicit if isinstance(m, dict)]
|
||||
entries = [m for m in explicit if isinstance(m, dict)]
|
||||
degenerate = _all_cells_clamped([e.get("deficit_index") for e in entries])
|
||||
return [_enrich_mix_entry(m, degenerate=degenerate) for m in entries]
|
||||
ranked = product_tz.get("ranked_segments")
|
||||
if isinstance(ranked, list):
|
||||
segs = [seg for seg in ranked if isinstance(seg, dict)]
|
||||
# #1959 honesty-guard: вся сетка прижата к ±1.0 → дефицит не дискриминирует
|
||||
# сегменты (degenerate-fallback). Не эмитим категоричные «строить»/«избегать»
|
||||
# — вместо них честная плашка `_DEGENERATE_SEGMENT_NOTE`.
|
||||
degenerate = _all_cells_clamped([seg.get("deficit_index") for seg in segs])
|
||||
return [
|
||||
{
|
||||
"bucket": seg.get("bucket"),
|
||||
"obj_class": seg.get("obj_class"),
|
||||
"deficit_index": seg.get("deficit_index"),
|
||||
"projected_demand_units": seg.get("projected_demand_units"),
|
||||
"signal": _build_signal(seg.get("deficit_index")),
|
||||
"signal": (
|
||||
_DEGENERATE_SEGMENT_NOTE
|
||||
if degenerate
|
||||
else _build_signal(seg.get("deficit_index"))
|
||||
),
|
||||
}
|
||||
for seg in ranked
|
||||
if isinstance(seg, dict)
|
||||
for seg in segs
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def _enrich_mix_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
def _all_cells_clamped(deficit_indices: Sequence[Any]) -> bool:
|
||||
"""True если ВСЕ измеримые ячейки прижаты к ±1.0 (degenerate-fallback). PURE.
|
||||
|
||||
#1959 honesty-guard. «Измеримые» = числовые deficit_index (None-ячейки —
|
||||
тонкие данные, в знаменатель не идут). Пусто / нет измеримых → False (нечего
|
||||
защищать — обычный graceful-путь). Если хотя бы одна ячейка различается
|
||||
(|di| < порога), сетка ДИСКРИМИНИРУЕТ → вердикты честны → False. PURE.
|
||||
|
||||
Args:
|
||||
deficit_indices: deficit_index ячеек (числа / None / мусор).
|
||||
|
||||
Returns:
|
||||
True только когда есть ≥1 измеримая ячейка и ВСЕ они |di| ≥ порога.
|
||||
"""
|
||||
measured = [
|
||||
float(d) for d in deficit_indices if isinstance(d, int | float) and not isinstance(d, bool)
|
||||
]
|
||||
if not measured:
|
||||
return False
|
||||
return all(abs(d) >= _DEGENERATE_CLAMP_THRESHOLD for d in measured)
|
||||
|
||||
|
||||
def _enrich_mix_entry(entry: dict[str, Any], *, degenerate: bool = False) -> dict[str, Any]:
|
||||
"""Дополнить явную mix-ячейку деривированным `signal` (#1745). PURE.
|
||||
|
||||
Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет
|
||||
`signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует
|
||||
вход — assembler-чистота).
|
||||
|
||||
#1959 honesty-guard: `degenerate=True` (вся сетка прижата к ±1.0) → вместо
|
||||
категоричного сигнала ставим честную плашку `_DEGENERATE_SEGMENT_NOTE` (только
|
||||
когда у ячейки не было своего явного `signal`).
|
||||
"""
|
||||
enriched = dict(entry)
|
||||
if enriched.get("signal") is None:
|
||||
enriched["signal"] = _build_signal(enriched.get("deficit_index"))
|
||||
enriched["signal"] = (
|
||||
_DEGENERATE_SEGMENT_NOTE if degenerate else _build_signal(enriched.get("deficit_index"))
|
||||
)
|
||||
return enriched
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ Confidence = Literal["high", "medium", "low"]
|
|||
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
|
||||
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
|
||||
# может добавить). Держим явной константой для детерминизма сетки.
|
||||
_DEFAULT_CLASSES: tuple[str, ...] = ("эконом", "комфорт", "бизнес")
|
||||
# #1959: нижний массовый класс objective_lots.class — «стандарт», НЕ «эконом»
|
||||
# (verified на prod: стандарт=483k лотов, эконома НЕТ → раньше эта ячейка матчила
|
||||
# 0 строк → молча выпадала из сетки; посегментный дефицит её не видел).
|
||||
_DEFAULT_CLASSES: tuple[str, ...] = ("стандарт", "комфорт", "бизнес")
|
||||
|
||||
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
|
||||
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это
|
||||
|
|
|
|||
|
|
@ -51,18 +51,23 @@ def _market_metrics_key(
|
|||
obj_ids: Sequence[int] | None = None,
|
||||
window_months: int = 6,
|
||||
premise_kind: str = "квартира",
|
||||
obj_class: str | None = None,
|
||||
room_bucket: str | None = None,
|
||||
) -> tuple[Any, ...]:
|
||||
"""Ключ кэша §22-форсайта для compute_market_metrics (#1129).
|
||||
|
||||
Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
|
||||
hashability), окно, premise_kind. В форсайт-пути obj_ids всегда None → tuple()
|
||||
устойчив. `db` не в ключе (одна сессия на отчёт).
|
||||
hashability), окно, premise_kind, + сегмент-оси obj_class/room_bucket (#1959 —
|
||||
посегментный дефицит). В форсайт-пути obj_ids всегда None → tuple() устойчив.
|
||||
`db` не в ключе (одна сессия на отчёт).
|
||||
"""
|
||||
return (
|
||||
district,
|
||||
tuple(obj_ids) if obj_ids is not None else None,
|
||||
window_months,
|
||||
premise_kind,
|
||||
obj_class,
|
||||
room_bucket,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -269,15 +274,42 @@ def _room_bucket(rooms_int: int | None) -> str:
|
|||
# SQL aggregation
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Текущий сток per-flat. Считаем по objective_lots (последний UPSERT-снапшот).
|
||||
# Текущий сток per-flat. ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959):
|
||||
# objective_lots — append-per-snapshot (~2.9× инфляция строк), без дедупа сток
|
||||
# раздут и симметрично раздувает спрос И предложение → деградирует посегментный
|
||||
# дефицит. Ключ физлота = (project_name, corpus_name, section, floor, lot_number);
|
||||
# берём строку с MAX(snapshot_date), tie-break MAX(id). Поддерживается индексом
|
||||
# objective_lots_physflat_latest_idx (создаётся в миграции 173) — он совпадает с
|
||||
# ключом и направлениями этого DISTINCT ON → Index Only Scan + Unique без Sort.
|
||||
# project_name NOT NULL; corpus/section/floor/lot_number могут быть NULL (~2.5%
|
||||
# лотов) — для таких лотов DISTINCT ON схлопывает NULL=NULL в одну группу
|
||||
# (неидентифицируемые физлоты, в дефицит-расчёте безопасно), для остального —
|
||||
# точный per-flat дедуп.
|
||||
#
|
||||
# ⚠️ ДЕДУП БЕЗУСЛОВНЫЙ — применяется ко ВСЕМ вызовам compute_market_metrics, не
|
||||
# только к форсайт-пути. Deep-review (#1959) подтвердил: безопасно для остальных
|
||||
# потребителей (absorption/MoS/sell_through — отношения; velocity — saturated),
|
||||
# т.к. inflation симметрична в числителе/знаменателе и съедается шкалой. Дедуп
|
||||
# здесь касается ТОЛЬКО выборки внутри метрик — дедуп самой сырой таблицы
|
||||
# objective_lots (для всех platform-консьюмеров) остаётся отдельной задачей #1964.
|
||||
#
|
||||
# Сегмент-фильтры (#1959): obj_class (class в objective_lots — lowercase, матчим
|
||||
# LOWER=LOWER) + room_bucket. ROOM-bucket — Source-B room_area-вокабуляр
|
||||
# ("Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²"), ТОЧНОЕ зеркало
|
||||
# sales_series.room_area_bucket_of (и _SOURCE_B_SQL): площадь ≥ :large_area → LARGE,
|
||||
# иначе по rooms_int; так what_to_build (Source-B сетка) фильтрует БЕЗ перевода
|
||||
# вокабуляра. Применяются ДО DISTINCT ON: сегментация объективна по физлоту
|
||||
# (класс/комнатность/площадь не меняются между снапшотами).
|
||||
#
|
||||
# is_sold распознаём И через флаг is_sold, И через наличие contract_date / статус
|
||||
# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и
|
||||
# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»).
|
||||
_STOCK_SQL = text(
|
||||
"""
|
||||
WITH lots AS (
|
||||
SELECT
|
||||
ol.objective_lot_id,
|
||||
SELECT DISTINCT ON (
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
|
||||
)
|
||||
ol.project_name,
|
||||
ol.rooms_int,
|
||||
ol.area_pd,
|
||||
|
|
@ -297,6 +329,26 @@ _STOCK_SQL = text(
|
|||
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
|
||||
)
|
||||
AND (
|
||||
CAST(:cls AS text) IS NULL
|
||||
OR LOWER(ol.class) = LOWER(CAST(:cls AS text))
|
||||
)
|
||||
AND (
|
||||
CAST(:room_bucket AS text) IS NULL
|
||||
OR (CASE
|
||||
WHEN ol.area_pd >= CAST(:large_area AS numeric)
|
||||
THEN CAST(:b_large AS text)
|
||||
WHEN ol.rooms_int IS NULL THEN CAST(:b_unknown AS text)
|
||||
WHEN ol.rooms_int <= 0 THEN CAST(:b_studio AS text)
|
||||
WHEN ol.rooms_int = 1 THEN CAST(:b_1k AS text)
|
||||
WHEN ol.rooms_int = 2 THEN CAST(:b_2k AS text)
|
||||
WHEN ol.rooms_int = 3 THEN CAST(:b_3k AS text)
|
||||
ELSE CAST(:b_large AS text)
|
||||
END) = CAST(:room_bucket AS text)
|
||||
)
|
||||
ORDER BY
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||
ol.snapshot_date DESC, ol.id DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS n_lots,
|
||||
|
|
@ -320,9 +372,45 @@ _STOCK_SQL = text(
|
|||
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
|
||||
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
|
||||
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
|
||||
# area_pd берём из самого objective_lots (текущий per-flat area).
|
||||
#
|
||||
# ДЕДУП до ПОСЛЕДНЕГО снапшота на физический лот (#1959, симметрично _STOCK_SQL):
|
||||
# без дедупа один проданный физлот, присутствующий в N снапшотах, считается N раз
|
||||
# → velocity/absorption раздуты ~2.9× (раздувают спрос симметрично раздутому
|
||||
# предложению → дефицит вырождается в −1.0). Дедупим в CTE `latest`, окно по
|
||||
# contract_date и сегмент-фильтры применяем уже к дедуплицированному набору.
|
||||
# Ключ/направления дедупа — зеркало _STOCK_SQL (оба поддержаны индексом
|
||||
# objective_lots_physflat_latest_idx из миграции 173). contract_date / rooms_int /
|
||||
# area_pd берём из последнего снапшота физлота.
|
||||
# Сегмент-фильтры (#1959): obj_class (lowercase, LOWER=LOWER) + room_bucket
|
||||
# (Source-B room_area-вокабуляр "Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/
|
||||
# "80+ м²" — ТОЧНОЕ зеркало sales_series.room_area_bucket_of, как в _STOCK_SQL) —
|
||||
# применяются ПОСЛЕ дедупа (объективны по физлоту: класс/комнатность/площадь не
|
||||
# меняются между снапшотами). ROLLUP остаётся: при room_bucket-фильтре отдаёт
|
||||
# {bucket, total}.
|
||||
_SALES_WINDOW_SQL = text(
|
||||
"""
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
|
||||
)
|
||||
ol.rooms_int,
|
||||
ol.area_pd,
|
||||
ol.contract_date,
|
||||
ol.class
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
)
|
||||
AND (
|
||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
|
||||
)
|
||||
ORDER BY
|
||||
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number,
|
||||
ol.snapshot_date DESC, ol.id DESC
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS units_sold_window,
|
||||
COALESCE(SUM(area_pd), 0) AS area_sold_window,
|
||||
|
|
@ -334,18 +422,26 @@ _SALES_WINDOW_SQL = text(
|
|||
-- эмитит итог ПЕРВЫМ → NULL-группа затирает units_total частичным
|
||||
-- счётом → unit_velocity/absorption занижены, MoS завышен.
|
||||
GROUPING(rooms_int) AS is_total
|
||||
FROM objective_lots ol
|
||||
WHERE ol.premise_kind = :premise_kind
|
||||
FROM latest
|
||||
WHERE contract_date IS NOT NULL
|
||||
AND contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
|
||||
AND (
|
||||
CAST(:has_district AS boolean) IS FALSE
|
||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
||||
CAST(:cls AS text) IS NULL
|
||||
OR LOWER(class) = LOWER(CAST(:cls AS text))
|
||||
)
|
||||
AND (
|
||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
|
||||
CAST(:room_bucket AS text) IS NULL
|
||||
OR (CASE
|
||||
WHEN area_pd >= CAST(:large_area AS numeric)
|
||||
THEN CAST(:b_large AS text)
|
||||
WHEN rooms_int IS NULL THEN CAST(:b_unknown AS text)
|
||||
WHEN rooms_int <= 0 THEN CAST(:b_studio AS text)
|
||||
WHEN rooms_int = 1 THEN CAST(:b_1k AS text)
|
||||
WHEN rooms_int = 2 THEN CAST(:b_2k AS text)
|
||||
WHEN rooms_int = 3 THEN CAST(:b_3k AS text)
|
||||
ELSE CAST(:b_large AS text)
|
||||
END) = CAST(:room_bucket AS text)
|
||||
)
|
||||
AND ol.contract_date IS NOT NULL
|
||||
AND ol.contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
|
||||
GROUP BY ROLLUP (rooms_int)
|
||||
"""
|
||||
)
|
||||
|
|
@ -359,6 +455,8 @@ def compute_market_metrics(
|
|||
obj_ids: Sequence[int] | None = None,
|
||||
window_months: int = 6,
|
||||
premise_kind: str = "квартира",
|
||||
obj_class: str | None = None,
|
||||
room_bucket: str | None = None,
|
||||
) -> MarketMetrics:
|
||||
"""Вычислить рыночные метрики ТЗ §9.2 для локации.
|
||||
|
||||
|
|
@ -366,9 +464,34 @@ def compute_market_metrics(
|
|||
заданы). Если ни один не задан — считается по всей выборке premise_kind
|
||||
(имеет смысл для ЕКБ-wide baseline).
|
||||
|
||||
#1959 (посегментный дефицит): `obj_class` (lowercase в objective_lots,
|
||||
матчим регистронезависимо) и `room_bucket` (Source-B room_area-вокабуляр —
|
||||
"Студии 15-30"/"1-к 30-45"/"2-к 45-60"/"3-к 60-80"/"80+ м²", зеркало
|
||||
`sales_series.room_area_bucket_of` → what_to_build фильтрует БЕЗ перевода)
|
||||
сужают выборку до одного сегмента. Так base_pace (unit_velocity) и доступный
|
||||
сток становятся ПОСЕГМЕНТНЫМИ, а не district-wide (иначе все ячейки
|
||||
what_to_build делят один темп → дефицит вырождается в −1.0). None по обеим осям
|
||||
→ прежнее district-wide поведение. ВАЖНО: SQL дедуплицирует objective_lots до
|
||||
последнего снапшота на физлот → counts (а значит и confidence) считаются на
|
||||
дедуплицированном наборе.
|
||||
|
||||
Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики =
|
||||
None, confidence='low'. Никогда не бросает на отсутствии данных.
|
||||
"""
|
||||
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of /
|
||||
# _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без перевода.
|
||||
# Локальный (lazy) импорт: модульный импорт sales_series тянет forecasting/__init__
|
||||
# → demand_supply_forecast → future_supply → market_metrics (циклический импорт).
|
||||
from app.services.forecasting.sales_series import (
|
||||
_LARGE_AREA_THRESHOLD_M2,
|
||||
ROOM_AREA_BUCKET_1K,
|
||||
ROOM_AREA_BUCKET_2K,
|
||||
ROOM_AREA_BUCKET_3K,
|
||||
ROOM_AREA_BUCKET_LARGE,
|
||||
ROOM_AREA_BUCKET_STUDIO,
|
||||
ROOM_AREA_BUCKET_UNKNOWN,
|
||||
)
|
||||
|
||||
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
|
||||
has_obj_ids = bool(obj_id_list)
|
||||
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
|
||||
|
|
@ -386,6 +509,19 @@ def compute_market_metrics(
|
|||
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
|
||||
"obj_ids": obj_id_list,
|
||||
"overstock_interval": f"{_OVERSTOCK_MONTHS_THRESHOLD} months",
|
||||
# #1959 сегмент-фильтры (None → ось не сужается).
|
||||
"cls": obj_class,
|
||||
"room_bucket": room_bucket,
|
||||
# #1959 room-bucket пороги/метки — зеркало sales_series.room_area_bucket_of
|
||||
# / _SOURCE_B_SQL (Source-B вокабуляр), чтобы what_to_build фильтровал без
|
||||
# перевода. Меняешь bucketing — правь в ОБОИХ местах (тут + sales_series).
|
||||
"large_area": _LARGE_AREA_THRESHOLD_M2,
|
||||
"b_studio": ROOM_AREA_BUCKET_STUDIO,
|
||||
"b_1k": ROOM_AREA_BUCKET_1K,
|
||||
"b_2k": ROOM_AREA_BUCKET_2K,
|
||||
"b_3k": ROOM_AREA_BUCKET_3K,
|
||||
"b_large": ROOM_AREA_BUCKET_LARGE,
|
||||
"b_unknown": ROOM_AREA_BUCKET_UNKNOWN,
|
||||
}
|
||||
|
||||
# ── Текущий сток ──────────────────────────────────────────────────────────
|
||||
|
|
@ -433,10 +569,12 @@ def compute_market_metrics(
|
|||
confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
|
||||
|
||||
logger.info(
|
||||
"market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d "
|
||||
"n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
|
||||
"market_metrics: district=%s micros=%s class=%s room=%s obj_ids=%d n_lots=%d "
|
||||
"n_sold=%d n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
|
||||
district,
|
||||
micros,
|
||||
obj_class,
|
||||
room_bucket,
|
||||
len(obj_id_list),
|
||||
n_lots,
|
||||
n_sold_total,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ from app.services.forecasting.demand_supply_forecast import (
|
|||
_demand_per_mo,
|
||||
_gross_supply,
|
||||
_hidden_release_fraction,
|
||||
_market_room_bucket,
|
||||
_min_confidence,
|
||||
_months_of_inventory,
|
||||
_project_demand,
|
||||
|
|
@ -552,10 +553,18 @@ class TestAsDict:
|
|||
# ── orchestrator helpers (стабы §9.x compute_*) ───────────────────────────────
|
||||
|
||||
|
||||
def _metrics_stub(*, unit_velocity: float | None = 10.0, confidence: str = "high") -> MagicMock:
|
||||
def _metrics_stub(
|
||||
*,
|
||||
unit_velocity: float | None = 10.0,
|
||||
confidence: str = "high",
|
||||
n_available: int = 0,
|
||||
) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.unit_velocity = unit_velocity
|
||||
m.confidence = confidence
|
||||
# #1959: посегментный путь читает n_available как open-сток сегмента. Дефолт 0
|
||||
# (НЕзаданный сегмент его не использует — supply берётся из fsp-стаба).
|
||||
m.n_available = n_available
|
||||
return m
|
||||
|
||||
|
||||
|
|
@ -683,7 +692,24 @@ class _Patches:
|
|||
|
||||
|
||||
def _run(**over: object) -> list[DemandSupplyForecast]:
|
||||
spec = SegmentSpec(obj_class="комфорт", district="Академический")
|
||||
# #1959: НЕзаданный сегмент (только district) → предложение берётся из §9.3 слоёв
|
||||
# (fsp-стаб), как и тестировалось исторически. Посегментный путь (obj_class/
|
||||
# room_bucket заданы → open=n_available, L2/L3 исключены) проверяется отдельно в
|
||||
# TestSegmentedSupply ниже.
|
||||
spec = SegmentSpec(district="Академический")
|
||||
return compute_demand_supply_forecast(
|
||||
MagicMock(),
|
||||
spec=spec,
|
||||
district="Академический",
|
||||
cad_num="66:41:0303161:123",
|
||||
**over, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _run_segmented(**over: object) -> list[DemandSupplyForecast]:
|
||||
"""#1959 посегментный прогон: spec с obj_class+room_bucket → open=n_available,
|
||||
L2/L3 (hidden/future) исключены из баланса."""
|
||||
spec = SegmentSpec(obj_class="комфорт", room_bucket="Студии 15-30", district="Академический")
|
||||
return compute_demand_supply_forecast(
|
||||
MagicMock(),
|
||||
spec=spec,
|
||||
|
|
@ -1193,6 +1219,95 @@ class TestGraceful:
|
|||
assert all(isinstance(f, DemandSupplyForecast) for f in res)
|
||||
|
||||
|
||||
# ── #1959: VOCAB-мост room_bucket (load-bearing) ──────────────────────────────
|
||||
|
||||
|
||||
class TestMarketRoomBucketTranslator:
|
||||
"""_market_room_bucket: Source-B метки what_to_build → room-фильтр market_metrics.
|
||||
|
||||
Вокабуляр выровнен (#1959) → распознанная Source-B метка проходит КАК ЕСТЬ;
|
||||
None / неизвестная → None (= без room-фильтра, district-wide темп) вместо тихого
|
||||
0-rows фильтра, который выбросил бы ячейку из ранкинга.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bucket",
|
||||
["Студии 15-30", "1-к 30-45", "2-к 45-60", "3-к 60-80", "80+ м²"],
|
||||
)
|
||||
def test_recognized_source_b_passes_through(self, bucket: str) -> None:
|
||||
assert _market_room_bucket(bucket) == bucket
|
||||
|
||||
def test_none_to_none(self) -> None:
|
||||
assert _market_room_bucket(None) is None
|
||||
|
||||
def test_unknown_vocab_to_none(self) -> None:
|
||||
# Source-A метка ("студия"/"1"/…) или опечатка → None (НЕ тихий 0-rows).
|
||||
assert _market_room_bucket("студия") is None
|
||||
assert _market_room_bucket("1") is None
|
||||
assert _market_room_bucket("totally-bogus") is None
|
||||
|
||||
|
||||
# ── #1959: ПОСЕГМЕНТНОЕ предложение (open=n_available, L2/L3 исключены) ─────────
|
||||
|
||||
|
||||
class TestSegmentedSupply:
|
||||
"""#1959: при заданном сегменте (obj_class/room_bucket) предложение = открытый
|
||||
сток ИМЕННО сегмента (market_metrics.n_available), а L2/L3 (hidden/future)
|
||||
ИСКЛЮЧАЮТСЯ из баланса (они класс/формат-агностичны → иначе двоились бы по всем
|
||||
ячейкам → дефицит снова вырождается в −1.0).
|
||||
"""
|
||||
|
||||
def test_open_units_from_n_available_not_fsp(self) -> None:
|
||||
# fsp отдаёт open=300/hidden=200/future=40, НО сегмент задан → open = 555
|
||||
# (n_available сегмента), hidden/future = 0.
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=0.0, n_available=555),
|
||||
_SUPPLY=_supply_stub(open_units=300, hidden_units=200, future_units_by_horizon=40.0),
|
||||
):
|
||||
res = _run_segmented(horizons=[12])
|
||||
f = res[0]
|
||||
assert f.open_units == 555 # из n_available, НЕ fsp.open_units (300)
|
||||
assert f.hidden_release_units == 0.0 # L2 исключён
|
||||
assert f.future_online_units == 0.0 # L3 исключён
|
||||
# спрос 0 → нет поглощения → чистое = open сегмента.
|
||||
assert f.projected_supply_units == pytest.approx(555.0)
|
||||
|
||||
def test_segments_differentiate_not_all_minus_one(self) -> None:
|
||||
"""Регрессия #1959: ДВА сегмента с разным спросом/стоком → РАЗНЫЙ
|
||||
deficit_index (НЕ оба −1.0). Корень бага: один district-wide темп + один
|
||||
district-wide сток → каждая ячейка делила одно и то же → все −1.0.
|
||||
"""
|
||||
|
||||
def seg(*, velocity: float, n_available: int) -> DemandSupplyForecast:
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=velocity, n_available=n_available),
|
||||
_NORM=_norm_stub(coefficient=1.0),
|
||||
_MACRO_COEF=_macro_coef_stub(coefficient=1.0),
|
||||
):
|
||||
return _run_segmented(horizons=[24])[0]
|
||||
|
||||
# Сегмент с сильным спросом + малым стоком → дефицит (index > 0).
|
||||
hot = seg(velocity=50.0, n_available=10)
|
||||
# Сегмент со слабым спросом + большим стоком → затоварка (index < 0).
|
||||
cold = seg(velocity=1.0, n_available=5000)
|
||||
assert hot.deficit_index is not None and cold.deficit_index is not None
|
||||
# КЛЮЧЕВОЕ: ячейки ДИСКРИМИНИРУЮТ (не липнут к одному значению).
|
||||
assert hot.deficit_index != cold.deficit_index
|
||||
assert hot.deficit_index > cold.deficit_index
|
||||
|
||||
def test_unsegmented_still_uses_fsp_layers(self) -> None:
|
||||
# Контроль обратной совместимости: spec без class/room → supply из fsp слоёв
|
||||
# (open 300 + hidden-phased + future), n_available игнорируется.
|
||||
with _Patches(
|
||||
_METRICS=_metrics_stub(unit_velocity=0.0, n_available=999),
|
||||
_SUPPLY=_supply_stub(open_units=300, hidden_units=0, future_units_by_horizon=40.0),
|
||||
):
|
||||
res = _run(horizons=[12]) # _run = НЕзаданный сегмент
|
||||
f = res[0]
|
||||
assert f.open_units == 300 # из fsp, НЕ n_available (999)
|
||||
assert f.future_online_units == pytest.approx(40.0)
|
||||
|
||||
|
||||
# ── sanity: math import used (deficit lib) ────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,8 @@ class TestMapClass:
|
|||
("Comfort+", "комфорт"),
|
||||
("Business", "бизнес"),
|
||||
("Elite", "бизнес"),
|
||||
("Economy", "эконом"),
|
||||
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
|
||||
("Economy", "стандарт"),
|
||||
],
|
||||
)
|
||||
def test_folding(self, live: str, expected: str) -> None:
|
||||
|
|
|
|||
|
|
@ -29,8 +29,11 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:
|
|||
|
||||
from app.services.forecasting.report import SiteFinderReport
|
||||
from app.services.forecasting.report_assembler import (
|
||||
_DEGENERATE_SEGMENT_NOTE,
|
||||
_all_cells_clamped,
|
||||
_analog_count,
|
||||
_as_dict_or,
|
||||
_build_product_tz,
|
||||
_component_confidences,
|
||||
_confounded,
|
||||
_deal_count,
|
||||
|
|
@ -383,6 +386,51 @@ class TestProductTz:
|
|||
assert "комфорт" in pt["reasons"][0]["why"]
|
||||
|
||||
|
||||
# ── #1959 honesty-guard: вся сетка прижата к ±1.0 → нет категоричных вердиктов ──
|
||||
|
||||
|
||||
class TestDegenerateHonestyGuard:
|
||||
def test_all_cells_clamped_detector(self) -> None:
|
||||
# Все измеримые |di| ≥ порога → degenerate.
|
||||
assert _all_cells_clamped([-1.0, -1.0, 1.0]) is True
|
||||
assert _all_cells_clamped([-1.0, -0.9999999]) is True
|
||||
# Хотя бы одна различается → НЕ degenerate.
|
||||
assert _all_cells_clamped([-1.0, 0.3]) is False
|
||||
assert _all_cells_clamped([0.0]) is False
|
||||
# None-ячейки / пусто / мусор → False (нечего защищать).
|
||||
assert _all_cells_clamped([None, None]) is False
|
||||
assert _all_cells_clamped([]) is False
|
||||
assert _all_cells_clamped([True, "x"]) is False
|
||||
|
||||
def test_degenerate_grid_suppresses_verdicts(self) -> None:
|
||||
# Вся сетка −1.0 → вместо «строить»/«избегать» — честная плашка.
|
||||
product_tz = {
|
||||
"ranked_segments": [
|
||||
{"bucket": "1-Студия", "obj_class": "стандарт", "deficit_index": -1.0},
|
||||
{"bucket": "2-1к", "obj_class": "комфорт", "deficit_index": -1.0},
|
||||
]
|
||||
}
|
||||
pt = _build_product_tz(product_tz).as_dict()
|
||||
assert all(m["signal"] == _DEGENERATE_SEGMENT_NOTE for m in pt["mix"])
|
||||
assert all(m["signal"] not in {"строить", "избегать", "баланс"} for m in pt["mix"])
|
||||
|
||||
def test_differentiated_grid_keeps_categorical_verdicts(self) -> None:
|
||||
# Ячейки различаются → категоричные вердикты честны (фикс #1959 сработал).
|
||||
product_tz = {
|
||||
"ranked_segments": [
|
||||
{"bucket": "1-Студия", "obj_class": "комфорт", "deficit_index": 0.73},
|
||||
{"bucket": "2-3к", "obj_class": "комфорт", "deficit_index": -0.14},
|
||||
{"bucket": "3-Студия", "obj_class": "стандарт", "deficit_index": -1.0},
|
||||
]
|
||||
}
|
||||
pt = _build_product_tz(product_tz).as_dict()
|
||||
signals = {m["bucket"]: m["signal"] for m in pt["mix"]}
|
||||
assert signals["1-Студия"] == "строить"
|
||||
assert signals["2-3к"] == "избегать"
|
||||
assert signals["3-Студия"] == "избегать"
|
||||
assert _DEGENERATE_SEGMENT_NOTE not in signals.values()
|
||||
|
||||
|
||||
# ── scenarios ← #984 by_scenario ──────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -733,8 +733,12 @@ class TestSalesWindowSource:
|
|||
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
|
||||
# #1959: дедуп до последнего снапшота физлота в CTE latest, окно по
|
||||
# contract_date применяется к дедуплицированному набору (bare-колонка,
|
||||
# без ol.-префикса в outer WHERE).
|
||||
assert "DISTINCT ON (" in sales_sql
|
||||
assert "contract_date IS NOT NULL" in sales_sql
|
||||
assert "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
|
||||
|
|
|
|||
38
data/sql/173_objective_lots_physflat_idx.sql
Normal file
38
data/sql/173_objective_lots_physflat_idx.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
-- Контекст: индекс дефицита #1959 (эпик #1953) — «−1.00 везде».
|
||||
--
|
||||
-- Прогнозный движок дефицита (compute_demand_supply_forecast) дедуплицирует
|
||||
-- objective_lots до ПОСЛЕДНЕГО снапшота на каждый ФИЗИЧЕСКИЙ лот
|
||||
-- (objective_lots — append-per-snapshot, ~2.9× инфляция строк; без дедупа спрос И
|
||||
-- предложение оба раздуты симметрично → каждая ячейка what_to_build даёт один и тот
|
||||
-- же ratio → все deficit_index = −1.0). Дедуп делается через
|
||||
-- SELECT DISTINCT ON (project_name, corpus_name, section, floor, lot_number) …
|
||||
-- ORDER BY project_name, corpus_name, section, floor, lot_number,
|
||||
-- snapshot_date DESC, id DESC
|
||||
-- по квартирам (premise_kind='квартира'). Этот partial index точно совпадает с
|
||||
-- ключом DISTINCT ON и его направлениями сортировки → планировщик берёт Index-Only
|
||||
-- Scan + Unique вместо сортировки 1.75M строк (EXPLAIN на prod: Sort исчезает,
|
||||
-- cost ≈ 122k вместо полной сортировки).
|
||||
--
|
||||
-- DESC на snapshot_date/id — чтобы «последний снапшот первым» читался прямо из
|
||||
-- индекса (DISTINCT ON берёт первую строку каждой группы). premise_kind в WHERE —
|
||||
-- partial index только по квартирам (единственный сегмент дефицит-расчёта), меньше
|
||||
-- и плотнее, чем полный.
|
||||
--
|
||||
-- Порядок применения: миграция ПЕРВОЙ (deploy first), затем backend-код, который
|
||||
-- использует DISTINCT ON. Idempotent (CREATE INDEX IF NOT EXISTS) — безопасно при
|
||||
-- ре-apply. Self-wrapped BEGIN/COMMIT.
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS objective_lots_physflat_latest_idx
|
||||
ON objective_lots (
|
||||
project_name,
|
||||
corpus_name,
|
||||
section,
|
||||
floor,
|
||||
lot_number,
|
||||
snapshot_date DESC,
|
||||
id DESC
|
||||
)
|
||||
WHERE premise_kind = 'квартира';
|
||||
|
||||
COMMIT;
|
||||
Loading…
Add table
Reference in a new issue