Merge pull request 'fix(forecast): segment + dedup demand/supply → индекс дефицита больше не −1.00 везде (#1959)' (#2018) from fix/deficit-segmentation-1959 into main
This commit is contained in:
commit
6aff5a000d
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_coefficient import compute_macro_coefficient
|
||||||
from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro
|
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.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.competitors import get_competitors
|
||||||
from app.services.site_finder.future_supply import compute_future_supply_pressure
|
from app.services.site_finder.future_supply import compute_future_supply_pressure
|
||||||
from app.services.site_finder.market_metrics import compute_market_metrics
|
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}
|
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||||||
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
|
_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)
|
@dataclass(frozen=True)
|
||||||
class DemandSupplyForecast:
|
class DemandSupplyForecast:
|
||||||
|
|
@ -555,6 +607,15 @@ def compute_demand_supply_forecast(
|
||||||
(она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО
|
(она сама применяет β внутри); §9.6 compute_rate_sensitivity вызывается ТОЛЬКО
|
||||||
ради explainability-фразы — её β/x_pct в арифметику спроса НЕ входят.
|
ради 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).
|
• macro = get_monthly_macro(db) — для дефолтного rate-path (hold_last_rate).
|
||||||
• base_pace = compute_market_metrics(...).unit_velocity (§9.2 ед./мес).
|
• 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).
|
• rate_future = rate_path[h] (или hold_last_rate).
|
||||||
• §9.4 compute_demand_normalization(rate_future=rate_future) → norm (β внутри).
|
• §9.4 compute_demand_normalization(rate_future=rate_future) → norm (β внутри).
|
||||||
• projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда).
|
• projected_demand = base_pace × norm × §9.5 × h (линейно, без компаунда).
|
||||||
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future;
|
• §9.3 compute_future_supply_pressure(horizon_months=h) → open/hidden/future
|
||||||
hidden_release = hidden × _hidden_release_fraction(h); чистое предложение =
|
(для НЕзаданного сегмента); при заданном сегменте open = n_available,
|
||||||
open + hidden_release + future − поглощённое спросом (clamp ≥0).
|
hidden/future = 0. Чистое предложение = open + hidden_release + future −
|
||||||
|
поглощённое спросом (clamp ≥0).
|
||||||
• balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
|
• balance / ratio / знаковый deficit_index (АБСОЛЮТ) + months_of_inventory
|
||||||
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
|
(ДИСКРИМИНИРУЮЩИЙ companion = gross-предложение / помесячный темп спроса).
|
||||||
• §9.7 future_competitors = top-N get_competitors(horizon_months=h).
|
• §9.7 future_competitors = top-N get_competitors(horizon_months=h).
|
||||||
|
|
@ -600,7 +662,19 @@ def compute_demand_supply_forecast(
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Один раз: §9.2 наблюдаемый темп (base_pace) ────────────────────────────
|
# ── Один раз: §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
|
base_pace = metrics.unit_velocity
|
||||||
|
|
||||||
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
|
# ── Один раз: §9.5 макро-коэффициент (ортогонален β) ───────────────────────
|
||||||
|
|
@ -616,6 +690,15 @@ def compute_demand_supply_forecast(
|
||||||
# значение).
|
# значение).
|
||||||
confounded = _series_confounded(macro_coef, sensitivity)
|
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] = []
|
out: list[DemandSupplyForecast] = []
|
||||||
for h in horizon_list:
|
for h in horizon_list:
|
||||||
out.append(
|
out.append(
|
||||||
|
|
@ -629,6 +712,8 @@ def compute_demand_supply_forecast(
|
||||||
rate_future=effective_rate_path.get(h),
|
rate_future=effective_rate_path.get(h),
|
||||||
base_pace=base_pace,
|
base_pace=base_pace,
|
||||||
market_confidence=metrics.confidence,
|
market_confidence=metrics.confidence,
|
||||||
|
segment_open_units=metrics.n_available,
|
||||||
|
segmented=segmented,
|
||||||
macro_coef=macro_coef,
|
macro_coef=macro_coef,
|
||||||
sensitivity_phrase=sensitivity.phrase,
|
sensitivity_phrase=sensitivity.phrase,
|
||||||
confounded=confounded,
|
confounded=confounded,
|
||||||
|
|
@ -663,6 +748,8 @@ def _forecast_for_horizon(
|
||||||
rate_future: float | None,
|
rate_future: float | None,
|
||||||
base_pace: float | None,
|
base_pace: float | None,
|
||||||
market_confidence: Confidence,
|
market_confidence: Confidence,
|
||||||
|
segment_open_units: int,
|
||||||
|
segmented: bool,
|
||||||
macro_coef: Any,
|
macro_coef: Any,
|
||||||
sensitivity_phrase: str | None,
|
sensitivity_phrase: str | None,
|
||||||
confounded: bool,
|
confounded: bool,
|
||||||
|
|
@ -680,16 +767,28 @@ def _forecast_for_horizon(
|
||||||
projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon)
|
projected_demand = _project_demand(base_pace, norm_coefficient, macro_coefficient, horizon)
|
||||||
|
|
||||||
# ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое (− поглощённое) ───
|
# ── ПРЕДЛОЖЕНИЕ: §9.3 слои → фазированный hidden → чистое (− поглощённое) ───
|
||||||
|
# fsp читаем ВСЕГДА (нужен fsp.confidence для итоговой уверенности; для незаданного
|
||||||
|
# сегмента — ещё и open/hidden/future как раньше).
|
||||||
fsp = compute_future_supply_pressure(
|
fsp = compute_future_supply_pressure(
|
||||||
db, district=district, horizon_months=horizon, premise_kind=premise_kind
|
db, district=district, horizon_months=horizon, premise_kind=premise_kind
|
||||||
)
|
)
|
||||||
hidden_fraction = _hidden_release_fraction(horizon)
|
if segmented:
|
||||||
hidden_release = fsp.hidden_units * hidden_fraction
|
# #1959: ПОСЕГМЕНТНОЕ предложение = открытый сток ИМЕННО этого сегмента
|
||||||
future_online = fsp.future_units_by_horizon
|
# (market_metrics.n_available, дедуплицированный). L2/L3 (hidden/future)
|
||||||
gross_supply = _gross_supply(fsp.open_units, hidden_release, future_online)
|
# класс/формат-агностичны (нет room-оси) → если прибавить их каждой ячейке,
|
||||||
projected_supply = _project_supply(
|
# один и тот же объём двоится по всем сегментам → дефицит снова липнет к −1.0.
|
||||||
fsp.open_units, hidden_release, future_online, projected_demand
|
# Поэтому в посегментном балансе 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 ──────────────────────────
|
# ── БАЛАНС / индекс дефицита / months-of-inventory ──────────────────────────
|
||||||
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
|
# balance_ratio честно None при исчерпанном предложении (спрос/0 неотличим от
|
||||||
|
|
@ -737,7 +836,7 @@ def _forecast_for_horizon(
|
||||||
demand_norm_coefficient=norm_coefficient,
|
demand_norm_coefficient=norm_coefficient,
|
||||||
macro_coefficient=macro_coefficient,
|
macro_coefficient=macro_coefficient,
|
||||||
projected_demand_units=projected_demand,
|
projected_demand_units=projected_demand,
|
||||||
open_units=fsp.open_units,
|
open_units=open_units,
|
||||||
hidden_release_units=hidden_release,
|
hidden_release_units=hidden_release,
|
||||||
future_online_units=future_online,
|
future_online_units=future_online,
|
||||||
projected_supply_units=projected_supply,
|
projected_supply_units=projected_supply,
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,8 @@ _CLASS_TO_FORECAST: dict[str, str] = {
|
||||||
"Comfort+": "комфорт",
|
"Comfort+": "комфорт",
|
||||||
"Business": "бизнес",
|
"Business": "бизнес",
|
||||||
"Elite": "бизнес",
|
"Elite": "бизнес",
|
||||||
"Economy": "эконом",
|
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
|
||||||
|
"Economy": "стандарт",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── #983 named-константы (§10/§16) ─────────────────────────────────────────────
|
# ── #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.
|
"""Live target_class → forecast obj_class. PURE. None/неизвестное → None.
|
||||||
|
|
||||||
Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite — в «бизнес»,
|
Comfort/Comfort+ сворачиваются в «комфорт», Business/Elite — в «бизнес»,
|
||||||
Economy — в «эконом» (3 массовых класса §9.7). None → None = «без фильтра
|
Economy — в «стандарт» (#1959: нижний массовый класс objective_lots —
|
||||||
класса», вызывающий отдаёт движку дефолтную сетку классов.
|
«стандарт», эконома в данных нет). 3 массовых класса §9.7. None → None =
|
||||||
|
«без фильтра класса», вызывающий отдаёт движку дефолтную сетку классов.
|
||||||
"""
|
"""
|
||||||
if target_class is None:
|
if target_class is None:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,14 @@ _STRONG_DEFICIT_THRESHOLD: float = 0.25
|
||||||
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
|
# (#1745 «баланс»). Зеркало frontend forecast-helpers.DEFICIT_BALANCE_EPS = 0.05.
|
||||||
_SIGNAL_BALANCE_EPS: float = 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 (мес). Берём
|
# Сегментный горизонт по умолчанию для извлечения сигналов из forecasts (мес). Берём
|
||||||
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
# 12 — типовой средне-срочный продуктовый горизонт (зеркало #982/#983/#986 default).
|
||||||
_PRIMARY_HORIZON_MONTHS: int = 12
|
_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")
|
explicit = product_tz.get("mix")
|
||||||
if isinstance(explicit, list):
|
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")
|
ranked = product_tz.get("ranked_segments")
|
||||||
if isinstance(ranked, list):
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"bucket": seg.get("bucket"),
|
"bucket": seg.get("bucket"),
|
||||||
"obj_class": seg.get("obj_class"),
|
"obj_class": seg.get("obj_class"),
|
||||||
"deficit_index": seg.get("deficit_index"),
|
"deficit_index": seg.get("deficit_index"),
|
||||||
"projected_demand_units": seg.get("projected_demand_units"),
|
"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
|
for seg in segs
|
||||||
if isinstance(seg, dict)
|
|
||||||
]
|
]
|
||||||
return []
|
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` (#1745). PURE.
|
||||||
|
|
||||||
Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет
|
Не перетирает уже заданные поля (явный `mix` мог нести свой `signal`); добавляет
|
||||||
`signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует
|
`signal` из `deficit_index` только если его нет. Возвращает НОВЫЙ dict (не мутирует
|
||||||
вход — assembler-чистота).
|
вход — assembler-чистота).
|
||||||
|
|
||||||
|
#1959 honesty-guard: `degenerate=True` (вся сетка прижата к ±1.0) → вместо
|
||||||
|
категоричного сигнала ставим честную плашку `_DEGENERATE_SEGMENT_NOTE` (только
|
||||||
|
когда у ячейки не было своего явного `signal`).
|
||||||
"""
|
"""
|
||||||
enriched = dict(entry)
|
enriched = dict(entry)
|
||||||
if enriched.get("signal") is None:
|
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
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,10 @@ Confidence = Literal["high", "medium", "low"]
|
||||||
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
|
# «человеческом» регистре — SQL внутри §9.x нормализует регистр. 3 массовых класса
|
||||||
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
|
# (премиум опускаем из дефолта — тонкий сегмент, обычно low-данные; вызывающий
|
||||||
# может добавить). Держим явной константой для детерминизма сетки.
|
# может добавить). Держим явной константой для детерминизма сетки.
|
||||||
_DEFAULT_CLASSES: tuple[str, ...] = ("эконом", "комфорт", "бизнес")
|
# #1959: нижний массовый класс objective_lots.class — «стандарт», НЕ «эконом»
|
||||||
|
# (verified на prod: стандарт=483k лотов, эконома НЕТ → раньше эта ячейка матчила
|
||||||
|
# 0 строк → молча выпадала из сетки; посегментный дефицит её не видел).
|
||||||
|
_DEFAULT_CLASSES: tuple[str, ...] = ("стандарт", "комфорт", "бизнес")
|
||||||
|
|
||||||
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
|
# Дефолтные room-bucket'ы (ось «формат») — Source-B room_area-вокабуляр
|
||||||
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это
|
# (вывод room_area_bucket_of). 5 форматов: студии / 1-к / 2-к / 3-к / 80+. Это
|
||||||
|
|
|
||||||
|
|
@ -51,18 +51,23 @@ def _market_metrics_key(
|
||||||
obj_ids: Sequence[int] | None = None,
|
obj_ids: Sequence[int] | None = None,
|
||||||
window_months: int = 6,
|
window_months: int = 6,
|
||||||
premise_kind: str = "квартира",
|
premise_kind: str = "квартира",
|
||||||
|
obj_class: str | None = None,
|
||||||
|
room_bucket: str | None = None,
|
||||||
) -> tuple[Any, ...]:
|
) -> tuple[Any, ...]:
|
||||||
"""Ключ кэша §22-форсайта для compute_market_metrics (#1129).
|
"""Ключ кэша §22-форсайта для compute_market_metrics (#1129).
|
||||||
|
|
||||||
Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
|
Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для
|
||||||
hashability), окно, premise_kind. В форсайт-пути obj_ids всегда None → tuple()
|
hashability), окно, premise_kind, + сегмент-оси obj_class/room_bucket (#1959 —
|
||||||
устойчив. `db` не в ключе (одна сессия на отчёт).
|
посегментный дефицит). В форсайт-пути obj_ids всегда None → tuple() устойчив.
|
||||||
|
`db` не в ключе (одна сессия на отчёт).
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
district,
|
district,
|
||||||
tuple(obj_ids) if obj_ids is not None else None,
|
tuple(obj_ids) if obj_ids is not None else None,
|
||||||
window_months,
|
window_months,
|
||||||
premise_kind,
|
premise_kind,
|
||||||
|
obj_class,
|
||||||
|
room_bucket,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -269,15 +274,42 @@ def _room_bucket(rooms_int: int | None) -> str:
|
||||||
# SQL aggregation
|
# 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 / статус
|
# is_sold распознаём И через флаг is_sold, И через наличие contract_date / статус
|
||||||
# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и
|
# 'продан' — Объектив заполняет их неконсистентно. n_long_unsold: непродан и
|
||||||
# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»).
|
# в продаже > N мес (sales_start_date — самый надёжный «когда вышел на рынок»).
|
||||||
_STOCK_SQL = text(
|
_STOCK_SQL = text(
|
||||||
"""
|
"""
|
||||||
WITH lots AS (
|
WITH lots AS (
|
||||||
SELECT
|
SELECT DISTINCT ON (
|
||||||
ol.objective_lot_id,
|
ol.project_name, ol.corpus_name, ol.section, ol.floor, ol.lot_number
|
||||||
|
)
|
||||||
ol.project_name,
|
ol.project_name,
|
||||||
ol.rooms_int,
|
ol.rooms_int,
|
||||||
ol.area_pd,
|
ol.area_pd,
|
||||||
|
|
@ -297,6 +329,26 @@ _STOCK_SQL = text(
|
||||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
CAST(:has_obj_ids AS boolean) IS FALSE
|
||||||
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
|
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
|
SELECT
|
||||||
COUNT(*) AS n_lots,
|
COUNT(*) AS n_lots,
|
||||||
|
|
@ -320,9 +372,45 @@ _STOCK_SQL = text(
|
||||||
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
|
# ~17 дней: любой сейчас-проданный лот имел sold-снапшот в окне → «продажи в окне»
|
||||||
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
|
# схлопывались в весь кумулятивный проданный сток, завышая absorption/velocity/MoS.
|
||||||
# Bug #949: Автовокзал 6mo давал ~33 245 ед. вместо реальных ~2 308.)
|
# 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(
|
_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
|
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,
|
||||||
|
|
@ -334,18 +422,26 @@ _SALES_WINDOW_SQL = text(
|
||||||
-- эмитит итог ПЕРВЫМ → NULL-группа затирает units_total частичным
|
-- эмитит итог ПЕРВЫМ → NULL-группа затирает units_total частичным
|
||||||
-- счётом → unit_velocity/absorption занижены, MoS завышен.
|
-- счётом → unit_velocity/absorption занижены, MoS завышен.
|
||||||
GROUPING(rooms_int) AS is_total
|
GROUPING(rooms_int) AS is_total
|
||||||
FROM objective_lots ol
|
FROM latest
|
||||||
WHERE ol.premise_kind = :premise_kind
|
WHERE contract_date IS NOT NULL
|
||||||
|
AND contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)
|
||||||
AND (
|
AND (
|
||||||
CAST(:has_district AS boolean) IS FALSE
|
CAST(:cls AS text) IS NULL
|
||||||
OR ol.district = ANY(CAST(:districts AS text[]))
|
OR LOWER(class) = LOWER(CAST(:cls AS text))
|
||||||
)
|
)
|
||||||
AND (
|
AND (
|
||||||
CAST(:has_obj_ids AS boolean) IS FALSE
|
CAST(:room_bucket AS text) IS NULL
|
||||||
OR ol.objective_lot_id = ANY(CAST(:obj_ids AS bigint[]))
|
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)
|
GROUP BY ROLLUP (rooms_int)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -359,6 +455,8 @@ def compute_market_metrics(
|
||||||
obj_ids: Sequence[int] | None = None,
|
obj_ids: Sequence[int] | None = None,
|
||||||
window_months: int = 6,
|
window_months: int = 6,
|
||||||
premise_kind: str = "квартира",
|
premise_kind: str = "квартира",
|
||||||
|
obj_class: str | None = None,
|
||||||
|
room_bucket: str | None = None,
|
||||||
) -> MarketMetrics:
|
) -> MarketMetrics:
|
||||||
"""Вычислить рыночные метрики ТЗ §9.2 для локации.
|
"""Вычислить рыночные метрики ТЗ §9.2 для локации.
|
||||||
|
|
||||||
|
|
@ -366,9 +464,34 @@ def compute_market_metrics(
|
||||||
заданы). Если ни один не задан — считается по всей выборке premise_kind
|
заданы). Если ни один не задан — считается по всей выборке premise_kind
|
||||||
(имеет смысл для ЕКБ-wide baseline).
|
(имеет смысл для ЕКБ-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 ВСЕГДА (даже на пустых данных): тогда метрики =
|
Возвращает MarketMetrics ВСЕГДА (даже на пустых данных): тогда метрики =
|
||||||
None, confidence='low'. Никогда не бросает на отсутствии данных.
|
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 []
|
obj_id_list: list[int] = [int(x) for x in obj_ids] if obj_ids else []
|
||||||
has_obj_ids = bool(obj_id_list)
|
has_obj_ids = bool(obj_id_list)
|
||||||
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
|
# Резолвим district (админ-имя ЕКБ) → набор informal микро-районов, по которым
|
||||||
|
|
@ -386,6 +509,19 @@ def compute_market_metrics(
|
||||||
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
|
# ANY(NULL::bigint[]) валиден; передаём пустой список когда фильтра нет.
|
||||||
"obj_ids": obj_id_list,
|
"obj_ids": obj_id_list,
|
||||||
"overstock_interval": f"{_OVERSTOCK_MONTHS_THRESHOLD} months",
|
"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)
|
confidence = _confidence(n_lots=n_lots, obj_count=obj_count, n_sold=n_sold_total)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"market_metrics: district=%s micros=%s obj_ids=%d n_lots=%d n_sold=%d "
|
"market_metrics: district=%s micros=%s class=%s room=%s obj_ids=%d n_lots=%d "
|
||||||
"n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
|
"n_sold=%d n_available=%d obj_count=%d units_sold_window=%d confidence=%s",
|
||||||
district,
|
district,
|
||||||
micros,
|
micros,
|
||||||
|
obj_class,
|
||||||
|
room_bucket,
|
||||||
len(obj_id_list),
|
len(obj_id_list),
|
||||||
n_lots,
|
n_lots,
|
||||||
n_sold_total,
|
n_sold_total,
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ from app.services.forecasting.demand_supply_forecast import (
|
||||||
_demand_per_mo,
|
_demand_per_mo,
|
||||||
_gross_supply,
|
_gross_supply,
|
||||||
_hidden_release_fraction,
|
_hidden_release_fraction,
|
||||||
|
_market_room_bucket,
|
||||||
_min_confidence,
|
_min_confidence,
|
||||||
_months_of_inventory,
|
_months_of_inventory,
|
||||||
_project_demand,
|
_project_demand,
|
||||||
|
|
@ -552,10 +553,18 @@ class TestAsDict:
|
||||||
# ── orchestrator helpers (стабы §9.x compute_*) ───────────────────────────────
|
# ── 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 = MagicMock()
|
||||||
m.unit_velocity = unit_velocity
|
m.unit_velocity = unit_velocity
|
||||||
m.confidence = confidence
|
m.confidence = confidence
|
||||||
|
# #1959: посегментный путь читает n_available как open-сток сегмента. Дефолт 0
|
||||||
|
# (НЕзаданный сегмент его не использует — supply берётся из fsp-стаба).
|
||||||
|
m.n_available = n_available
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -683,7 +692,24 @@ class _Patches:
|
||||||
|
|
||||||
|
|
||||||
def _run(**over: object) -> list[DemandSupplyForecast]:
|
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(
|
return compute_demand_supply_forecast(
|
||||||
MagicMock(),
|
MagicMock(),
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
|
@ -1193,6 +1219,95 @@ class TestGraceful:
|
||||||
assert all(isinstance(f, DemandSupplyForecast) for f in res)
|
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) ────────────────────────────────────
|
# ── sanity: math import used (deficit lib) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,8 @@ class TestMapClass:
|
||||||
("Comfort+", "комфорт"),
|
("Comfort+", "комфорт"),
|
||||||
("Business", "бизнес"),
|
("Business", "бизнес"),
|
||||||
("Elite", "бизнес"),
|
("Elite", "бизнес"),
|
||||||
("Economy", "эконом"),
|
# #1959: нижний класс objective_lots — «стандарт» (эконома в данных нет).
|
||||||
|
("Economy", "стандарт"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_folding(self, live: str, expected: str) -> None:
|
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 import SiteFinderReport
|
||||||
from app.services.forecasting.report_assembler import (
|
from app.services.forecasting.report_assembler import (
|
||||||
|
_DEGENERATE_SEGMENT_NOTE,
|
||||||
|
_all_cells_clamped,
|
||||||
_analog_count,
|
_analog_count,
|
||||||
_as_dict_or,
|
_as_dict_or,
|
||||||
|
_build_product_tz,
|
||||||
_component_confidences,
|
_component_confidences,
|
||||||
_confounded,
|
_confounded,
|
||||||
_deal_count,
|
_deal_count,
|
||||||
|
|
@ -383,6 +386,51 @@ class TestProductTz:
|
||||||
assert "комфорт" in pt["reasons"][0]["why"]
|
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 ──────────────────────────────────────────────
|
# ── scenarios ← #984 by_scenario ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -733,8 +733,12 @@ class TestSalesWindowSource:
|
||||||
sales_sql = _executed_sql(db, 1)
|
sales_sql = _executed_sql(db, 1)
|
||||||
# Окно теперь по contract_date из objective_lots — без зависимости от history.
|
# Окно теперь по contract_date из objective_lots — без зависимости от history.
|
||||||
assert "objective_lots_history" not in sales_sql
|
assert "objective_lots_history" not in sales_sql
|
||||||
assert "ol.contract_date IS NOT NULL" in sales_sql
|
# #1959: дедуп до последнего снапшота физлота в CTE latest, окно по
|
||||||
assert "ol.contract_date >= CURRENT_DATE - CAST(:window_interval AS interval)" in sales_sql
|
# 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
|
assert "FROM objective_lots ol" in sales_sql
|
||||||
# Контракт окна сохранён: ROLLUP по комнатности + interval-bind через CAST.
|
# Контракт окна сохранён: ROLLUP по комнатности + interval-bind через CAST.
|
||||||
assert "GROUP BY ROLLUP (rooms_int)" in sales_sql
|
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