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
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:
parent
8da1c00138
commit
379af88424
2 changed files with 352 additions and 88 deletions
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue