fix(site_finder): make Location demand_index city-relative (#948)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m22s

demand_index used a fixed clamp01(velocity/50); on a live prod refresh
all 8 ЕКБ districts sold ≥50/mo so it saturated to 1.0 everywhere — zero
discrimination between districts. Redesign to mirror infra_index:
normalize each district's unit_velocity against the city reference (MAX
district velocity per refresh run), so demand always discriminates and
self-calibrates as the market grows (no magic constant to rot).

- normalize_demand(velocity, *, city_reference_velocity), pure + graceful
  (None stays None; reference<=0 -> honest 0.0, no ZeroDivisionError)
- refresh_locations now two-pass: collect velocities (one
  compute_market_metrics per district, no O(n^2)), derive city reference,
  normalize + upsert; SAVEPOINT-per-row and counters preserved
- remove _DEMAND_SATURATION_UPM constant; log city_reference_velocity
- tests: rewrite demand normalization + add end-to-end city-relative
  suite incl. discrimination regression guarding the all-1.0 prod bug

Refs #948
This commit is contained in:
Light1YT 2026-06-08 13:58:12 +05:00
parent 8da1c00138
commit 379af88424
2 changed files with 352 additions and 88 deletions

View file

@ -33,13 +33,28 @@ per-district дёшев). Существующий код продолжает
МЕНЬШЕ доступной конкуренции, а не больше. overstock_index честная,
ортогональная спросу замена.
demand market_metrics.compute_market_metrics(db, district=).unit_velocity
demand market_metrics.compute_market_metrics(db, district=).unit_velocity,
нормированный CITY-RELATIVE (как infra ниже), а НЕ фиксированной константой.
unit_velocity = ед., проданных в месяц (за окно) [0, ). Это ТОТ ЖЕ сигнал
спроса, что использует forecasting/demand_supply_forecast (base_pace). Нет
естественного потолка насыщающее преобразование (зеркало
future_supply._saturating_index): min(1.0, velocity / _DEMAND_SATURATION_UPM).
_DEMAND_SATURATION_UPM = 50 ед./мес район, продающий 50 квартир/мес,
считаем «максимальный спрос» (index=1.0). None когда unit_velocity None.
спроса, что использует forecasting/demand_supply_forecast (base_pace). У него
нет естественного абсолютного потолка нормируем ОТНОСИТЕЛЬНО самого рынка:
demand(district) = clamp01(velocity / city_reference_velocity), где
city_reference_velocity = MAX скорость продаж среди всех районов прогона.
«Спрос относительно самого горячего района»: busiest = 1.0, остальные
пропорционально ниже. None когда unit_velocity None (нет выборки).
ПОЧЕМУ relative, а НЕ absolute (#948 fix): прежняя реализация делила на жёсткую
константу _DEMAND_SATURATION_UPM=50 ед./мес. На live-прогоне ВСЕ 8 районов ЕКБ
продавали 50/мес demand=1.0 У ВСЕХ (плоское насыщение, НОЛЬ дискриминации
между районами бесполезно как сравнительный сигнал). Абсолютная константа
«гниёт»: она была неверна уже сегодня и расходилась бы дальше с ростом рынка.
Относительная нормировка (как у infra: district POI / city POI) само-
калибруется всегда даёт спред для любого невырожденного распределения и
НИКОГДА не требует ручной перенастройки. Выбран MAX как якорь (простейший
статистик, гарантирующий дискриминацию; при 8 районах «выброс» = сигнал, а не
шум самый горячий район и ЕСТЬ потолок спроса). Вырожденные случаи:
reference=0 (все честно продали 0) demand=0.0 у всех (честный ноль, НЕ None);
все скорости равны 1.0 у всех («одинаково горячо»).
future_supply future_supply.compute_future_supply_pressure(db, district=).index
УЖЕ нормирован [0,1] самим сервисом (насыщение months_of_pressure). Берём
@ -86,11 +101,10 @@ from app.services.site_finder.market_metrics import compute_market_metrics
logger = logging.getLogger(__name__)
# Насыщение demand-индекса: unit_velocity (ед./мес), при котором index достигает 1.0.
# 50 квартир/мес по району — очень высокий темп для ЕКБ (зеркало дисциплины насыщения
# future_supply._PRESSURE_SATURATION_MONTHS). Tunable. Линейный clamp интерпретируем:
# index=0.5 ⇔ 25 ед./мес.
_DEMAND_SATURATION_UPM: float = 50.0
# demand-индекс нормируется CITY-RELATIVE (велосити района / city_reference_velocity),
# а не фиксированной константой — никакого _DEMAND_SATURATION_UPM (см. #948 fix в
# docstring выше + normalize_demand ниже). Якорь city_reference выводится в
# refresh_locations из собранных за прогон скоростей (MAX), потому константы здесь нет.
# Окно метрик рынка (мес) — совпадает с дефолтом compute_market_metrics (§9.2).
_MARKET_WINDOW_MONTHS: int = 6
@ -105,12 +119,21 @@ class LocationIndices:
None НИКОГДА не подменяется 0 «нет данных» и «честный ноль» обязаны различаться
(дух market_metrics.py graceful-on-thin-data).
demand_index CITY-RELATIVE (#948 fix), поэтому считается не локально по одному
району, а нормировкой против опорной скорости по городу (см. normalize_demand +
refresh_locations two-pass). compute_location_indices возвращает СЫРУЮ скорость
района в raw_unit_velocity (входной материал для нормировки) и оставляет
demand_index=None как плейсхолдер финальное значение проставляет pass 2 refresh.
"""
infra_index: float | None
competition_index: float | None
demand_index: float | None
future_supply_index: float | None
# Сырая unit_velocity (ед./мес) района — None при отсутствии выборки. НЕ персистится
# в колонку; служит входом для city-relative нормировки demand в refresh_locations.
raw_unit_velocity: float | None = None
def as_dict(self) -> dict[str, float | None]:
return {
@ -157,22 +180,34 @@ def normalize_competition(overstock_index: float | None) -> float | None:
def normalize_demand(
unit_velocity: float | None,
*,
saturation_upm: float = _DEMAND_SATURATION_UPM,
city_reference_velocity: float | None,
) -> float | None:
"""Индекс спроса из unit_velocity (ед./мес ∈ [0,∞)) → [0,1]. PURE.
"""Индекс спроса CITY-RELATIVE: velocity района / city_reference → [0,1]. PURE.
Насыщающее преобразование (зеркало future_supply._saturating_index): линейный
clamp min(1.0, velocity / saturation_upm). Монотонно неубывающее, насыщается в 1.0
на saturation_upm. None None. velocity=0.0 (валидное измерение «0 продаж/мес»)
0.0 (а не None это честный ноль спроса, market_metrics уже отличил его от
None-«нет выборки»).
Зеркалит дисциплину normalize_infra (district / city-reference, clamp), а НЕ
фиксированную константу. city_reference_velocity = опорная скорость продаж по городу
(refresh_locations передаёт MAX среди всех районов прогона). Так demand ВСЕГДА
дискриминирует районы и само-калибруется см. #948 fix в module docstring (старый
/50 давал demand=1.0 у всех 8 районов ЕКБ ноль дискриминации).
Semantics / edge cases (каждый покрыт юнит-тестом):
unit_velocity is None None («нет выборки», НЕ 0 load-bearing дисциплина
модуля: «нет данных» «честный ноль»).
city_reference is None None (нет опоры: все районы без данных).
city_reference == 0 (вырождение: все районы честно продали 0) 0.0, НЕ None
и без ZeroDivisionError (честный нулевой спрос).
unit_velocity == 0 при положительной опоре 0.0 (честный ноль).
все скорости равны / единственный район 1.0 («одинаково горячо»).
clamp01 страхует от velocity > reference (плавающая арифметика / редкие артефакты).
"""
if unit_velocity is None:
if unit_velocity is None or city_reference_velocity is None:
return None
if saturation_upm <= 0:
# Деградация без падения: нулевой масштаб → любой положительный спрос = max.
return 1.0 if unit_velocity > 0 else 0.0
return _clamp01(unit_velocity / saturation_upm)
if city_reference_velocity <= 0:
# Вырождение: опора 0 ⇒ весь город продал 0/мес. Честный нулевой спрос у всех
# (НЕ None — выборка была, market_metrics уже отличил «0 продаж» от «нет данных»),
# без деления на ноль.
return 0.0
return _clamp01(unit_velocity / city_reference_velocity)
def normalize_infra(
@ -197,11 +232,17 @@ def normalize_infra(
def compute_location_indices(db: Session, district: str) -> LocationIndices:
"""Вычислить 4 district-level индекса для района (#948 §8.2).
"""Вычислить per-district индексы района (#948 §8.2) — PASS 1 материал для refresh.
Переиспользует существующие per-district forecast-функции (#1129-кэшированы) и
нормализует каждый результат к [0,1] (None при отсутствии данных см. модульный
docstring про нормализацию каждого индекса). НИКОГДА не фабрикует 0.
нормализует каждый локально-вычислимый результат к [0,1] (None при отсутствии данных
см. модульный docstring про нормализацию каждого индекса). НИКОГДА не фабрикует 0.
demand_index НЕ вычисляется здесь: он CITY-RELATIVE (#948 fix) и требует опорной
скорости по городу, доступной только после сбора всех районов. Возвращаем СЫРУЮ
скорость района в raw_unit_velocity, а demand_index оставляем None (плейсхолдер);
финальную city-relative нормировку проставляет refresh_locations (pass 2). Это
единственный вызов compute_market_metrics на район (без O()).
Graceful: если источник бросает соответствующий индекс = None (logged), остальные
считаются. Никогда не валит весь расчёт из-за одного сбойного сигнала.
@ -211,14 +252,16 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
district: имя района (одно из 8 админ-районов ЕКБ).
Returns:
LocationIndices (всегда; индекс=None там, где данных нет / источник сбоил).
LocationIndices: infra/competition/future_supply нормированы (или None);
demand_index=None (заполнит pass 2); raw_unit_velocity = сырая скорость района
(или None) для city-relative нормировки demand.
"""
competition_index: float | None = None
demand_index: float | None = None
raw_unit_velocity: float | None = None
future_supply_index: float | None = None
infra_index: float | None = None
# ── competition + demand ← market_metrics (один вызов, два сигнала) ─────────
# ── competition + raw demand-velocity ← market_metrics (ОДИН вызов, два сигнала)
try:
metrics = compute_market_metrics(
db, district=district, window_months=_MARKET_WINDOW_MONTHS
@ -227,10 +270,11 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
# предложение) — ортогонален demand. НЕ sell_through (тот коррелирован с
# velocity и инвертирован относительно «свободной конкуренции»).
competition_index = normalize_competition(metrics.overstock_index)
demand_index = normalize_demand(metrics.unit_velocity)
# demand нормируется city-relative в pass 2 — здесь только собираем сырьё.
raw_unit_velocity = metrics.unit_velocity
except Exception:
logger.exception(
"location_indices: market_metrics failed (district=%s) → competition/demand None",
"location_indices: market_metrics failed (district=%s) → competition/velocity None",
district,
)
@ -258,19 +302,20 @@ def compute_location_indices(db: Session, district: str) -> LocationIndices:
)
logger.info(
"location_indices: district=%s infra=%s competition=%s demand=%s future_supply=%s",
"location_indices: district=%s infra=%s competition=%s raw_velocity=%s future_supply=%s",
district,
_round_or_none(infra_index),
_round_or_none(competition_index),
_round_or_none(demand_index),
_round_or_none(raw_unit_velocity),
_round_or_none(future_supply_index),
)
return LocationIndices(
infra_index=infra_index,
competition_index=competition_index,
demand_index=demand_index,
demand_index=None, # city-relative, заполнит pass 2 refresh_locations
future_supply_index=future_supply_index,
raw_unit_velocity=raw_unit_velocity,
)
@ -320,8 +365,15 @@ _UPSERT_LOCATION_SQL = text(
def refresh_locations(db: Session, *, region: str | None = None) -> dict[str, Any]:
"""Пересчитать + upsert-нуть индексы по всем 8 админ-районам ЕКБ (#948 §8.2).
Итерирует district-источник (district_resolver._admin_names ekb_districts),
для каждого считает индексы (compute_location_indices) и апсертит в `location`.
Two-pass (#948 fix — demand стал CITY-RELATIVE):
Pass 1: для каждого района считаем locally-вычислимые индексы
(compute_location_indices) + собираем сырую unit_velocity. ОДИН вызов
compute_market_metrics на район (без O()).
Между проходами: city_reference_velocity = MAX среди собранных НЕ-None
скоростей (опора для city-relative нормировки demand). None если ни у одного
района нет выборки demand=None у всех (graceful).
Pass 2: normalize_demand(velocity, city_reference) на район + upsert в `location`.
Идемпотентно: ON CONFLICT (district_name) обновляет индексы + centroid +
indices_computed_at (повторный прогон даёт тот же результат при тех же данных).
@ -343,11 +395,41 @@ def refresh_locations(db: Session, *, region: str | None = None) -> dict[str, An
)
return {"districts": 0, "upserted": 0, "failed": 0}
upserted = 0
failed = 0
# ── Pass 1: per-district индексы + сырьё для city-relative demand ────────────
# Сбой compute_location_indices по одному району не валит прогон: индексы=None,
# район всё равно апсертится в pass 2 (graceful, как было).
per_district: list[tuple[str, LocationIndices]] = []
for district in districts:
try:
indices = compute_location_indices(db, district)
per_district.append((district, compute_location_indices(db, district)))
except Exception as e:
logger.warning(
"refresh_locations: district=%s compute failed: %s — indices None", district, e
)
per_district.append((district, LocationIndices(None, None, None, None)))
# ── City-reference: MAX скорость среди НЕ-None (опора demand-нормировки). ────
# «Спрос относительно самого горячего района» — само-калибрующийся якорь, всегда
# даёт спред (см. #948 fix в module docstring). None ⇒ ни у кого нет данных.
velocities = [
idx.raw_unit_velocity for _, idx in per_district if idx.raw_unit_velocity is not None
]
city_reference_velocity: float | None = max(velocities) if velocities else None
logger.info(
"refresh_locations: city_reference_velocity=%s (from %d non-None of %d districts)",
_round_or_none(city_reference_velocity),
len(velocities),
len(districts),
)
# ── Pass 2: city-relative demand + upsert (SAVEPOINT per-row) ───────────────
upserted = 0
failed = 0
for district, indices in per_district:
try:
demand_index = normalize_demand(
indices.raw_unit_velocity, city_reference_velocity=city_reference_velocity
)
with db.begin_nested():
db.execute(
_UPSERT_LOCATION_SQL,
@ -356,7 +438,7 @@ def refresh_locations(db: Session, *, region: str | None = None) -> dict[str, An
"region": region,
"infra_index": indices.infra_index,
"competition_index": indices.competition_index,
"demand_index": indices.demand_index,
"demand_index": demand_index,
"future_supply_index": indices.future_supply_index,
},
)

View file

@ -2,12 +2,16 @@
Покрывает:
1. Pure-нормализация каждого индекса [0,1] + None-on-no-data (никогда 0-как-заглушка):
normalize_competition / normalize_demand / normalize_infra.
normalize_competition / normalize_demand / normalize_infra. demand CITY-RELATIVE
(#948 fix): нормировка против опорной скорости города, а НЕ фикс-константы.
2. compute_location_indices: мокаем 4 source-функции (compute_market_metrics /
compute_future_supply_pressure / _district_poi_score / _city_avg_poi_score)
assert нормализованные значения + None когда источник вернул None/сбоил.
3. refresh_locations: idempotent upsert по district-источнику (мок _admin_names),
SAVEPOINT per-row, счётчики; пустой источник no-op.
assert нормализованные значения + None когда источник вернул None/сбоил. demand
здесь НЕ считается (pass 2 refresh) проверяем raw_unit_velocity + demand=None.
3. refresh_locations: two-pass (city-relative demand), idempotent upsert по
district-источнику (мок _admin_names), SAVEPOINT per-row, счётчики; пустой
источник no-op. Включая discrimination-регрессию против прод-бага (#948:
demand=1.0 у всех 8 районов ЕКБ при старом /50-clamp).
Стратегия mock: source-функции патчим через unittest.mock.patch, DB MagicMock
(сервис ходит в БД только через эти запатченные функции + upsert SQL, который на
@ -16,11 +20,12 @@ MagicMock-сессии ничего не делает). Детерминиров
from __future__ import annotations
from itertools import pairwise
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from app.services.site_finder.locations import (
_DEMAND_SATURATION_UPM,
LocationIndices,
compute_location_indices,
normalize_competition,
normalize_demand,
@ -82,34 +87,57 @@ class TestNormalizeCompetition:
assert normalize_competition(None) is None
# ── 1. Pure-нормализация: demand (saturating) ──────────────────────────────────
# ── 1. Pure-нормализация: demand (CITY-RELATIVE, #948 fix) ─────────────────────
class TestNormalizeDemand:
def test_half_saturation_is_half(self) -> None:
assert normalize_demand(_DEMAND_SATURATION_UPM / 2) == 0.5
"""demand нормируется ОТНОСИТЕЛЬНО опорной скорости города (как infra), а НЕ делением
на фикс-константу. velocity / city_reference, clamp [0,1]. Зеркалит normalize_infra.
def test_at_saturation_is_one(self) -> None:
assert normalize_demand(_DEMAND_SATURATION_UPM) == 1.0
#948 fix: старый /50-clamp давал demand=1.0 у ВСЕХ 8 районов ЕКБ (≥50/мес каждый) →
ноль дискриминации. City-relative само-калибруется и всегда даёт спред.
"""
def test_beyond_saturation_clamps_to_one(self) -> None:
assert normalize_demand(_DEMAND_SATURATION_UPM * 3) == 1.0
def test_relative_to_reference_half(self) -> None:
# Район вдвое медленнее самого горячего → 0.5 (velocity/reference, как infra).
assert normalize_demand(50.0, city_reference_velocity=100.0) == 0.5
def test_zero_velocity_is_zero_not_none(self) -> None:
# 0 продаж/мес — честный ноль спроса (market_metrics уже отличил от None).
assert normalize_demand(0.0) == 0.0
def test_at_reference_is_one(self) -> None:
# Сам горячий район (velocity == опора) → 1.0.
assert normalize_demand(100.0, city_reference_velocity=100.0) == 1.0
def test_none_passthrough(self) -> None:
assert normalize_demand(None) is None
def test_above_reference_clamps_to_one(self) -> None:
# velocity > reference (плавающая арифметика / артефакт) → clamp 1.0.
assert normalize_demand(130.0, city_reference_velocity=100.0) == 1.0
def test_zero_saturation_degrades_without_crash(self) -> None:
assert normalize_demand(5.0, saturation_upm=0.0) == 1.0
assert normalize_demand(0.0, saturation_upm=0.0) == 0.0
# ── Hard correctness edge case #4: velocity 0 при положительной опоре → 0.0 ──
def test_zero_velocity_with_positive_reference_is_zero_not_none(self) -> None:
# 0 продаж/мес при живом рынке — честный ноль спроса (НЕ None: market_metrics
# уже отличил «0 продаж» от «нет выборки»).
assert normalize_demand(0.0, city_reference_velocity=100.0) == 0.0
def test_monotonic_non_decreasing(self) -> None:
# ── Hard correctness edge case #1 (pure-уровень): velocity None → None ──────
def test_none_velocity_passthrough(self) -> None:
# «Нет выборки» → None, НИКОГДА не подменяем 0 (load-bearing дисциплина модуля).
assert normalize_demand(None, city_reference_velocity=100.0) is None
def test_none_reference_is_none(self) -> None:
# Нет опоры (ни у одного района нет данных) → None у каждого.
assert normalize_demand(42.0, city_reference_velocity=None) is None
# ── Hard correctness edge case #3: reference == 0 → 0.0 (НЕ ZeroDivisionError) ─
def test_zero_reference_is_zero_not_none_no_crash(self) -> None:
# Вырождение: весь город честно продал 0/мес → опора 0. Честный нулевой спрос
# у всех (НЕ None), без деления на ноль.
assert normalize_demand(0.0, city_reference_velocity=0.0) == 0.0
# И для (гипотетического) положительного velocity при нулевой опоре — без падения.
assert normalize_demand(5.0, city_reference_velocity=0.0) == 0.0
def test_monotonic_non_decreasing_in_velocity(self) -> None:
# При фиксированной опоре индекс монотонно растёт со скоростью района.
prev = -1.0
for upm in range(0, 80, 5):
cur = normalize_demand(float(upm))
for upm in range(0, 110, 10):
cur = normalize_demand(float(upm), city_reference_velocity=100.0)
assert cur is not None
assert cur >= prev
prev = cur
@ -154,20 +182,24 @@ class TestComputeLocationIndices:
):
idx = compute_location_indices(db, "Кировский")
assert idx.competition_index == 0.8 # overstock_index passthrough (уже 0..1)
assert idx.demand_index == 0.5 # 25 / 50 saturation
# demand НЕ считается здесь (city-relative, pass 2 refresh) — None-плейсхолдер,
# а сырая скорость собрана в raw_unit_velocity для последующей нормировки.
assert idx.demand_index is None
assert idx.raw_unit_velocity == 25.0
assert idx.future_supply_index == 0.42 # passthrough (уже 0..1)
assert idx.infra_index == 1.0 # 12/10 clamp to 1.0
def test_competition_and_demand_from_distinct_sources(self) -> None:
# ОРТОГОНАЛЬНОСТЬ: competition ← overstock_index, demand ← unit_velocity —
def test_competition_and_demand_velocity_from_distinct_sources(self) -> None:
# ОРТОГОНАЛЬНОСТЬ: competition ← overstock_index, demand-сырьё ← unit_velocity —
# два РАЗНЫХ поля MarketMetrics. Подаём независимые значения и проверяем, что
# competition отражает overstock (а НЕ velocity), а demand — velocity. Так
# фиксируем, что индексы больше не из одного sales-velocity-сигнала
# (старый sell_through был монотонен в продажах = коррелирован с demand).
# competition отражает overstock (а НЕ velocity), а raw_unit_velocity — velocity.
# Так фиксируем, что сигналы не из одного sales-velocity-источника (старый
# sell_through был монотонен в продажах = коррелирован с demand). demand_index
# здесь None (city-relative нормировка — pass 2), сравниваем сырьё.
db = MagicMock()
with (
# высокое затоваривание (много конкурирующего стока стоит) при НИЗКОЙ
# скорости продаж — competition высокий, demand низкий (разъезжаются).
# скорости продаж — competition высокий, velocity низкая (разъезжаются).
patch(_MM, return_value=_metrics(0.9, 5.0)),
patch(_FSP, return_value=_fsp(None)),
patch(_DPOI, return_value=None),
@ -176,7 +208,7 @@ class TestComputeLocationIndices:
idx_high_comp = compute_location_indices(db, "Кировский")
with (
# мало затоваривания (сток быстро уходит) при ВЫСОКОЙ скорости —
# competition низкий, demand высокий (инверсия первого кейса).
# competition низкий, velocity высокая (инверсия первого кейса).
patch(_MM, return_value=_metrics(0.1, 45.0)),
patch(_FSP, return_value=_fsp(None)),
patch(_DPOI, return_value=None),
@ -186,14 +218,16 @@ class TestComputeLocationIndices:
# competition следует за overstock, НЕ за velocity.
assert idx_high_comp.competition_index == 0.9
assert idx_low_comp.competition_index == 0.1
# demand следует за velocity независимо от competition.
assert idx_high_comp.demand_index == 0.1 # 5 / 50
assert idx_low_comp.demand_index == 0.9 # 45 / 50
# Главное: high-competition-кейс имеет НИЗКИЙ demand, low-competition — ВЫСОКИЙ.
# Будь competition прежним sell_through (∝ продажам), он бы рос ВМЕСТЕ с demand;
# demand считается позже (pass 2) — здесь оба None, а сырьё следует за velocity.
assert idx_high_comp.demand_index is None
assert idx_low_comp.demand_index is None
assert idx_high_comp.raw_unit_velocity == 5.0
assert idx_low_comp.raw_unit_velocity == 45.0
# Главное: high-competition-кейс имеет НИЗКУЮ скорость, low-competition — ВЫСОКУЮ.
# Будь competition прежним sell_through (∝ продажам), он бы рос ВМЕСТЕ со скоростью;
# здесь они расходятся → сигналы ортогональны.
assert idx_high_comp.competition_index > idx_low_comp.competition_index
assert idx_high_comp.demand_index < idx_low_comp.demand_index
assert idx_high_comp.raw_unit_velocity < idx_low_comp.raw_unit_velocity
def test_no_market_data_yields_none_competition_and_demand(self) -> None:
db = MagicMock()
@ -204,15 +238,18 @@ class TestComputeLocationIndices:
patch(_CPOI, return_value=10.0),
):
idx = compute_location_indices(db, "Кировский")
# Все None (нет данных) — НИ ОДИН не подменён 0.
# Все None (нет данных) — НИ ОДИН не подменён 0. raw_unit_velocity тоже None
# (нет выборки) → район получит demand=None в pass 2.
assert idx.competition_index is None
assert idx.demand_index is None
assert idx.raw_unit_velocity is None
assert idx.future_supply_index is None
assert idx.infra_index is None
def test_source_exception_isolated_to_its_index(self) -> None:
# market_metrics бросает → competition/demand None, но future_supply + infra
# всё равно считаются (graceful per-signal, не валит весь расчёт).
# market_metrics бросает → competition None + raw_unit_velocity None (→ demand
# None в pass 2), но future_supply + infra всё равно считаются (graceful
# per-signal, не валит весь расчёт).
db = MagicMock()
with (
patch(_MM, side_effect=RuntimeError("db boom")),
@ -223,6 +260,7 @@ class TestComputeLocationIndices:
idx = compute_location_indices(db, "Ленинский")
assert idx.competition_index is None
assert idx.demand_index is None
assert idx.raw_unit_velocity is None
assert idx.future_supply_index == 0.3
assert idx.infra_index == 0.8
@ -235,14 +273,17 @@ class TestComputeLocationIndices:
patch(_CPOI, return_value=10.0),
):
idx = compute_location_indices(db, "Академический")
# demand_index здесь None (city-relative, считается в pass 2) — проверяем 3
# locally-нормированных индекса в [0,1] + что сырьё собрано.
for v in (
idx.infra_index,
idx.competition_index,
idx.demand_index,
idx.future_supply_index,
):
assert v is not None
assert 0.0 <= v <= 1.0
assert idx.demand_index is None
assert idx.raw_unit_velocity == 13.0
# ── 3. refresh_locations: idempotent upsert + SAVEPOINT + counters ──────────────
@ -264,17 +305,18 @@ class TestRefreshLocations:
patch(_ADMIN, return_value=districts),
patch(
"app.services.site_finder.locations.compute_location_indices",
return_value=SimpleNamespace(
return_value=LocationIndices(
infra_index=0.5,
competition_index=0.6,
demand_index=0.4,
demand_index=None, # city-relative, заполнит pass 2
future_supply_index=0.3,
raw_unit_velocity=30.0,
),
),
):
result = refresh_locations(db)
assert result == {"districts": 3, "upserted": 3, "failed": 0}
# SAVEPOINT использован per-row (3 раза), один commit в конце.
# SAVEPOINT использован per-row (3 раза в pass 2), один commit в конце.
assert db.begin_nested.call_count == 3
assert db.execute.call_count == 3
db.commit.assert_called_once()
@ -284,8 +326,12 @@ class TestRefreshLocations:
# (ON CONFLICT обновляет, не плодит). На уровне сервиса это детерминизм
# числа upsert'ов при неизменном district-источнике.
districts = {"Кировский", "Ленинский"}
ns = SimpleNamespace(
infra_index=0.5, competition_index=0.6, demand_index=0.4, future_supply_index=0.3
ns = LocationIndices(
infra_index=0.5,
competition_index=0.6,
demand_index=None,
future_supply_index=0.3,
raw_unit_velocity=30.0,
)
with (
patch(_ADMIN, return_value=districts),
@ -306,11 +352,12 @@ class TestRefreshLocations:
patch(_ADMIN, return_value=set(districts_sorted)),
patch(
"app.services.site_finder.locations.compute_location_indices",
return_value=SimpleNamespace(
return_value=LocationIndices(
infra_index=0.5,
competition_index=0.6,
demand_index=0.4,
demand_index=None,
future_supply_index=0.3,
raw_unit_velocity=30.0,
),
),
):
@ -325,3 +372,138 @@ class TestRefreshLocations:
assert result == {"districts": 0, "upserted": 0, "failed": 0}
db.execute.assert_not_called()
db.commit.assert_not_called()
# ── 4. refresh_locations city-relative demand wiring (#948 fix, END-TO-END) ─────
# Прогоняем НАСТОЯЩИЙ two-pass demand-путь (compute_location_indices НЕ мокаем —
# мокаем 4 источника) и захватываем, что реально апсертится в demand_index по району.
# Здесь живут hard-correctness edge cases #1,#2,#5 + discrimination-регрессия #6.
def _capturing_refresh_db() -> tuple[MagicMock, dict[str, dict[str, object]]]:
"""MagicMock-сессия, захватывающая params каждого upsert по district_name.
Возвращает (db, captured) где captured[district_name] = переданный в execute dict
(включая итоговый demand_index). begin_nested работает как no-op SAVEPOINT.
"""
captured: dict[str, dict[str, object]] = {}
def _record(_sql: object, params: dict[str, object]) -> None:
captured[str(params["district_name"])] = params
db = MagicMock()
db.begin_nested.return_value.__enter__ = MagicMock()
db.begin_nested.return_value.__exit__ = MagicMock(return_value=False)
db.execute.side_effect = _record
return db, captured
def _velocity_by_district(mapping: dict[str, float | None]) -> object:
"""side_effect для compute_market_metrics: отдаёт per-district unit_velocity.
overstock_index фиксируем (competition тут не предмет теста). Ключ kwarg district=.
"""
def _side_effect(_db: object, *, district: str, window_months: int) -> SimpleNamespace:
return _metrics(overstock_index=0.5, unit_velocity=mapping[district])
return _side_effect
class TestRefreshDemandCityRelative:
def _run(self, velocities: dict[str, float | None]) -> dict[str, dict[str, object]]:
"""Прогнать refresh с заданными per-district velocities, вернуть captured upserts."""
db, captured = _capturing_refresh_db()
with (
patch(_ADMIN, return_value=set(velocities)),
patch(_MM, side_effect=_velocity_by_district(velocities)),
patch(_FSP, return_value=_fsp(None)),
patch(_DPOI, return_value=None),
patch(_CPOI, return_value=10.0),
):
result = refresh_locations(db)
assert result["upserted"] == len(velocities)
assert result["failed"] == 0
return captured
# ── Hard correctness edge case #6 — THE critical discrimination regression ──
def test_discriminates_realistic_spread_regression_948(self) -> None:
# РЕГРЕССИЯ #948: на live-прогоне старый demand=clamp01(velocity/50) выдал
# demand=1.0 у ВСЕХ 8 районов ЕКБ (Академический, Верх-Исетский,
# Железнодорожный, Кировский, Ленинский, Октябрьский, Орджоникидзевский,
# Чкаловский) — каждый продаёт ≥50/мес → плоское насыщение, НОЛЬ дискриминации.
# City-relative нормировка ОБЯЗАНА разнести их в distinct, monotonic спред.
velocities = {
"Академический": 60.0,
"Верх-Исетский": 120.0,
"Железнодорожный": 180.0,
"Кировский": 300.0,
"Чкаловский": 450.0,
}
captured = self._run(velocities)
# Извлекаем как float (в этом populated-кейсе ни один не None — заодно проверяем).
demands: dict[str, float] = {}
for d in velocities:
v = captured[d]["demand_index"]
assert isinstance(v, float)
demands[d] = v
# Самый горячий (450) → ровно 1.0; остальные строго ниже.
assert demands["Чкаловский"] == 1.0
# Все значения РАЗЛИЧНЫ (старый /50 дал бы пять раз 1.0).
vals = list(demands.values())
assert len(set(vals)) == len(vals), f"demand не дискриминирует: {demands}"
# Строго возрастает по скорости (60<120<180<300<450).
ordered = [
demands["Академический"],
demands["Верх-Исетский"],
demands["Железнодорожный"],
demands["Кировский"],
demands["Чкаловский"],
]
assert ordered == sorted(ordered)
# Попарно строго возрастает (каждый следующий район горячее предыдущего).
assert all(a < b for a, b in pairwise(ordered))
# Осмысленный диапазон: min заметно мал (<0.2: 60/450≈0.133), max == 1.0.
assert min(vals) < 0.2
assert max(vals) == 1.0
# ── Hard correctness edge case #1: ВСЕ velocities None → ВСЕ demand None ────
def test_all_velocities_none_yields_all_demand_none(self) -> None:
captured = self._run(
{"Кировский": None, "Ленинский": None, "Академический": None}
)
# Нет опоры → demand None у всех. НИКОГДА не подменяем 0.
for d in ("Кировский", "Ленинский", "Академический"):
assert captured[d]["demand_index"] is None
# ── Hard correctness edge case #2: смешанно — None только у безданных ───────
def test_mixed_none_and_present(self) -> None:
# Опора = MAX среди НЕ-None (= 200). Район без данных → demand None; районы с
# данными нормируются относительно 200, без учёта None в опоре.
captured = self._run(
{"Кировский": 200.0, "Ленинский": 50.0, "Академический": None}
)
assert captured["Академический"]["demand_index"] is None # нет выборки
assert captured["Кировский"]["demand_index"] == 1.0 # сам горячий (=опора)
assert captured["Ленинский"]["demand_index"] == 0.25 # 50 / 200
# ── Hard correctness edge case #3 (end-to-end): опора 0 → demand 0.0 у всех ─
def test_all_zero_velocity_yields_honest_zero_not_none(self) -> None:
# Все районы честно продали 0/мес → опора 0. Честный нулевой спрос (0.0), НЕ
# None и без ZeroDivisionError.
captured = self._run({"Кировский": 0.0, "Ленинский": 0.0})
for d in ("Кировский", "Ленинский"):
assert captured[d]["demand_index"] == 0.0
# ── Hard correctness edge case #5: единственный район / все равны → 1.0 ─────
def test_single_district_is_one(self) -> None:
captured = self._run({"Кировский": 137.0})
assert captured["Кировский"]["demand_index"] == 1.0
def test_all_equal_velocities_all_one(self) -> None:
# Все одинаково горячи → опора = их общее значение → 1.0 у каждого.
captured = self._run(
{"Кировский": 90.0, "Ленинский": 90.0, "Академический": 90.0}
)
for d in ("Кировский", "Ленинский", "Академический"):
assert captured[d]["demand_index"] == 1.0