Compare commits
1 commit
main
...
fix/2464-d
| Author | SHA1 | Date | |
|---|---|---|---|
| e7c79c9646 |
7 changed files with 151 additions and 17 deletions
|
|
@ -1436,9 +1436,27 @@ def _velocity_baseline(
|
||||||
|
|
||||||
Migrated from domrf_kn_sale_graph (stale since 2026-01) to
|
Migrated from domrf_kn_sale_graph (stale since 2026-01) to
|
||||||
objective_corpus_room_month (updated weekly via Objective API).
|
objective_corpus_room_month (updated weekly via Objective API).
|
||||||
objective_corpus_room_month.district matches domrf_kn_objects.district_name.
|
|
||||||
class filter uses 'class' column (Комфорт/Бизнес/Стандарт).
|
class filter uses 'class' column (Комфорт/Бизнес/Стандарт).
|
||||||
|
|
||||||
|
ВНИМАНИЕ ПРО СЛОВАРЬ РАЙОНОВ. Прежняя редакция утверждала, что
|
||||||
|
`objective_corpus_room_month.district` совпадает с
|
||||||
|
`domrf_kn_objects.district_name`. Это неверно, и docstring `_elasticity_coef`
|
||||||
|
ниже описывает ту же колонку правильно: там МИКРО-вокабуляр ЕКБ.
|
||||||
|
|
||||||
|
Замер прода 20.08.2026:
|
||||||
|
district (микро) Академический, ВИЗ, Юго-Западный, Уктус, Втузгородок,
|
||||||
|
Широкая Речка, Центр, Эльмаш, …
|
||||||
|
district_name (админ) Академический, Чкаловский, Верх-Исетский, Ленинский,
|
||||||
|
Орджоникидзевский, Кировский, …
|
||||||
|
|
||||||
|
Пересечение частичное: из 8 админ-имён в микро-колонке встречаются 4, и с
|
||||||
|
сильно меньшим объёмом (Ленинский 55 точек против 621 у Академического;
|
||||||
|
Чкаловский и Верх-Исетский — ноль). Вызывающий передаёт сюда
|
||||||
|
`district_row["district_name"]`, то есть АДМИН-имя: для половины районов
|
||||||
|
выборка пустая, для остальных — заметно урезанная. Резолв admin→micros
|
||||||
|
(как в `_elasticity_coef`, #1211) здесь НЕ сделан — это отдельная задача,
|
||||||
|
docstring лишь перестаёт утверждать обратное (#2464).
|
||||||
|
|
||||||
Returns dict {realised_per_month_median, realised_per_month_avg,
|
Returns dict {realised_per_month_median, realised_per_month_avg,
|
||||||
objects_count, observations}. All-None means no data → caller falls back.
|
objects_count, observations}. All-None means no data → caller falls back.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -96,10 +96,12 @@ _MACRO_COEF_NEUTRAL: float = 1.0
|
||||||
# режима (зеркалит дух лагов §9.6, где полугодовой лаг ловит ипотечный эффект).
|
# режима (зеркалит дух лагов §9.6, где полугодовой лаг ловит ипотечный эффект).
|
||||||
_TREND_WINDOW_MONTHS: int = 6
|
_TREND_WINDOW_MONTHS: int = 6
|
||||||
|
|
||||||
# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.45) ──────────────
|
# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.53) ──────────────
|
||||||
# Веса — экспертная оценка вклада каждого канала в макрорежим спроса (НЕ фит).
|
# Веса — экспертная оценка вклада каждого канала в макрорежим спроса (НЕ фит).
|
||||||
# Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ.
|
# Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ.
|
||||||
# Backed-каналы (rate/mortgage_rate/issuance/overdue) несут основную массу: ставка и
|
# Backed-каналы (rate/mortgage_rate/issuance/overdue/inflation) несут основную массу:
|
||||||
|
# 0.18+0.12+0.10+0.05+0.08 = 0.53. Прежде здесь стояло 0.45 — цифра до #946, где
|
||||||
|
# inflation стал backed-каналом с весом 0.08; сумму тогда не обновили (#2464). Ставка и
|
||||||
# стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ.
|
# стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ.
|
||||||
# Degraded-каналы (gov/income/confidence) имеют НЕнулевые веса в схеме (резерв
|
# Degraded-каналы (gov/income/confidence) имеют НЕнулевые веса в схеме (резерв
|
||||||
# под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают.
|
# под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают.
|
||||||
|
|
|
||||||
|
|
@ -301,16 +301,19 @@ def get_monthly_macro(
|
||||||
ЛЮБЫХ данных всё равно присутствует (все поля None для него — кроме carry key_rate).
|
ЛЮБЫХ данных всё равно присутствует (все поля None для него — кроме carry key_rate).
|
||||||
|
|
||||||
Graceful: при сбое БД или пустой таблице key_rate сетка месяцев всё равно
|
Graceful: при сбое БД или пустой таблице key_rate сетка месяцев всё равно
|
||||||
возвращается, но с None-полями (НЕ crash). Пустой список [] — только если
|
возвращается, но с None-полями (НЕ crash).
|
||||||
сама сетка пуста (months_back < 0).
|
|
||||||
|
Пустой список [] недостижим: months_back клампится через max(0, ...), поэтому
|
||||||
|
даже при отрицательном вводе сетка содержит текущий месяц. Прежняя редакция
|
||||||
|
обещала [] «при months_back < 0» — это описывало поведение, которого нет (#2464).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: SQLAlchemy sync Session.
|
db: SQLAlchemy sync Session.
|
||||||
months_back: глубина ряда в месяцах (по умолчанию _DEFAULT_MONTHS_BACK).
|
months_back: глубина ряда в месяцах (по умолчанию _DEFAULT_MONTHS_BACK).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Список MonthlyMacro по возрастанию month (по непрерывной сетке);
|
Список MonthlyMacro по возрастанию month (по непрерывной сетке).
|
||||||
[] только при пустой сетке (months_back < 0).
|
Пустым не бывает: см. про клампинг выше.
|
||||||
"""
|
"""
|
||||||
# month-bucketing в локальной tz сервера (single-region, как и весь codebase)
|
# month-bucketing в локальной tz сервера (single-region, как и весь codebase)
|
||||||
today = date.today()
|
today = date.today()
|
||||||
|
|
|
||||||
|
|
@ -479,8 +479,12 @@ def build_sales_series(
|
||||||
bias на старых месяцах — каведат в module docstring).
|
bias на старых месяцах — каведат в module docstring).
|
||||||
|
|
||||||
Graceful: при сбое БД / пустых данных возвращается ряд по сетке с units=0,
|
Graceful: при сбое БД / пустых данных возвращается ряд по сетке с units=0,
|
||||||
area/price=None, confidence='low' (НЕ crash). Пустой ряд (months=[]) — только
|
area/price=None, confidence='low' (НЕ crash).
|
||||||
если сетка пуста (months_back < 0).
|
|
||||||
|
Пустой ряд (months=[]) недостижим: months_back клампится через max(0, ...),
|
||||||
|
поэтому даже при отрицательном вводе сетка содержит текущий месяц. Прежняя
|
||||||
|
редакция обещала пустой ряд «при months_back < 0» — это описывало поведение,
|
||||||
|
которого нет (#2464).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: SQLAlchemy sync Session.
|
db: SQLAlchemy sync Session.
|
||||||
|
|
|
||||||
|
|
@ -876,15 +876,24 @@ class NSPDClient:
|
||||||
Шаги:
|
Шаги:
|
||||||
1. `search_by_cad(quarter_cad, thematic_id=2)` — получить полигон квартала
|
1. `search_by_cad(quarter_cad, thematic_id=2)` — получить полигон квартала
|
||||||
2. Compute bbox в EPSG:3857 из quarter geometry (или None если NSPD пуст)
|
2. Compute bbox в EPSG:3857 из quarter geometry (или None если NSPD пуст)
|
||||||
3. Для каждого core layer → `get_features_in_bbox(layer_id, bbox)`
|
3. Core layers: parcels/buildings — legacy `get_features_in_bbox`
|
||||||
4. Если include_zouit — то же для 5 ЗОУИТ layers
|
(1 запрос); territorial_zones/red_lines/engineering_structures —
|
||||||
5. Если include_risks — то же для 11 risk layers
|
`get_features_in_bbox_grid` при grid_n=7, то есть 49 запросов КАЖДЫЙ
|
||||||
|
(см. _GRID_WALK_LAYERS и docstring get_features_in_bbox_grid)
|
||||||
|
4. Если include_zouit — 5 ЗОУИТ layers, все через grid-walk
|
||||||
|
5. Если include_risks — 11 risk layers, все через grid-walk
|
||||||
|
|
||||||
Стоимость HTTP:
|
Стоимость HTTP:
|
||||||
- core only: 1 (search) + 5 (core layers) = 6 запросов
|
- core only: 1 (search) + 2*1 (legacy) + 3*49 (grid) = 150 запросов
|
||||||
- +zouit: +5 = 11 запросов
|
- +zouit: +5*49 = 395 запросов
|
||||||
- +risks: +11 = 22 запроса
|
- +risks: +11*49 = 934 запроса
|
||||||
При rate_ms=600 один dump = ~3.6с (core) / ~6.6с (+zouit) / ~13с (всё).
|
При rate_ms=600 один dump = ~90с (core) / ~237с (+zouit) / ~560с (всё).
|
||||||
|
|
||||||
|
Прежняя редакция обещала 6/11/22 запроса и ~3.6с/~6.6с/~13с — цифры для
|
||||||
|
мира, где все слои идут legacy-путём. Занижение в 25-42 раза, и это не
|
||||||
|
безобидно: по такой оценке слои включают не задумываясь, а объём запросов
|
||||||
|
здесь — прямой фактор WAF-риска (#2464; ср. #2956, где НСПД сейчас отдаёт
|
||||||
|
403 на IP VPS).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
quarter_cad: 3-сегментный cad-номер квартала, e.g. '66:41:0204016'.
|
quarter_cad: 3-сегментный cad-номер квартала, e.g. '66:41:0204016'.
|
||||||
|
|
|
||||||
|
|
@ -324,7 +324,11 @@ def denorm_dump(
|
||||||
одной строки не откатывает весь batch.
|
одной строки не откатывает весь batch.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: SQLAlchemy Session. Caller отвечает за commit/close после вызова.
|
db: SQLAlchemy Session. Функция САМА делает commit в конце (см. ниже);
|
||||||
|
на вызывающем остаётся только close. Прежняя редакция обещала
|
||||||
|
обратное — «caller отвечает за commit/close», — и вызывающий,
|
||||||
|
понадеявшийся обернуть это в свою транзакцию, получил бы уже
|
||||||
|
зафиксированные строки (#2464).
|
||||||
quarter_cad: 3-сегментный кадастровый квартал.
|
quarter_cad: 3-сегментный кадастровый квартал.
|
||||||
features: плоский list из features_json JSONB (уже декодированный Python list).
|
features: плоский list из features_json JSONB (уже декодированный Python list).
|
||||||
|
|
||||||
|
|
|
||||||
94
backend/tests/services/test_2464_docs_match_code.py
Normal file
94
backend/tests/services/test_2464_docs_match_code.py
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Числа в документации сверяются с кодом, а не живут отдельно (#2464).
|
||||||
|
|
||||||
|
Оба расхождения ниже — реальные, найденные 20.08.2026, и оба были незаметны: цифра в
|
||||||
|
комментарии не проверяется ничем, а расходится тихо при первой же правке констант.
|
||||||
|
|
||||||
|
• macro_coefficient: комментарий обещал сумму backed-весов 0.45. С #946 inflation
|
||||||
|
стал backed-каналом с весом 0.08, сумма стала 0.53 — комментарий не обновили.
|
||||||
|
|
||||||
|
• nspd_client.search_by_quarter: docstring обещал 6/11/22 запроса и ~3.6с/~6.6с/~13с.
|
||||||
|
Фактически три из пяти core-слоёв и ВСЕ zouit/risk идут grid-walk'ом по 49 запросов:
|
||||||
|
150/395/934 запроса, ~90с/~237с/~560с. Занижение в 25-42 раза — а объём запросов
|
||||||
|
здесь прямой фактор WAF-риска.
|
||||||
|
|
||||||
|
Гейт сверяет то, что НАПИСАНО, с тем, что ВЫЧИСЛЯЕТСЯ из констант. Красное здесь
|
||||||
|
означает «текст разошёлся с кодом», а не «текст непривычно отформатирован».
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_backed_weight_sum_in_comment_matches_constants() -> None:
|
||||||
|
"""Сумма из комментария обязана совпасть с суммой backed-констант."""
|
||||||
|
from app.services.forecasting import macro_coefficient as mc
|
||||||
|
|
||||||
|
src = Path(mc.__file__).read_text()
|
||||||
|
m = re.search(r"СУММА backed-весов = ([0-9.]+)", src)
|
||||||
|
assert m is not None, "в файле пропала строка «СУММА backed-весов = …» — гейт ослеп"
|
||||||
|
documented = float(m.group(1))
|
||||||
|
|
||||||
|
actual = round(
|
||||||
|
mc._W_RATE + mc._W_MORTG_RATE + mc._W_ISSUANCE + mc._W_OVERDUE + mc._W_INFLATION, 4
|
||||||
|
)
|
||||||
|
assert documented == actual, (
|
||||||
|
f"в комментарии сумма backed-весов {documented}, по константам {actual} — "
|
||||||
|
"текст разошёлся с кодом"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _documented_request_counts(src: str) -> tuple[int, int, int]:
|
||||||
|
core = re.search(r"core only:.*?= (\d+) запрос", src)
|
||||||
|
zouit = re.search(r"\+zouit:\s*\+\d+\*\d+\s*= (\d+) запрос", src)
|
||||||
|
risks = re.search(r"\+risks:\s*\+\d+\*\d+\s*= (\d+) запрос", src)
|
||||||
|
assert core and zouit and risks, "в docstring пропала смета HTTP-запросов — гейт ослеп"
|
||||||
|
return int(core.group(1)), int(zouit.group(1)), int(risks.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_cost_in_docstring_matches_layer_dispatch() -> None:
|
||||||
|
"""Смета запросов обязана следовать из _GRID_WALK_LAYERS, а не из памяти автора."""
|
||||||
|
from app.services.scrapers import nspd_client as nc
|
||||||
|
|
||||||
|
src = Path(nc.__file__).read_text()
|
||||||
|
doc_core, doc_zouit, doc_risks = _documented_request_counts(src)
|
||||||
|
|
||||||
|
grid = set(nc._GRID_WALK_LAYERS)
|
||||||
|
core_layers = [
|
||||||
|
"parcels",
|
||||||
|
"buildings",
|
||||||
|
"territorial_zones",
|
||||||
|
"red_lines",
|
||||||
|
"engineering_structures",
|
||||||
|
]
|
||||||
|
grid_n = 7
|
||||||
|
per_grid = grid_n * grid_n
|
||||||
|
|
||||||
|
core_grid = sum(1 for c in core_layers if c in grid)
|
||||||
|
core_legacy = len(core_layers) - core_grid
|
||||||
|
n_zouit = len(nc.NSPDClient.QUARTER_ZOUIT_LAYERS)
|
||||||
|
n_risks = len(nc.NSPDClient.QUARTER_RISK_LAYERS)
|
||||||
|
|
||||||
|
exp_core = 1 + core_legacy + core_grid * per_grid
|
||||||
|
exp_zouit = exp_core + n_zouit * per_grid
|
||||||
|
exp_risks = exp_zouit + n_risks * per_grid
|
||||||
|
|
||||||
|
assert (doc_core, doc_zouit, doc_risks) == (exp_core, exp_zouit, exp_risks), (
|
||||||
|
f"docstring обещает {doc_core}/{doc_zouit}/{doc_risks} запросов, "
|
||||||
|
f"по диспетчеризации слоёв выходит {exp_core}/{exp_zouit}/{exp_risks}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_grid_walk_membership_is_not_empty() -> None:
|
||||||
|
"""Контроль на сам гейт: если _GRID_WALK_LAYERS опустеет, расчёт станет
|
||||||
|
тавтологически совпадать с любой мелкой цифрой в docstring."""
|
||||||
|
from app.services.scrapers import nspd_client as nc
|
||||||
|
|
||||||
|
grid = set(nc._GRID_WALK_LAYERS)
|
||||||
|
assert len(grid) >= 10, f"grid-walk слоёв всего {len(grid)} — смета считается не по тому"
|
||||||
|
assert "territorial_zones" in grid, "core-слой выпал из grid-walk — пересчитать смету"
|
||||||
Loading…
Add table
Reference in a new issue