diff --git a/backend/app/services/site_finder/locations.py b/backend/app/services/site_finder/locations.py index 31ed6e8d..17117c58 100644 --- a/backend/app/services/site_finder/locations.py +++ b/backend/app/services/site_finder/locations.py @@ -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(n²)). 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(n²)). + • Между проходами: 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, }, ) diff --git a/backend/tests/services/site_finder/test_locations.py b/backend/tests/services/site_finder/test_locations.py index 4edeb5e6..75eea3bf 100644 --- a/backend/tests/services/site_finder/test_locations.py +++ b/backend/tests/services/site_finder/test_locations.py @@ -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