diff --git a/backend/app/services/forecasting/normalize.py b/backend/app/services/forecasting/normalize.py new file mode 100644 index 00000000..a3bdacdb --- /dev/null +++ b/backend/app/services/forecasting/normalize.py @@ -0,0 +1,274 @@ +"""§9.4 сезонная (month-of-year) нормализация сырого месячного спроса перед регрессией. + +#979 (951-C, ТЗ §9.4), EPIC 7 «Чувствительность к ключевой ставке». DoD: УБРАТЬ +сезонность календарного месяца (month-of-year) из СЫРОГО месячного ряда спроса +ДО регрессии/прогноза §9.6/§9.8. Это ОТДЕЛЬНАЯ от `demand_normalization.py` вещь: +там — rate-regime дисконт «бумного» темпа под смену ключевой ставки (β·Δrate); здесь +— чисто КАЛЕНДАРНОЕ дессзонивание (январь систематически слабее июля и т.п.), не +зависящее от ставки. Оба нужны и ортогональны: сначала снимаем календарный паттерн +(этот модуль), потом — режимный дисконт (demand_normalization). + +РАЦИОНАЛ «не переносить sell-through при низкой эфф.ставке в будущее по рыночной» +(scope #979): сырой месячный спрос несёт ДВА смешанных сигнала — (1) календарную +сезонность (устойчивый внутригодовой паттерн: весенний/осенний всплеск, новогодний +провал) и (2) режимный отклик на ставку/льготную ипотеку. Регрессия §9.6 (продажи ↔ +ставка) ищет ВТОРОЕ; если не снять ПЕРВОЕ, сезонный пик, случайно совпавший с окном +низкой ставки, читается как «эффект ставки» и завышает перенос sell-through вперёд. +Дессзонивание изолирует календарный паттерн, чтобы регрессия видела режимный сигнал +чище — а не тащила сезонно-раздутый низкоставочный темп в будущее наивно. + +МЕТОД — классическая МУЛЬТИПЛИКАТИВНАЯ month-of-year сезонная корректировка +(stable-seasonal / ratio-to-mean, тот же дух, что seasonal-индексы X-11/decompose): + + 1. Группируем значения ряда по календарному месяцу m ∈ 1..12. + 2. month_mean[m] = среднее наблюдений месяца m; overall_mean = среднее всех. + 3. seasonal_factor[m] = month_mean[m] / overall_mean (мультипликативно: >1 — + месяц систематически выше года, <1 — ниже). + 4. Дессзонированное значение точки t = raw_t / seasonal_factor[month_of(t)]. + Так среднее по каждому календарному месяцу выравнивается к общему — устойчивый + внутригодовой паттерн снят, режимная/трендовая динамика сохранена. + +ПОЧЕМУ МУЛЬТИПЛИКАТИВНО (а не аддитивно): спрос на жильё растёт/падает в %-шкале +(сезонная амплитуда пропорциональна уровню — в «жирный» год и пик, и провал крупнее +в абсолюте). Это та же логлинейная/процентная шкала, на которой работает §9.6 +(Y = Δln(units), x_pct = 100·(exp(β)−1)) — дессзонивание делением согласовано с ней. + +МИНИМАЛЬНЫЙ ПОРОГ ДАННЫХ (guard — graceful, без усиления шума на тонких данных): +сезонный фактор для месяца m применяется ТОЛЬКО если его не на чем переоценить +случайным шумом одного-двух наблюдений: + • нужно ≥ _MIN_FULL_YEARS полных лет (по умолчанию 2) — иначе у каждого месяца + < 2 наблюдений, «сезонность» неотличима от шума → ВСЕ факторы = 1.0 (ряд + возвращается без изменений); + • месяц без наблюдений → его фактор = 1.0 (нет базы для оценки); + • overall_mean ≤ 0 (пустой/нулевой ряд) → ВСЕ факторы = 1.0 (нет масштаба, и + защита от деления на ноль); + • месяц со средним ≤ 0 (все наблюдения нули) → фактор = 1.0 (не раздуваем нули). +Деградация всегда к НЕЙТРАЛИ (factor=1.0 = «не корректируем»), НИКОГДА не делим на +ноль и НИКОГДА не усиливаем шум тонких данных. Дух market_metrics / rate_sensitivity. + +PURE, детерминированно, без БД, без LLM. numpy используется для среднего (уже dep). +Возвращаем тот же shape входа с дессзонированными значениями + сами факторы (для +explainability / следующего слоя нормализации). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, replace +from datetime import date + +import numpy as np + +from app.services.forecasting.sales_series import SalesSeries + +logger = logging.getLogger(__name__) + +# ── Named-константы ─────────────────────────────────────────────────────────── + +# Нейтральный сезонный фактор: 1.0 = «месяц не корректируем» (деление на 1.0). +_NEUTRAL_FACTOR: float = 1.0 + +# Минимум ПОЛНЫХ лет наблюдений, чтобы вообще оценивать сезонность. < 2 лет → у +# каждого месяца < 2 точек, ratio-to-mean ловит случайный шум, а не устойчивый +# внутригодовой паттерн → дессзонивание отключаем целиком (все факторы 1.0). 2 — +# абсолютный минимум, при котором у месяца есть ≥2 наблюдения для усреднения +# (классический порог стабильности сезонного индекса). Уточняется бэктестом. +_MIN_FULL_YEARS: int = 2 + +# Месяцев в году — размер группировки month-of-year (1..12). +_MONTHS_IN_YEAR: int = 12 + + +@dataclass(frozen=True) +class SeasonalAdjustment: + """Результат сезонной (month-of-year) нормализации месячного ряда спроса. + + Детерминированный. `factors` — {месяц 1..12: мультипликативный сезонный фактор} + (>1 месяц систематически выше года, <1 ниже, ровно 1.0 = не корректировали: + тонкие данные / нет наблюдений / нулевой месяц). `applied` = True, только если + хотя бы один фактор ≠ 1.0 (реально сняли сезонность); False = деградация к + нейтрали (< _MIN_FULL_YEARS лет / пустой ряд) — ряд вернулся без изменений. + `n_full_years` — сколько полных лет покрывает ряд (для explainability порога). + """ + + factors: dict[int, float] # {month 1..12: seasonal_factor} (1.0 = не трогали) + applied: bool # True = снята реальная сезонность; False = деградация к нейтрали + n_full_years: int # полных лет в ряду (база для _MIN_FULL_YEARS guard) + + +# ────────────────────────────────────────────────────────────────────────────── +# Pure-логика — без БД, полностью юнит-тестируемо (numpy-среднее на синтетике). +# ────────────────────────────────────────────────────────────────────────────── + + +def seasonal_factors( + months: list[date], + values: list[float | int | None], + *, + min_full_years: int = _MIN_FULL_YEARS, +) -> SeasonalAdjustment: + """Оценить мультипликативные month-of-year сезонные факторы ряда. PURE. + + seasonal_factor[m] = month_mean[m] / overall_mean по календарному месяцу m∈1..12 + (см. метод в module docstring). months[i] и values[i] выровнены по индексу; + None-значения в values пропускаются (нет наблюдения — не подмешиваем 0). + + GUARD (деградация к нейтрали 1.0, без деления на ноль / усиления шума): + • полных лет < min_full_years → ВСЕ факторы 1.0, applied=False; + • overall_mean ≤ 0 → ВСЕ факторы 1.0 (нет масштаба / защита от /0); + • месяц без наблюдений ИЛИ со средним ≤ 0 → его фактор 1.0. + + Args: + months: список дат точек (любой день месяца — берём .month), index-aligned. + values: значения спроса (units), None = нет наблюдения; index-aligned. + min_full_years: минимум полных лет для оценки сезонности (_MIN_FULL_YEARS). + + Returns: + SeasonalAdjustment с factors {1..12: фактор}, applied, n_full_years. + """ + # Собираем наблюдения по календарному месяцу (None пропускаем — не нули). + by_month: dict[int, list[float]] = {m: [] for m in range(1, _MONTHS_IN_YEAR + 1)} + all_values: list[float] = [] + for d, v in zip(months, values, strict=False): + if v is None: + continue + fv = float(v) + by_month[d.month].append(fv) + all_values.append(fv) + + n_full_years = _count_full_years(months, values) + neutral = {m: _NEUTRAL_FACTOR for m in range(1, _MONTHS_IN_YEAR + 1)} + + # Тонкие данные: < min_full_years полных лет → сезонность неотличима от шума. + if n_full_years < min_full_years: + return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years) + + # Нет масштаба / непозитивный overall_mean → нейтраль (и защита от деления /0). + if not all_values: + return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years) + overall_mean = float(np.mean(all_values)) + if overall_mean <= 0.0: + return SeasonalAdjustment(factors=neutral, applied=False, n_full_years=n_full_years) + + factors: dict[int, float] = {} + for m in range(1, _MONTHS_IN_YEAR + 1): + obs = by_month[m] + if not obs: + factors[m] = _NEUTRAL_FACTOR # нет наблюдений месяца → не корректируем + continue + month_mean = float(np.mean(obs)) + if month_mean <= 0.0: + factors[m] = _NEUTRAL_FACTOR # все нули в месяце → не раздуваем нули + continue + factors[m] = month_mean / overall_mean + + applied = any(f != _NEUTRAL_FACTOR for f in factors.values()) + return SeasonalAdjustment(factors=factors, applied=applied, n_full_years=n_full_years) + + +def _count_full_years(months: list[date], values: list[float | int | None]) -> int: + """Сколько ПОЛНЫХ календарных лет (все 12 месяцев имеют ≥1 наблюдение) в ряду. + + Считаем по годам, где присутствует наблюдение в КАЖДОМ из 12 месяцев — это + «полный год» в смысле сезонного покрытия (а не просто диапазон дат). None-точки + наблюдением НЕ считаются. PURE. + """ + months_seen: dict[int, set[int]] = {} + for d, v in zip(months, values, strict=False): + if v is None: + continue + months_seen.setdefault(d.year, set()).add(d.month) + return sum(1 for present in months_seen.values() if len(present) == _MONTHS_IN_YEAR) + + +def deseasonalize_values( + months: list[date], + values: list[float | int | None], + factors: dict[int, float], +) -> list[float | None]: + """Поделить значения на сезонный фактор их месяца: out[t] = raw_t / factor[m]. PURE. + + None-значение остаётся None (нет наблюдения). Фактор ≤ 0 или отсутствующий месяц + → нейтраль 1.0 (страховка: деления на ноль/отрицательное не делаем). Длина выхода + = длине входа, index-aligned. + + Args: + months: даты точек (берём .month), index-aligned с values. + values: сырые значения (units), None = нет наблюдения. + factors: {month 1..12: seasonal_factor} из seasonal_factors(). + + Returns: + Дессзонированные значения той же длины (None там, где был None). + """ + out: list[float | None] = [] + for d, v in zip(months, values, strict=False): + if v is None: + out.append(None) + continue + factor = factors.get(d.month, _NEUTRAL_FACTOR) + if factor <= 0.0: + factor = _NEUTRAL_FACTOR + out.append(float(v) / factor) + return out + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный API — дроп-ин в demand→regression путь (§9.6 rate_sensitivity). +# ────────────────────────────────────────────────────────────────────────────── + + +def normalize_demand(series: SalesSeries) -> SalesSeries: + """Снять month-of-year сезонность с сырого месячного ряда спроса (DoD #979). + + Принимает и возвращает `SalesSeries` (тот самый объект, что течёт в регрессию + §9.6: build_sales_series → normalize_demand → log_diff(units)). Дессзонирует + `units` мультипликативной month-of-year корректировкой (см. module docstring), + остальные поля (area_m2, avg_price, source, segment, confidence) сохраняет. + + ⚠️ Это КАЛЕНДАРНОЕ дессзонивание, ОРТОГОНАЛЬНОЕ rate-regime дисконту + `demand_normalization.compute_demand_normalization` (β·Δrate). Применять ПЕРЕД + регрессией §9.6, чтобы сезонный пик не читался как «эффект ставки» и не тащил + низкоставочный sell-through вперёд (рационал — module docstring). + + GUARD (graceful): < _MIN_FULL_YEARS полных лет / пустой / нулевой ряд → факторы + все 1.0 → ряд возвращается ФАКТИЧЕСКИ без изменений (deseasonalized units равны + исходным, с точностью до float). НИКОГДА не делит на ноль, не усиливает шум. + + Дессзонированные units — float (деление на фактор), но `SalesSeries.units` + типизирован как list[int]: спрос дессзонивания идёт в log_diff (float-математика), + поэтому округляем к int ТОЛЬКО для сохранения контракта dataclass; для регрессии + важна форма паттерна, а не целочисленность. Если нужны точные float-значения — + бери deseasonalize_values(...) напрямую (см. ниже helper). Детерминированно. + + Args: + series: сырой месячный ряд продаж сегмента (build_sales_series, §9.6 Y-ось). + + Returns: + Новый SalesSeries с дессзонированными units (тот же shape; прочие поля как у + входа). Пустой ряд → возвращается как есть. + """ + if not series.months: + return series + + adjustment = seasonal_factors(series.months, series.units) + if not adjustment.applied: + logger.info( + "normalize_demand: no seasonal adjustment applied (segment=%s n_full_years=%d " + "< min) → series unchanged", + series.segment, + adjustment.n_full_years, + ) + return series + + deseasonalized = deseasonalize_values(series.months, series.units, adjustment.factors) + # Контракт SalesSeries.units = list[int]: округляем дессзонированные значения + # (None в units не бывает — units всегда ≥0 int; страховка max(0, …)). + new_units = [max(0, round(v)) if v is not None else 0 for v in deseasonalized] + + logger.info( + "normalize_demand: deseasonalized segment=%s n_full_years=%d factors=%s", + series.segment, + adjustment.n_full_years, + {m: round(f, 3) for m, f in adjustment.factors.items()}, + ) + return replace(series, units=new_units) diff --git a/backend/app/services/forecasting/rate_sensitivity.py b/backend/app/services/forecasting/rate_sensitivity.py index 6d77b54d..86af4c61 100644 --- a/backend/app/services/forecasting/rate_sensitivity.py +++ b/backend/app/services/forecasting/rate_sensitivity.py @@ -554,6 +554,16 @@ def _align_sales_deltas( log_diff даёт Δln по сетке ПРОДАЖ; чтобы пары (Δrate[t−L], Δln[t]) были month- в-month, перекладываем их на сетку macro_months (месяц без продаж в выравнивании → None). Так best_lag сдвигает X строго по той же временной оси. PURE. + + TODO(#979): здесь — ЕДИНСТВЕННАЯ чистая точка интеграции month-of-year + дессзонивания (normalize.normalize_demand / seasonal_factors+deseasonalize_values) + ПЕРЕД log_diff. Интеграция ОТЛОЖЕНА: на коротком ряде (≤3 полных лет = ≤3 точки + на календарный месяц) ratio-to-mean фактор может перетянуть часть режимного + отклика на ставку в «сезонность» и сместить восстановленный β/лаг (вскрылось на + синтетике test_rate_sensitivity). Нужно сначала: (а) гейт «дессзонивать только + при ≥N точках/месяц или high-confidence ряде», либо (б) совместная оценка + seasonal+rate, чтобы не вычитать сигнал ставки. Модуль + тест готовы (#979 DoD); + подключение — отдельной задачей с бэктестом. """ deltas = log_diff(sales_units) by_month = dict(zip(sales_months, deltas, strict=False)) diff --git a/backend/tests/services/forecasting/test_normalize.py b/backend/tests/services/forecasting/test_normalize.py new file mode 100644 index 00000000..a33102f3 --- /dev/null +++ b/backend/tests/services/forecasting/test_normalize.py @@ -0,0 +1,241 @@ +"""Unit-тесты сезонной (month-of-year) нормализации спроса (#979, ТЗ §9.4). + +Синтетика, без живой БД. Гейт DoD: + • flat baseline × известный month-of-year паттерн (лето +30%, зима −30%) за 2-3 + года → после normalize_demand средние по календарным месяцам ~равны (сезонность + снята, within tolerance); + • плоский ряд → не меняется; + • тонкие данные (< 2 полных лет) → возвращаются ~без изменений (факторы = 1.0, без + blowup / деления на ноль); + • пустой ряд → пустой (без crash); + • factors / applied / n_full_years семантика + ортогональность прочих полей. +""" + +from __future__ import annotations + +import os +import statistics + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from datetime import date + +from app.services.forecasting.normalize import ( + SeasonalAdjustment, + deseasonalize_values, + normalize_demand, + seasonal_factors, +) +from app.services.forecasting.sales_series import SalesSeries + +# ── synthetic helpers ───────────────────────────────────────────────────────── + +# Известный month-of-year паттерн: лето (июнь-авг) +30%, зима (дек-фев) −30%, +# остальное ≈ базовый уровень. Сумма множителей вокруг 1.0 → overall_mean ≈ baseline. +_SEASONAL_PATTERN: dict[int, float] = { + 1: 0.70, # январь −30% + 2: 0.70, # февраль −30% + 3: 1.00, + 4: 1.00, + 5: 1.00, + 6: 1.30, # июнь +30% + 7: 1.30, # июль +30% + 8: 1.30, # август +30% + 9: 1.00, + 10: 1.00, + 11: 1.00, + 12: 0.70, # декабрь −30% +} + +_BASELINE_UNITS: int = 100 + + +def _months(n_months: int, start: date = date(2021, 1, 1)) -> list[date]: + """n_months подряд идущих 1-х чисел месяца, начиная со start.""" + out: list[date] = [] + y, m = start.year, start.month + for _ in range(n_months): + out.append(date(y, m, 1)) + m += 1 + if m > 12: + m = 1 + y += 1 + return out + + +def _seasonal_units(months: list[date], baseline: int = _BASELINE_UNITS) -> list[int]: + """baseline × month-of-year паттерн, округлённый к int (сырой сезонный спрос).""" + return [round(baseline * _SEASONAL_PATTERN[d.month]) for d in months] + + +def _make_series(months: list[date], units: list[int]) -> SalesSeries: + """SalesSeries-обёртка для теста (прочие поля — нейтральные заглушки).""" + return SalesSeries( + months=months, + units=units, + area_m2=[None] * len(months), + avg_price_per_m2=[None] * len(months), + n_months=len(months), + source="corpus_room_month", + segment={"obj_class": None, "room_bucket": None, "district": None, "price_bucket": None}, + confidence="high", + ) + + +def _month_of_year_means(months: list[date], units: list[float | None]) -> dict[int, float]: + """Среднее значение по каждому календарному месяцу (1..12) — для assert «снято».""" + buckets: dict[int, list[float]] = {} + for d, v in zip(months, units, strict=False): + if v is None: + continue + buckets.setdefault(d.month, []).append(float(v)) + return {m: statistics.mean(vs) for m, vs in buckets.items() if vs} + + +# ── seasonal_factors (pure) ─────────────────────────────────────────────────── + + +class TestSeasonalFactors: + def test_recovers_known_pattern_factors(self) -> None: + # 3 полных года сезонного ряда → факторы должны восстановить паттерн. + months = _months(36) + units = _seasonal_units(months) + adj = seasonal_factors(months, units) + assert isinstance(adj, SeasonalAdjustment) + assert adj.applied is True + assert adj.n_full_years == 3 + # Фактор каждого месяца ≈ его множитель паттерна (overall_mean ≈ baseline). + for m, expected in _SEASONAL_PATTERN.items(): + assert abs(adj.factors[m] - expected) < 0.05, (m, adj.factors[m], expected) + + def test_thin_data_under_two_years_neutral(self) -> None: + # 18 мес < 2 полных лет → все факторы 1.0, applied=False (шум не ловим). + months = _months(18) + units = _seasonal_units(months) + adj = seasonal_factors(months, units) + assert adj.applied is False + assert adj.n_full_years == 1 + assert all(f == 1.0 for f in adj.factors.values()) + + def test_flat_series_factors_all_one(self) -> None: + # Плоский ряд (без сезонности) → каждый month_mean == overall_mean → 1.0. + months = _months(36) + units = [_BASELINE_UNITS] * 36 + adj = seasonal_factors(months, units) + # applied может быть False (все ровно 1.0) — сезонности нет. + assert all(abs(f - 1.0) < 1e-9 for f in adj.factors.values()) + + def test_empty_series_neutral(self) -> None: + adj = seasonal_factors([], []) + assert adj.applied is False + assert adj.n_full_years == 0 + assert all(f == 1.0 for f in adj.factors.values()) + + def test_all_zero_series_neutral_no_div_zero(self) -> None: + # overall_mean == 0 → нейтраль, без деления на ноль. + months = _months(36) + adj = seasonal_factors(months, [0] * 36) + assert adj.applied is False + assert all(f == 1.0 for f in adj.factors.values()) + + def test_month_with_no_observations_factor_one(self) -> None: + # Guard «месяц без наблюдений → фактор 1.0» (defensive): март всегда пуст + # (None). Чтобы пройти year-guard и реально дойти до per-month ветки, + # ослабляем порог (min_full_years=0). Остальные месяцы получают свои факторы, + # март — нейтраль (нет базы оценки), без KeyError / деления на пустое среднее. + months = _months(36) + units = _seasonal_units(months) + vals: list[float | int | None] = [ + (None if d.month == 3 else u) for d, u in zip(months, units, strict=False) + ] + adj = seasonal_factors(months, vals, min_full_years=0) + assert adj.factors[3] == 1.0 # март без наблюдений → нейтраль + # Соседний июль наблюдается → его фактор НЕ нейтральный (паттерн +30%). + assert abs(adj.factors[7] - 1.3) < 0.1 + + +# ── deseasonalize_values (pure) ─────────────────────────────────────────────── + + +class TestDeseasonalizeValues: + def test_divides_by_factor(self) -> None: + months = [date(2021, 1, 1), date(2021, 7, 1)] + values: list[float | int | None] = [70, 130] + factors = {1: 0.7, 7: 1.3} + out = deseasonalize_values(months, values, factors) + assert out[0] is not None and abs(out[0] - 100.0) < 1e-9 + assert out[1] is not None and abs(out[1] - 100.0) < 1e-9 + + def test_none_preserved(self) -> None: + months = [date(2021, 1, 1), date(2021, 2, 1)] + out = deseasonalize_values(months, [None, 70], {1: 0.7, 2: 0.7}) + assert out[0] is None + assert out[1] is not None and abs(out[1] - 100.0) < 1e-9 + + def test_nonpositive_factor_falls_back_to_one(self) -> None: + # Фактор ≤ 0 / отсутствующий месяц → нейтраль 1.0 (без деления на 0/neg). + months = [date(2021, 1, 1), date(2021, 2, 1)] + out = deseasonalize_values(months, [50, 60], {1: 0.0, 2: -1.0}) + assert out == [50.0, 60.0] + + +# ── normalize_demand (public API, SalesSeries shape) ────────────────────────── + + +class TestNormalizeDemand: + def test_seasonality_removed_month_means_equalised(self) -> None: + # DoD GATE: сезонный ряд за 3 года → после normalize month-of-year средние + # ~равны (within tolerance), т.е. сезонность снята. + months = _months(36) + units = _seasonal_units(months) + raw_means = _month_of_year_means(months, [float(u) for u in units]) + # До нормализации: лето заметно выше зимы. + assert max(raw_means.values()) / min(raw_means.values()) > 1.5 + + result = normalize_demand(_make_series(months, units)) + assert isinstance(result, SalesSeries) + adj_means = _month_of_year_means(months, [float(u) for u in result.units]) + # После: разброс месячных средних схлопнут (≤ ~5% спред — остаток округления). + spread = max(adj_means.values()) / min(adj_means.values()) + assert spread < 1.05, (spread, adj_means) + + def test_flat_series_unchanged(self) -> None: + months = _months(36) + units = [_BASELINE_UNITS] * 36 + result = normalize_demand(_make_series(months, units)) + assert result.units == units # плоский ряд не тронут + + def test_thin_data_returned_unchanged(self) -> None: + # < 2 полных лет → факторы 1.0 → ряд возвращается без изменений (тот же объект). + months = _months(18) + units = _seasonal_units(months) + series = _make_series(months, units) + result = normalize_demand(series) + assert result is series # short-circuit: applied=False → возвращаем как есть + assert result.units == units + + def test_empty_series_empty_no_crash(self) -> None: + empty = _make_series([], []) + result = normalize_demand(empty) + assert result.months == [] + assert result.units == [] + + def test_other_fields_preserved(self) -> None: + months = _months(36) + units = _seasonal_units(months) + series = _make_series(months, units) + result = normalize_demand(series) + # Дессзонивание трогает только units; прочее — без изменений. + assert result.months == series.months + assert result.area_m2 == series.area_m2 + assert result.avg_price_per_m2 == series.avg_price_per_m2 + assert result.source == series.source + assert result.segment == series.segment + assert result.confidence == series.confidence + + def test_deseasonalized_units_nonnegative_int(self) -> None: + # Контракт SalesSeries.units = list[int] ≥ 0 сохраняется после нормализации. + months = _months(36) + units = _seasonal_units(months) + result = normalize_demand(_make_series(months, units)) + assert all(isinstance(u, int) and u >= 0 for u in result.units)