gendesign/backend/app/services/site_finder/future_supply.py
Light1YT 8206a0b067
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m49s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m45s
Deploy / deploy (push) Successful in 1m10s
perf(forecast): per-request memoization cache for §22 cold build (#1129)
Cold §22 forecast measured ~215-233s on prod: §9.x layers re-execute the same
horizon/segment-invariant DB loads with identical args hundreds of times per
report (profiled: get_competitors x69, market_metrics x124, get_monthly_macro
x290). Add a per-report ContextVar cache (forecast_cache(), opened once in the
orchestrator) + @cached(key_builder) on the expensive §9.x loaders so each
unique load runs ONCE and reuses the same frozen, read-only instance.

Output is byte-identical (memoized producers are frozen dataclasses / read-only
Pydantic, callers never mutate; cache is per-report, discarded on exit; no-op
outside the report build). No concurrency, no signature changes.

- forecast_request_cache.py: ContextVar cache + cached() decorator (no-op
  outside context, reentrant, _MISS sentinel for cached None)
- @cached on competitors/future_supply/market_metrics/macro_series/
  sales_series/macro_coefficient/demand_normalization/regression loaders
- orchestrator: wrap build_site_finder_report in forecast_cache()
- 58 tests: key discrimination (call-counting regression guard), no-op-outside,
  per-report isolation, reentrancy, frozen-producer canary, amplification proof
  (real get_monthly_macro xN->1)

code-reviewer APPROVE (keys correct, mutation-safe, output identical). 1265
forecast/cache tests green. No new deps. Refs #1129.
2026-06-08 05:26:27 +00:00

448 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Future-supply-pressure service — индекс будущего давления предложения (ТЗ §9.3).
#950 (Site Finder v2 / GG-форсайт, EPIC 6 «3-layer склад предложения») — Step 6,
финальный compute-слой. Это **измерительный слой**: детерминированный композит,
который оценивает, насколько грядущее предложение (скрытое + будущее) будет
конкурировать с нашим перспективным объектом в районе — ОТНОСИТЕЛЬНО того, как
быстро рынок поглощает сток. Считается НА ЛЕТУ (НЕ хранится в БД — см. м.125
docstring: «Future-supply-pressure считается на лету (#950 Step6), тут НЕ хранится»).
Принцип: **детерминированно, без LLM** — чистый set-based SQL + арифметика
(зеркало `market_metrics.py` / `supply_layers.py`). NO LLM-импортов нигде.
──────────────────────────────────────────────────────────────────────────────
Источники:
• Предложение по слоям — VIEW `v_supply_layers_latest` (м.125), НЕ базовая таблица
и НЕ пересчёт через supply_layers.compute_all_layers. ПОЧЕМУ именно view:
Layer3 включает строки `graddoc_stub` (#956) и `insider_manual`, которые
живут ТОЛЬКО в таблице (их нельзя вывести запросом → compute_all_layers их
НЕ вернёт). Читая view, мы честно учитываем эти невыводимые future-строки.
View отдаёт свежий снапшот на логический ключ (max snapshot_date, детерм.
tiebreak) — нам не нужно самим дедупить снапшоты.
• Поглощение рынка — reuse `market_metrics.compute_market_metrics(db, district=…)`:
берём `absorption_rate` (ед./мес ÷ доступные) и `months_of_supply` (доступные ÷
ед./мес) и ВЫВОДИМ месячное поглощение в штуках. НЕ пересчитываем absorption
с нуля — единый источник истины по скорости рынка (ТЗ §9.2).
Композит (ТЗ §9.3):
months_of_pressure = (hidden_units + future_units_by_horizon) / monthly_absorption
— сколько МЕСЯЦЕВ дополнительного конкурирующего предложения в очереди при
текущем темпе поглощения. Интерпретируемая «сырая» величина.
index ∈ [0,1] = насыщающее преобразование months_of_pressure (см. _saturating_index)
— выше = больше будущего давления.
Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics / supply_layers): пустой
район / нет поглощения / нет supply-данных → index=None, confidence='low', НИКОГДА
не crash и НИКОГДА деления на ноль. Детерминированно (без рандома).
ПРИМЕЧАНИЕ по prod-состоянию: пока worker #950 Step5 не наполнил `supply_layers`
(на prod таблица пуста до первого запуска по понедельникам 06:00), view вернёт 0
строк → open/hidden/future = 0 → months_of_pressure=None → index=None,
confidence='low'. Это валидный graceful-результат, не ошибка.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import date
from typing import Any, Literal
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.services.forecast_request_cache import cached
from app.services.site_finder.district_resolver import resolve_objective_districts
from app.services.site_finder.market_metrics import compute_market_metrics
logger = logging.getLogger(__name__)
def _future_supply_key(
db: Session,
*,
district: str | None,
horizon_months: int = 12,
premise_kind: str = "квартира",
) -> tuple[Any, ...]:
"""Ключ кэша §22-форсайта для compute_future_supply_pressure (#1129).
ГОРИЗОНТ-ЗАВИСИМ: `_horizon_weight` взвешивает L3 по horizon_months → горизонт
обязан быть в ключе (иначе все горизонты схлопнулись бы в один — это ИЗМЕНИЛО бы
результат). + district + premise_kind. `db` не в ключе.
"""
return (district, horizon_months, premise_kind)
Confidence = Literal["high", "medium", "low"]
# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД),
# 3=future (ещё не на рынке). Давление формируют скрытый + будущий слои.
_LAYER_OPEN: int = 1
_LAYER_HIDDEN: int = 2
_LAYER_FUTURE: int = 3
# Насыщение индекса: months_of_pressure, при котором индекс достигает 1.0.
# 18 мес = 1.5× «здорового» цикла поглощения ЕКБ (market_metrics._OVERSTOCK_MONTHS_
# THRESHOLD = 12): полтора цикла очереди будущего предложения сверх текущего стока —
# это уже полное насыщение давления. Выбран линейный clamp (а НЕ 1-exp): он
# интерпретируем (index=0.5 ⇔ ровно 9 мес очереди), тривиально монотонен и зеркалит
# clamp-дисциплину competitors._geo_proximity / _stage_at_horizon. Tunable.
_PRESSURE_SATURATION_MONTHS: float = 18.0
# Вес L3-строки с НЕИЗВЕСТНОЙ датой выхода (expected_online_date IS NULL). Тайминг
# неизвестен → не можем сказать, попадёт ли объём в горизонт. Нейтральные 0.5 (как
# competitors._NEUTRAL): не игнорируем объём целиком (это занизило бы давление) и не
# засчитываем его полностью (это завысило бы). Зеркало graceful-нейтрали ТЗ §15.
_NULL_HORIZON_WEIGHT: float = 0.5
# Порядок уверенности для MIN-агрегации компонентных confidence (хуже = ниже).
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
@dataclass(frozen=True)
class FutureSupplyPressure:
"""Индекс будущего давления предложения ТЗ §9.3 для района (считается на лету).
Все величины детерминированы. `months_of_pressure` и `index` = None при недостатке
данных (никогда 0-как-заглушка и никогда исключение). `index` ∈ [0,1] когда задан:
выше = больше грядущего конкурирующего предложения относительно скорости рынка.
"""
# ── Контекст ──────────────────────────────────────────────────────────────
district: str | None
horizon_months: int
premise_kind: str
confidence: Confidence # MIN компонентных confidence (тонкий L2/L3 честно тянет вниз)
# ── Сырые входы (объёмы предложения по слоям) ─────────────────────────────
open_units: int # Σ layer1 (в продаже) — контекст, в давление НЕ входит
hidden_units: int # Σ layer2 (скрытый запас ПД)
future_units_by_horizon: float # Σ layer3, взвешенный по попаданию в горизонт
# ── Поглощение (из market_metrics) ────────────────────────────────────────
monthly_absorption_units: float | None # ед./мес, поглощаемых рынком
# ── Композит ──────────────────────────────────────────────────────────────
months_of_pressure: float | None # (hidden + future) / поглощение, мес
index: float | None # насыщающее преобразование → [0,1]
def as_dict(self) -> dict[str, Any]:
return {
"district": self.district,
"horizon_months": self.horizon_months,
"premise_kind": self.premise_kind,
"confidence": self.confidence,
"index": _round_or_none(self.index, 3),
"breakdown": self.breakdown(),
}
def breakdown(self) -> dict[str, Any]:
"""Explainability-разбивка (ТЗ §16) — зеркало competitors.relevance_breakdown.
Все составляющие округлены. Показывает, ИЗ ЧЕГО собран index: объёмы по
слоям, месячное поглощение, сырые месяцы давления и нормированный индекс.
"""
return {
"open_units": self.open_units,
"hidden_units": self.hidden_units,
"future_units_by_horizon": _round_or_none(self.future_units_by_horizon, 1),
"monthly_absorption_units": _round_or_none(self.monthly_absorption_units, 2),
"months_of_pressure": _round_or_none(self.months_of_pressure, 1),
"index": _round_or_none(self.index, 3),
}
def _round_or_none(value: float | None, digits: int) -> float | None:
return round(value, digits) if value is not None else None
# ──────────────────────────────────────────────────────────────────────────────
# Pure-арифметика — без БД, полностью юнит-тестируемо.
# Каждая функция graceful: пустой/нулевой вход → None/нейтраль (не 0, не ZeroDivision).
# ──────────────────────────────────────────────────────────────────────────────
def _horizon_weight(
expected_online_date: date | None,
today: date,
horizon_months: int,
) -> float:
"""Вес L3-объёма по попаданию в горизонт ∈ {0.0, _NULL_HORIZON_WEIGHT, 1.0}.
Инверсия идеи competitors._stage_at_horizon: там — сколько ОСТАНЕТСЯ к горизонту;
здесь — сколько будущего предложения ВЫЙДЕТ онлайн в пределах горизонта и потому
будет конкурировать.
• дата ≤ today + horizon → 1.0 (выйдет в горизонте — полностью давит)
• дата > today + horizon → 0.0 (выйдет позже — в этом горизонте не давит)
• дата IS NULL → _NULL_HORIZON_WEIGHT (тайминг неизвестен → нейтраль)
horizon_months ≤ 0 трактуем как «нет окна» → только NULL-нейтраль/будущее=0.
Реализовано на относительных месяцах (а не date-арифметике календаря) —
детерминированно и тривиально тестируемо: считаем целые месяцы между today и
датой; > horizon_months → вне окна.
"""
if expected_online_date is None:
return _NULL_HORIZON_WEIGHT
months_until = _months_between(today, expected_online_date)
# Уже онлайн (дата в прошлом) или в пределах горизонта → давит полностью.
if months_until <= horizon_months:
return 1.0
return 0.0
def _months_between(start: date, end: date) -> int:
"""Целое число месяцев от start до end (может быть отрицательным, если end < start).
Календарные месяцы (год*12 + месяц), без учёта дня — достаточно для horizon-bucket
(«этот объём выйдет в пределах N месяцев?»). Детерминированно.
"""
return (end.year - start.year) * 12 + (end.month - start.month)
def _months_of_pressure(
hidden_units: float | None,
future_units_by_horizon: float | None,
monthly_absorption_units: float | None,
) -> float | None:
"""months_of_pressure = (hidden + future) / месячное поглощение.
Сколько месяцев дополнительного конкурирующего предложения стоит в очереди при
текущем темпе поглощения рынка. Guard: нет поглощения (None/≤0) → None (давление
неизмеримо, НЕ ∞ и НЕ 0 — иначе «нет данных о рынке» не отличить от «нулевого
давления»). Любой None-объём трактуем как 0 в сумме (отсутствие слоя ≠ нет данных
о рынке): если ОБА слоя пусты — сумма 0 → давление 0.0 (валидный «нет очереди»).
"""
if monthly_absorption_units is None or monthly_absorption_units <= 0:
return None
queued = (hidden_units or 0.0) + (future_units_by_horizon or 0.0)
return float(queued) / float(monthly_absorption_units)
def _saturating_index(months_of_pressure: float | None) -> float | None:
"""Насыщающее преобразование months_of_pressure → index ∈ [0,1].
Линейный clamp: min(1.0, months / _PRESSURE_SATURATION_MONTHS). Монотонно
неубывающее по months_of_pressure, насыщается в 1.0 на _PRESSURE_SATURATION_MONTHS,
clamp снизу в 0.0 (на случай отрицательных артефактов). None на входе → None.
Детерминированно.
"""
if months_of_pressure is None:
return None
if _PRESSURE_SATURATION_MONTHS <= 0:
# Деградация без падения: нулевой масштаб → любое положительное давление = max.
return 1.0 if months_of_pressure > 0 else 0.0
return max(0.0, min(1.0, months_of_pressure / _PRESSURE_SATURATION_MONTHS))
def _monthly_absorption_units(
absorption_rate: float | None,
months_of_supply: float | None,
n_available: int | None,
) -> float | None:
"""Месячное поглощение в ШТУКАХ из метрик market_metrics (без пересчёта с нуля).
Два эквивалентных пути (берём первый доступный, оба из ТЗ §9.2):
• available / months_of_supply (months_of_supply = available ÷ ед./мес)
• absorption_rate * available (absorption_rate = ед./мес ÷ available)
Предпочитаем months_of_supply (прямое деление, меньше накопленной ошибки округл.).
Нет доступного стока или метрик → None (поглощение неизмеримо, НЕ 0). Guard всех
делений на ноль/None.
"""
if not n_available or n_available <= 0:
return None
available = float(n_available)
if months_of_supply is not None and months_of_supply > 0:
return available / months_of_supply
if absorption_rate is not None and absorption_rate > 0:
return absorption_rate * available
return None
def _min_confidence(values: Sequence[Confidence | None]) -> Confidence:
"""Итоговая уверенность = MIN компонентных (худшая тянет вниз). Зеркало vocab.
Тонкий L3/L2 или low-поглощение честно роняют общий confidence в 'low'. None в
списке игнорируем (компонент не дал сигнала). Пустой/весь-None вход → 'low'
(нет ни одного надёжного сигнала). Только whitelisted 'high|medium|low'.
"""
ranks = [_CONFIDENCE_RANK[v] for v in values if v is not None]
if not ranks:
return "low"
return _RANK_TO_CONFIDENCE[min(ranks)]
# ──────────────────────────────────────────────────────────────────────────────
# SQL — читаем VIEW v_supply_layers_latest (м.125), все bind-параметры через
# CAST(:x AS type) (psycopg v3; НИКОГДА :x::type).
# ──────────────────────────────────────────────────────────────────────────────
# Все свежие строки склада предложения для района из VIEW (НЕ базовой таблицы):
# view уже отдаёт по одной строке на логический ключ (свежий снапшот) → дедуп не наш.
# КРИТИЧНО читать view, а не supply_layers.compute_all_layers: L3 graddoc_stub (#956)
# / insider_manual живут только в таблице и невыводимы пересчётом. Берём построчно
# (layer / units / expected_online_date / confidence) — взвешивание L3 по горизонту
# делаем в Python (per-row дата vs today+horizon), агрегацию L1/L2 — тоже в Python.
#
# District-фильтр — по НАБОРУ имён (:names), не по скаляру: view МИКС-вокабулярный.
# Layer1 (open) строки ключуются неформальным МИКРО-районом (objective_lots.district,
# напр. «Втузгородок»/«ЖБИ»), а Layer2/3 (hidden/future) — АДМИН-районом (domrf
# district_name, напр. «Кировский»). Скалярный фильтр по админ-имени промахивался бы
# мимо ВСЕХ L1 микро-строк → open=0 → ложный дефицит. Резолвер разворачивает админ →
# микро, а сам админ добавляем в набор для L2/3 (см. compute_future_supply_pressure).
# Bug: парцель 66:41:0702048:27 (Кировский) давал projected_supply=0 → ложный BUY.
# psycopg v3: ANY(CAST(:names AS text[])) — массивный bind, НИКОГДА :x::type.
_SUPPLY_LAYERS_SQL = text(
"""
SELECT
layer,
units_estimate,
expected_online_date,
confidence
FROM v_supply_layers_latest
WHERE (
CAST(:has_district AS boolean) IS FALSE
OR district_name = ANY(CAST(:names AS text[]))
)
"""
)
@cached(_future_supply_key, label="compute_future_supply_pressure")
def compute_future_supply_pressure(
db: Session,
*,
district: str | None,
horizon_months: int = 12,
premise_kind: str = "квартира",
) -> FutureSupplyPressure:
"""Вычислить индекс будущего давления предложения ТЗ §9.3 для района (на лету).
Шаги:
0. Резолвить district (админ-имя ЕКБ) → набор имён для МИКС-вокабулярного view:
resolve_objective_districts даёт МИКРО-районы (ключ L1-строк), к ним добавляем
сам админ (ключ L2/3-строк). None → EKB-wide (без фильтра).
1. Прочитать ВСЕ свежие supply-строки района из VIEW `v_supply_layers_latest`
(включает невыводимые L3 graddoc_stub/insider_manual).
2. Агрегировать: open=Σ L1, hidden=Σ L2, future=Σ L3·горизонт-вес (NULL-дата →
нейтраль 0.5). Собрать компонентные confidence.
3. Поглощение — reuse compute_market_metrics(db, district=…): вывести месячное
поглощение в штуках из months_of_supply / absorption_rate (НЕ с нуля).
4. months_of_pressure = (hidden + future) / поглощение → насыщающий index [0,1].
5. confidence = MIN(компонентные supply-confidence + market_metrics.confidence).
Возвращает FutureSupplyPressure ВСЕГДА (даже на пустых данных): тогда index=None,
months_of_pressure=None, confidence='low'. Никогда не бросает, никогда деления на 0.
"""
# ── 0. District → набор имён для МИКС-вокабулярного view ───────────────────
# view ключует L1-строки МИКРО-районом (objective_lots.district), а L2/3 —
# АДМИН-районом (domrf district_name). resolve_objective_districts разворачивает
# админ-имя в чистые микро (для L1); сам админ добавляем в набор (для L2/3). Для
# raw-микро входа резолвер вернёт [micro] → names=[micro] (legacy сохранён). None
# (вне 8 полигонов / 'не определён') → has_district=False → EKB-wide без фильтра.
# micros is None ⇔ EKB-wide (вход None/'не определён' ИЛИ админ без чистых алиасов);
# зеркало market_metrics has_district. Иначе micros — непустой набор микро-районов.
micros = resolve_objective_districts(db, district)
names: list[str] = []
if micros is not None and district is not None:
# dedup сохраняя порядок: микро (L1) + сам админ-район (L2/3). Инлайн-проверка
# district is not None (а не через has_district-флаг) сужает тип для mypy —
# resolver вернул бы None на None-входе, так что эта ветка ⇔ has_district.
names = list(dict.fromkeys([*micros, district]))
has_district = bool(names)
# ── 1. Supply-строки района из VIEW (graceful: сбой/пусто → []) ────────────
rows = _query_supply_rows(
db, {"has_district": has_district, "names": names, "district": district}
)
# ── 2. Агрегация по слоям + сбор компонентных confidence ──────────────────
today = date.today()
open_units = 0
hidden_units = 0
future_units_by_horizon = 0.0
supply_confidences: list[Confidence | None] = []
for r in rows:
layer = int(r["layer"]) if r["layer"] is not None else 0
units = int(r["units_estimate"]) if r["units_estimate"] is not None else 0
conf = _coerce_confidence(r["confidence"])
if layer == _LAYER_OPEN:
open_units += units
supply_confidences.append(conf)
elif layer == _LAYER_HIDDEN:
hidden_units += units
supply_confidences.append(conf)
elif layer == _LAYER_FUTURE:
weight = _horizon_weight(r["expected_online_date"], today, horizon_months)
future_units_by_horizon += units * weight
supply_confidences.append(conf)
# ── 3. Поглощение — reuse market_metrics (единый источник скорости рынка) ──
metrics = compute_market_metrics(
db, district=district, window_months=6, premise_kind=premise_kind
)
monthly_absorption = _monthly_absorption_units(
metrics.absorption_rate, metrics.months_of_supply, metrics.n_available
)
# ── 4. Композит: months_of_pressure → насыщающий index ────────────────────
mop = _months_of_pressure(hidden_units, future_units_by_horizon, monthly_absorption)
index = _saturating_index(mop)
# ── 5. confidence = MIN(supply-компоненты + market_metrics.confidence) ─────
confidence = _min_confidence([*supply_confidences, metrics.confidence])
logger.info(
"future_supply: district=%s horizon=%d open=%d hidden=%d future_w=%.1f "
"absorb/mo=%s months_of_pressure=%s index=%s confidence=%s",
district,
horizon_months,
open_units,
hidden_units,
future_units_by_horizon,
_round_or_none(monthly_absorption, 2),
_round_or_none(mop, 1),
_round_or_none(index, 3),
confidence,
)
return FutureSupplyPressure(
district=district,
horizon_months=horizon_months,
premise_kind=premise_kind,
confidence=confidence,
open_units=open_units,
hidden_units=hidden_units,
future_units_by_horizon=future_units_by_horizon,
monthly_absorption_units=monthly_absorption,
months_of_pressure=mop,
index=index,
)
def _coerce_confidence(value: Any) -> Confidence | None:
"""Привести значение confidence из строки view к whitelisted vocab или None.
View-данные проходят CHECK м.125 (high|medium|low), но защищаемся от мусора/None
дёшево: неизвестное → None (компонент не учитывается в MIN, не роняет искусственно).
"""
if value in _CONFIDENCE_RANK:
return value # type: ignore[return-value] # сужено проверкой членства
return None
def _query_supply_rows(db: Session, params: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]:
"""Прочитать supply-строки района из VIEW. Graceful: сбой/пусто → [] (не бросаем).
Зеркало market_metrics._query_* / supply_layers._safe_rows — пустой склад
(worker ещё не наполнил м.125) или сбой БД не должны валить расчёт индекса.
"""
try:
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
except Exception:
logger.exception(
"future_supply: supply-layers view query failed (district=%s)",
params.get("district"),
)
return []