gendesign/backend/app/services/site_finder/future_supply.py
lekss361 a10592847d
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m20s
Deploy / build-worker (push) Successful in 2m23s
Deploy / deploy (push) Successful in 1m5s
feat(site_finder): future-supply-pressure index (#950 Step 6) (#1006)
2026-06-03 05:10:08 +00:00

398 lines
24 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.site_finder.market_metrics import compute_market_metrics
logger = logging.getLogger(__name__)
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.
_SUPPLY_LAYERS_SQL = text(
"""
SELECT
layer,
units_estimate,
expected_online_date,
confidence
FROM v_supply_layers_latest
WHERE (
CAST(:district AS text) IS NULL
OR district_name = CAST(:district AS text)
)
"""
)
def compute_future_supply_pressure(
db: Session,
*,
district: str | None,
horizon_months: int = 12,
premise_kind: str = "квартира",
) -> FutureSupplyPressure:
"""Вычислить индекс будущего давления предложения ТЗ §9.3 для района (на лету).
Шаги:
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.
"""
# ── 1. Supply-строки района из VIEW (graceful: сбой/пусто → []) ────────────
rows = _query_supply_rows(db, {"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 []