gendesign/tradein-mvp/backend/app/services/estimator.py
bot-backend ec7838b7a9
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
fix(#3398): пустой пул в фоновой догрузке — WARNING без трейсбека (общая функция); комментарий про stateless
На проде `ESTIMATE_EXTERNAL_SOURCES_BACKGROUND=true` (docker-compose.prod.yml:296),
поэтому синхронный cian-вызов идёт с `fetch_on_miss=False` и возвращает None ДО
прокси-слоя (`providers/cian/valuation.py:171`) — добавленная в этой ветке ветка
WARNING в `estimate_quality` на проде почти не звучит. Настоящий фетч уходит в
`_defer_external_refresh`, где `NoProxyAvailableError` попадал в общий
`except Exception: logger.exception(...)` → ERROR + traceback → событие в GlitchTip
на каждый /estimate по новому адресу: ровно тот шум, который PR и убирает.

Правка в ОБЩЕЙ функции отложенной догрузки, а не в cian-ветке: через неё идут все
источники фонового режима (yandex тоже — у него swallow живёт внутри
`_get_or_fetch_yandex_valuation_cached`, дыры нет, но следующий источник получит
поведение бесплатно). Для прочих исключений всё как было: `logger.exception`.

Тест по значению: background=True + пустой пул в production → фоновая догрузка cian
логирует WARNING «пул прокси пуст», записей ERROR/traceback у логгера эстиматора нет.
Задача дожидается внутри того же loop'а и не снимая патчей (`_DEFERRED_REFRESH_TASKS`
+ `asyncio.gather`) — иначе `anyio.run` закрывает loop раньше старта задачи и тест
был бы зелёным по построению. На HEAD ветки тест красный:
  ERROR app.services.estimator:estimator.py:910 deferred cian_valuation: догрузка не
  удалась (кэш не прогрет) + Traceback … NoProxyAvailableError.

Комментарий у `_c_kwargs`: весь dict переиспользуется замыканием фоновой задачи, то
есть `config`/`proxy_provider` — один инстанс на два возможно-одновременных вызова.
Корректно ровно пока оба stateless (`RealScraperConfig` — read-only снимок настроек,
`RealProxyProvider` без полей, короткая сессия БД на операцию); появится per-вызов
состояние — фоновой задаче нужен свой инстанс.
2026-09-06 11:58:42 +05:00

7710 lines
420 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Trade-In Estimator — реальное SQL aggregation поверх listings + deals.
Заменяет старый _mock_estimate() из api/v1/trade_in.py.
Алгоритм:
1. Geocode address → (lat, lon)
2. SELECT listings с фильтрами:
- PostGIS ST_DWithin (geom, point, 1000m) — радиус поиска
- source ≠ avito (у Avito фейковые anchor-jitter координаты — не гео-аналог)
- rooms = target_rooms (точное совпадение)
- area_m2 BETWEEN target × 0.85 AND target × 1.15
- scraped_at > NOW() - 14 days (свежие)
- is_active = true
3. Tukey outlier filter (1.5 × IQR rule)
4. Median / Q1 / Q3 / count → confidence
5. То же для deals (period = 12 mo).
6. Сохранить в trade_in_estimates + вернуть AggregatedEstimate
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import math
import re
import statistics
import time
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from typing import Any, Literal
from uuid import uuid4
from fastapi import HTTPException
from scraper_kit.providers.avito.imv import (
IMVAddressNotFoundError,
IMVAuthError,
IMVEvaluation,
IMVTransientError,
compute_imv_cache_key,
evaluate_via_imv,
save_imv_evaluation,
)
from scraper_kit.providers.cian.valuation import (
CianValuationResult,
estimate_via_cian_valuation,
)
from scraper_kit.providers.yandex.valuation import (
YandexValuationResult,
YandexValuationScraper,
)
from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings
from app.core.db import SessionLocal
from app.schemas.trade_in import (
AggregatedEstimate,
AnalogLot,
AvitoImvSummary,
CianValuationSummary,
DkpCorridor,
PriceTrendPoint,
TradeInEstimateInput,
)
from app.services.dadata import DadataAddressResult
# enrich_address = /clean с graceful fallback на /suggest (#dadata-403: на проде услуга
# CLEAN выключена → house_fias_id/гео берём из token-only /suggest). Локальный алиас
# оставлен как dadata_clean_address — это точка патча в 11 тестах (без churn).
from app.services.dadata import enrich_address as dadata_clean_address
from app.services.geocoder import (
SVERDLOVSK_OBLAST_CITIES,
GeocodeResult,
geocode,
is_within_oblast66_bbox,
)
from app.services.house_metadata import get_house_metadata
from app.services.matching.houses import match_house_readonly, match_or_create_house
from app.services.scraper_adapters import RealProxyProvider, RealScraperConfig
from app.services.scraper_settings import get_scraper_delay
from app.tasks.asking_to_sold_ratio import area_bucket
logger = logging.getLogger(__name__)
# ── Constants ────────────────────────────────────────────────────────────────
DEFAULT_RADIUS_M = 1000 # ПО ВСТРЕЧЕ ПТИЦЫ: «локация не дальше 800-1000 м»
FALLBACK_RADIUS_M = 2000
AREA_TOLERANCE = 0.15 # ±15% площади
MAX_ANALOGS_PER_ADDRESS = 5 # анти-bias: не больше 5 лотов с одного адреса
MIN_ANALOGS_PER_SOURCE = 5 # гарантированный минимум на live source
# LISTINGS_FRESH_DAYS (окно свежести объявлений) живёт в app.core.config — #2656:
# его читает и ночной пересчёт asking_to_sold_ratios, а импортировать estimator
# оттуда нельзя (estimator сам тянет area_bucket из этой задачи → цикл импорта).
DEALS_PERIOD_MONTHS = 12 # сделки за последний год
# ── ЭТАП 4 B2C launch — anonymous consent-before-save (152-ФЗ) ────────────────
# Отдельная пара от _CONSENT_POLICY_VERSION/_CONSENT_TEXT_SNAPSHOT в
# app/api/v1/lead.py: там согласие на КОНТАКТ-заявку (обработка телефона для
# CRM), здесь — согласие на САМУ ОЦЕНКУ (обработка адреса + параметров квартиры
# для расчёта стоимости). Разный предмет обработки -> разный текст/версия, даже
# если оба сейчас датированы одним месяцем.
#
# Enforcement: `require_consent` — явный keyword-only флаг у estimate_quality(),
# НЕ вывод из created_by is None. Причина: estimate_quality() — единственная
# ФУНКЦИЯ, но с ДЕСЯТКАМИ прямых вызовов из тестов эстиматора (test_same_
# building_anchor.py, test_estimator_quarter_index.py и т.д.), почти все зовут
# её как estimate_quality(payload, db) без created_by вообще — они тестируют
# ценовую логику, не auth. Если бы gate триггерился от created_by is None,
# ЛЮБОЙ такой тест внезапно стал бы "анонимным" и падал на 422 (проверено:
# сломал 92 теста при первой попытке этого PR). require_consent=False по
# умолчанию -> НИ ОДИН существующий вызов не меняет поведение. Единственный
# реальный (production) вызывающий — app/api/v1/trade_in.py::estimate() —
# явно передаёт require_consent=(x_authenticated_user is None), т.е. gate
# реально применяется РОВНО там и тогда, где решение принимает rbac (нет
# X-Authenticated-User => анонимный запрос).
#
# СИНХРОННОСТЬ С ФРОНТОМ: сегодня анонимный флоу ещё не открыт (rbac_guard
# требует X-Authenticated-User на ЛЮБОМ non-public path — см. app/core/rbac.py),
# поэтому у этого текста пока НЕТ живого фронтового чекбокса для сверки (в
# отличие от _CONSENT_TEXT_SNAPSHOT в lead.py, см.
# tests/test_consent_text_frontend_sync.py). Когда анонимный /estimate откроется
# и фронт получит свой чекбокс — добавь сюда симметричный sync-тест ПРЕЖДЕ, чем
# полагаться на комментарий (ровно та ошибка, которую эта задача чинит для лидов).
_ESTIMATE_CONSENT_POLICY_VERSION = "2026-07"
_ESTIMATE_CONSENT_TEXT_SNAPSHOT = (
"Согласен(-на) на обработку персональных данных (адрес объекта и параметры "
"квартиры) в целях предварительной оценки стоимости в соответствии с "
"Федеральным законом «О персональных данных» № 152-ФЗ"
)
def _estimate_consent_persist_fields(
require_consent: bool, client_ip: str | None
) -> dict[str, Any]:
"""Поля consent/client_ip/policy/snapshot для INSERT в trade_in_estimates.
require_consent=True -> здесь мы УЖЕ прошли gate в начале estimate_quality()
(payload.consent is True гарантирован), поэтому пишем durable-доказательство.
require_consent=False (B2B-путь, дефолт для всех прочих вызывающих) -> все
четыре NULL: согласие закрыто договором либо вызывающий вообще не участвует
в consent-контракте (внутренние тесты/скрипты) — доказательство здесь не
собиралось и собираться не должно.
"""
if require_consent:
return {
"consent": True,
"client_ip": client_ip,
"consent_policy_version": _ESTIMATE_CONSENT_POLICY_VERSION,
"consent_text_snapshot": _ESTIMATE_CONSENT_TEXT_SNAPSHOT,
}
return {
"consent": None,
"client_ip": None,
"consent_policy_version": None,
"consent_text_snapshot": None,
}
# #oblast-D (non-EKB deals-headline-fallback): минимум ДКП-сделок, чтобы
# _fetch_dkp_corridor доверял СВОЕЙ street-scoped выборке — иначе (тонкая
# конкретная улица небольшого города) виджет расширяется до city-wide (см.
# _fetch_dkp_corridor). ОТДЕЛЬНО — минимум, чтобы _price_from_inputs счёл
# коридор пригодным как HEADLINE (не просто advisory/clamp/floor) при
# отсутствии листинговых аналогов — см. deals-headline-fallback ниже.
DKP_CORRIDOR_CITY_WIDE_MIN_N = 3
DEALS_HEADLINE_FALLBACK_MIN_N = 3
# #oblast-E (money-path sufficiency gate, live-audit 2026-08-02): минимум
# LIVE-объявлений, из которых можно честно построить headline-медиану по
# рынку. Ниже порога median() по 1-4 случайным лотам не отражает рынок —
# live repro на проде: Серов 2к/45м², n=3 листинга → 42 391 ₽/м² (36% vs
# городской ДКП-коридор 54 126 ₽/м², которых сама эта улица не показывала
# из-за тонкой street-выборки), а соседняя улица того же города с другими
# 1-2 случайными лотами даёт разброс до ×1.66. Ниже порога
# _price_from_inputs ОБНУЛЯЕТ радиусную популяцию (listings_clean=[]) —
# 100%-переиспользует уже существующий и покрытый тестами путь «листингов
# нет» (same-building anchor / #oblast-D deals-headline-fallback /
# insufficient_data ниже), а не изобретает новую ветку. Значение 5 выбрано
# по live-данным (n=3 уже недостаточно; n=5 — тот же порог, что
# MIN_ANALOGS_TIER_0 использует для "хватает на строгий когортный тир" —
# согласованная планка "достаточно, чтобы не быть шумом одного-двух лотов").
# НЕ трогает подбор аналогов/тиры/радиусы — только решение, доверять ли
# ИТОГОВОЙ выборке как headline-источнику.
HEADLINE_LISTINGS_MIN_N = 5
# #2899: минимальный размер когорты, при котором ПОЗИЦИЯ объекта в ней (перцентиль)
# перестаёт быть шумом. НЕ переиспользует HEADLINE_LISTINGS_MIN_N=5 намеренно: тот
# отвечает на другой вопрос — «доверять ли выборке как источнику МЕДИАНЫ», а медиана
# устойчива там, где ранг ещё пляшет.
#
# Замер 19.08.2026 (Монте-Карло: из 54 прод-когорт размера 62..125 набирались
# подвыборки размера k, сравнивался ранг объекта в подвыборке с рангом в полной
# когорте; поправка на конечную популяцию учтена):
# k=5 → средняя ошибка ранга 13.4 пп, ярлык «низ/рынок/верх» перепутан в 26.4%
# k=10 → 9.5 пп
# k=15 → 7.6 пп ← точка перелома
# k=20 → 6.7 пп (стоит ещё 10 пп покрытия, даёт ~1 пп точности — не окупается)
# k=30 → 5.4 пп, ярлык перепутан в 10.0%
#
# Согласованный второй сигнал: шаг перцентиля 100/n против ценового разрыва между
# соседями по когорте. При n≈7 шаг 14.3 пп на разрыв 1.8% — один соседний лот двигает
# ярлык на целую категорию; при n≈15 шаг 7.1 пп; при n≈30 — 3.3 пп на 0.8%.
#
# Цена порога: по 1086 историческим оценкам прода n>=15 покрывает 35.8% (n>=5 дало бы
# 76.2%, но с ошибкой ранга вдвое больше). Ниже порога поле остаётся None и UI не
# рисует ничего — «мало данных» уже говорят reliability и relaxations, вторая надпись
# о том же была бы дублированием.
MARKET_PERCENTILE_MIN_N = 15
# #oblast-F (never-block relaxation cascade, product decision 2026-08-10, live
# repro: Академика Парина 46/5 студия 23.1 м² — rooms=1 exact match gave n=4
# и попадала под #oblast-E выше, хотя rooms=0 по тому же адресу давал n=34;
# в радиусе 2 км rooms=0 17-29 м² — 327 активных лотов, rooms=1 — всего 10).
# Продукт: НИКОГДА не отказывать в оценке. Если после существующего 4-шагового
# каскада (tier0-когорта → без когорты → radius=fallback → area ±25%) выборка
# всё ещё < HEADLINE_LISTINGS_MIN_N — estimate_quality() продолжает ослаблять
# параметры подбора (см. #oblast-F блок там), от наименее к наиболее
# искажающему: (a) смежность комнатности, (b) свежесть объявлений, (c) сегмент
# (допустить новостройки), (d) радиус. Каждый применённый шаг попадает в
# AggregatedEstimate.relaxations (честный дисклеймер для пользователя) — гейт
# #oblast-E при этом больше НЕ обнуляет медиану (см. _price_from_inputs), а
# только помечает результат как низконадёжный.
RELAX_ROOMS_ADJACENT_DELTA = 1 # #oblast-F (a): rooms>=2 → BETWEEN rooms-1 AND rooms+1
LISTINGS_FRESH_DAYS_RELAXED = 60 # #oblast-F (b): LISTINGS_FRESH_DAYS 14 → 60 дней
RELAX_RADIUS_STEP1_M = 3000 # #oblast-F (d.1): max(текущий search_radius_m, 3000)
RELAX_RADIUS_STEP2_M = 5000 # #oblast-F (d.2): финальный максимум
# #2632: подпись радиусного ослабления — ЕДИНСТВЕННОЕ место, где число
# фактического радиуса попадает в persisted-состояние (колонка relaxations).
# Отсюда же его читает GET-rehydrate (radius_from_relaxations) — иначе строка и
# парсер разъедутся, и карта опять начнёт рисовать не тот круг.
RADIUS_RELAXATION_PREFIX = "радиус расширен до "
def radius_relaxation_label(radius_m: int) -> str:
"""RU-подпись шага (d) каскада #oblast-F для AggregatedEstimate.relaxations."""
return f"{RADIUS_RELAXATION_PREFIX}{radius_m} м"
def radius_from_relaxations(relaxations: list[str] | None) -> int | None:
"""Обратный разбор подписи: фактический радиус (м) или None, если его нет.
Нужен на GET-rehydrate: сам search_radius_m колонкой не персистится, но
подпись каскада — да (миграция 255), и в ней лежит ровно то число, по
которому реально отбирались аналоги.
"""
for label in relaxations or []:
if label.startswith(RADIUS_RELAXATION_PREFIX):
digits = label[len(RADIUS_RELAXATION_PREFIX) :].removesuffix(" м").strip()
if digits.isdigit():
return int(digits)
return None
def rehydrate_search_radius_m(
relaxations: list[str] | None, analog_distances_m: list[int | None]
) -> int | None:
"""Фактический радиус подбора для СОХРАНЁННОЙ оценки (#2632).
Колонки под search_radius_m нет, поэтому восстанавливаем из того, что
персистится:
1) подпись каскада «радиус расширен до N м» — точное значение, то же,
что вернул POST (строки, созданные с 2026-08-10, миграция 255);
2) иначе — размах сохранённых аналогов, но не меньше DEFAULT_RADIUS_M:
круг обязан вмещать пины, которые сам же и показывает (тот же приём
уже применён во v2 SourcesMap — там радиус круга = max distance_m).
None — расстояний нет вовсе (старые/anchor-строки): вызывающий честно
падает обратно на выбор пользователя, как и до этой правки.
"""
labelled = radius_from_relaxations(relaxations)
if labelled is not None:
return labelled
known = [d for d in analog_distances_m if d is not None]
if not known:
return None
return max(DEFAULT_RADIUS_M, max(known))
# #794: СберИндекс time-adjustment of frozen Rosreestr ДКП deals.
# Rosreestr deals freeze ~2026-01; the sber monthly index re-bases a stale deal's ppm²
# to the latest available month. Region fixed to Свердловская обл. (tradein MVP = ЕКБ).
SBER_TIME_ADJUST_REGION = "Свердловская область"
# Coefficient series preference — ТОЛЬКО вторичный рынок (эстиматор оценивает вторичку):
# real_estate_deals (Вторичный, зарег. сделки) → dinamika-tsen-obyavlenii (Вторичный, asking).
# #R2-H1: residential_real_estate_prices УБРАН — для обл.66 это 100% «Первичный рынок»
# (новостройки), даёт направленно ПРОТИВОПОЛОЖНУЮ time-adjust коррекцию для вторички.
SBER_COEFF_DASHBOARDS = ("real_estate_deals", "dinamika-tsen-obyavlenii")
SBER_TIME_FACTOR_MIN = 0.7 # clamp guards against bad/sparse index months
SBER_TIME_FACTOR_MAX = 1.6
# #2141: порог, ниже которого сохранённый asking_to_sold_ratio НЕ пересчитываем из
# expected_sold/median — оставляем табличный ratio байт-в-байт. Отсекает лишь шум
# округления round() (≤ 0.5/median_price ≈ 5e-7 при median ≥ 1M); любой значимый
# сдвиг expected_sold (hedonic/le_asking/corridor-clamp) заведомо больше и триггерит
# честный пересчёт дескриптора. 1e-4 << шаг бейджа round((1ratio)×100) (0.005).
_RATIO_DESCRIPTOR_EPS = 1e-4
# #2209: минимальная относительная полуширина ценового диапазона. При n=1 аналоге
# q1==q3==median → asking-диапазон (Q1..Q3 ₽/м² аналогов) схлопывается в НОЛЬ ширины,
# и юзеру уходит «диапазон» из одной точки с ложной точностью. Живой бэктест
# 2026-07-02 (engine=full, n=276): MAPE low-confidence bucket 14.8 %, медианная
# относительная ширина диапазона 0.743 — floor ±12 % затрагивает только вырожденно
# узкий хвост (n=1..2 аналога), не трогая типичные оценки. Ширина не может честно
# быть у́же типичной ошибки: floor только РАСШИРЯЕТ, точку не двигает.
RANGE_MIN_HALFWIDTH_PCT = 0.12
# #2255: границы ценовых сегментов по ₽/м² (ЕКБ-вторичка) — ЕДИНЫЙ источник для
# эстиматора (_apply_segment_multiplier) и бэктеста (scripts/backtest_estimator.py
# импортирует ЭТУ константу, числа НЕ дублирует). Значение лежит в первом бэнде,
# чей upper_bound (exclusive) оно НЕ превышает; последний бэнд ловит хвост (+inf).
# Верифицировано против config-заметок: p99.9 сделок ≈ 500k, премиум ~680k.
PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
("эконом", 120_000.0),
("комфорт", 160_000.0),
("бизнес", 220_000.0),
("элит", 300_000.0),
("премиум", float("inf")),
)
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
# guard-bands (НЕ относительные) для ЕКБ-вторички: рынок ~100400 К/м² (ср. пороги
# _band_haircut 180/350К), премиум до ~680К. Нижняя/верхняя границы заведомо вне
# рынка — режут «4.98 М за 125 м²» = 39.7 К/м² и т.п. Этаж: ЕКБ-максимум ~52 эт.
#
# Mera-audit fix-4: верифицировано vs данных prod deals (2026-06-21):
# deals p90=172_419 ₽/м², p95=199_583 ₽/м², p99=278_681 ₽/м², p99.9=500_000 ₽/м²
# deals >800k = 13 штук (0.026%) — нерыночные outlier'ы, не легитимный премиум.
# listings p99 (is_active, <800k фильтр) = 392_795 ₽/м².
# Вывод: DEAL_MAX_PPM2=800_000 НЕ режет легитимный ЕКБ-премиум (p99.9 сделок=500k).
# Значение НЕ изменено — порог адекватен. Проверить повторно при p99 > 700_000.
DEAL_MIN_PPM2 = 50_000 # ниже = не arms-length (доля/обременение/ошибка)
DEAL_MAX_PPM2 = 800_000 # выше премиума → опечатка/коммерция (verified p99.9=500k, 2026-06-21)
DEAL_MAX_FLOOR = 60 # выше реального максимума ЕКБ → битый этаж (напр. floor:100)
# #2478: DEAL_MIN_PPM2/MAX_PPM2 выше калиброваны ИСКЛЮЧИТЕЛЬНО под ЕКБ-вторичку.
# Миграция 177 открыла +47183 не-ЕКБ сделок (region 66, deals.city заполнен) —
# глобальный пол 50k ₽/м² для дешёвых городов области НЕ anti-outlier guard, а
# cut-off легитимного рынка (Североуральск median ≈21.7k, Новоуральск ≈36k,
# Асбест ≈41k — все ниже 50k → 46.6% не-ЕКБ сделок молча дропались).
# deal_city_price_bands (миграция 178) — per-city [ppm2_min, ppm2_max] band из
# перцентилей p1/p99 реальных rosreestr-сделок города (hard floor/ceiling
# 8000/800000 всё равно режут доли/опечатки). Екатеринбург НАМЕРЕННО исключён
# из таблицы — .get(city, ...) fallback на глобальные DEAL_MIN_PPM2/MAX_PPM2
# ниже гарантирует byte-identical ЕКБ-поведение.
_CITY_PRICE_BANDS_CACHE_TTL_S = 300.0
_city_price_bands_cache: tuple[dict[str, tuple[int, int]], float] | None = None
def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]:
"""#2478: {city: (ppm2_min, ppm2_max)} из deal_city_price_bands (миграция 178).
Кэш в процессе (TTL _CITY_PRICE_BANDS_CACHE_TTL_S) — таблица рефрешится
периодическим ре-запуском миграции, не на каждый estimate. Таблицы нет /
любая ошибка → {} (graceful) — вызывающий код fallback'ит на глобальные
DEAL_MIN_PPM2/DEAL_MAX_PPM2, т.е. поведение как до #2478.
"""
global _city_price_bands_cache
if _city_price_bands_cache is not None:
bands, fetched = _city_price_bands_cache
if (time.monotonic() - fetched) < _CITY_PRICE_BANDS_CACHE_TTL_S:
return bands
try:
rows = (
db.execute(text("SELECT city, ppm2_min, ppm2_max FROM deal_city_price_bands"))
.mappings()
.all()
)
except Exception as exc:
logger.warning("deal_city_price_bands lookup failed (graceful): %s", exc)
return {}
bands = {r["city"]: (int(r["ppm2_min"]), int(r["ppm2_max"])) for r in rows}
_city_price_bands_cache = (bands, time.monotonic())
return bands
# Правдоподобный диапазон года постройки МКД (guard на входе — Mera-audit 2026-08-02).
# Нижняя граница 1917: массовая многоквартирная застройка в РФ/СССР началась
# после революции — год раньше почти гарантированно ошибка источника
# (house_metadata OSM/кадастр смешивают год постройки дома с годом основания
# места/памятника на тех же координатах — прод-инцидент 2026-08: house_metadata
# отдал year_built=1829 для обычной вторички, см. vault fixes). Верхняя граница
# — текущий год + 3: допуск на цели trade-in со строящимся домом (год сдачи по
# ДДУ известен заранее, но не более чем на несколько лет вперёд).
# Год вне диапазона трактуем как ОТСУТСТВУЮЩИЙ (None), а НЕ клампим к границе —
# хедонический фактор (_price_from_inputs, #2002) экстраполирует regression fit
# far вне обучающей выборки (COHORTS ниже даже не определяет когорту раньше
# 1955 — модель никогда не видела осмысленного объёма домов старше этого), и
# estimate_hedonic_factor_min=0.75 в таком случае не защита, а маскировка
# выхода за диапазон под видом уверенной 25% поправки. «Не знаем год» —
# честный сигнал, который просто отключает year-term фактора (нейтрален).
MIN_PLAUSIBLE_BUILD_YEAR = 1917
MAX_PLAUSIBLE_BUILD_YEAR_LEAD = 3 # текущий год + N — допуск на стройки
def _sanitize_build_year(
year: int | None, *, house_id: int | None = None, address: str | None = None
) -> int | None:
"""Отбрасывает неправдоподобный год постройки, трактуя его как «неизвестен».
Валидный диапазон — [MIN_PLAUSIBLE_BUILD_YEAR, текущий год + LEAD]. Год вне
диапазона логируется на WARNING (с идентификатором дома — house_id либо
адрес) и заменяется на None, а не клампится к границе: клампинг превращает
заведомый мусор источника (house_metadata OSM/кадастр, либо year_built из
payload — ge=1800 в схеме пропускает подобные значения) в уверенный вход
для хедонической поправки (_price_from_inputs), хотя физического смысла
у результата нет.
"""
if year is None:
return None
max_year = datetime.now(UTC).year + MAX_PLAUSIBLE_BUILD_YEAR_LEAD
if year < MIN_PLAUSIBLE_BUILD_YEAR or year > max_year:
logger.warning(
"estimate: implausible year_built=%s dropped (house_id=%s, address=%s) — "
"valid range [%s, %s]",
year,
house_id,
address,
MIN_PLAUSIBLE_BUILD_YEAR,
max_year,
)
return None
return year
# Когорта по году постройки — типизация массовой застройки РФ.
# Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24).
# Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается.
COHORTS = (
# (cohort_name, year_min_inclusive, year_max_inclusive)
("khrushchev", 1955, 1969), # Хрущёвки 5-эт
("brezhnev", 1970, 1989), # Брежневка кирпич/панель 912-эт
(
"late_soviet",
1990,
1999,
), # Поздний СССР (no overlap; first-match would never pick old range)
("2000s", 2000, 2010), # Ранние новостройки
("modern", 2011, 2100), # Современные ЖК
)
# Минимум аналогов чтобы остаться на Tier 0 (с cohort); ниже — fallback на Tier A.
MIN_ANALOGS_TIER_0 = 5
def _target_cohort_range(year_built: int | None) -> tuple[int, int] | None:
"""Maps a target year to its cohort year range [min, max] inclusive.
Returns None if year_built is None — caller will skip cohort filter.
Picks the FIRST matching cohort (so 1988 → 'brezhnev', not 'late_soviet').
"""
if year_built is None:
return None
for _name, ymin, ymax in COHORTS:
if ymin <= year_built <= ymax:
return (ymin, ymax)
# Out-of-range год (например, 1900 или 2050) — cohort фильтр не применяем,
# лучше показать что есть в радиусе, чем 0 результатов.
return None
# Маппинг наших house_type → словарь Avito-IMV (внешний source). НЕ путать с
# _REPAIR_COEF (heuristic-множитель ниже).
_IMV_HOUSE_TYPE_MAP: dict[str | None, str | None] = {
"panel": "panel",
"brick": "brick",
"monolith": "monolith",
"monolith_brick": "monolith_brick",
"monolithic": "monolith",
"block": "block",
"wood": "wood",
None: None,
}
_IMV_REPAIR_MAP: dict[str | None, str | None] = {
"needs_repair": "required",
"standard": "cosmetic",
"good": "euro",
"excellent": "designer",
None: None,
}
# Множители к медиане по состоянию ремонта. Аналоги в выборке — микс состояний;
# коэффициент сдвигает оценку под ремонт целевой квартиры (встреча Птицы: ремонт
# влияет на цену).
#
# WARNING: tunable МАРКЕТ-ЭВРИСТИКА, НЕ data-derived (issue #7). Вывести из данных пока
# нельзя: listings.repair_state покрыт только ~2% (coverage вырастет после #621 backfill),
# а медианы по нему confounded by area (немонотонны). Baseline = standard = 1.00 (no-op:
# было 0.98, срезало каждую «стандартную» оценку на 2% — пофикшено). Пересмотреть при
# coverage > 20% и наборе достаточной выборки по каждому bucket-у (#7).
# После #621: repair_state нормализован → needs_repair/standard/good/excellent на инgesте.
_REPAIR_COEF: dict[str, float] = {
"needs_repair": 0.94, # требует ремонта — ниже рынка
"standard": 1.00, # baseline
"good": 1.05,
"excellent": 1.10, # евроремонт — выше рынка
}
_REPAIR_LABEL: dict[str | None, str] = {
"needs_repair": "требует ремонта",
"standard": "стандартный ремонт",
"good": "хороший ремонт",
"excellent": "евроремонт",
}
def _repair_coefficient(repair_state: str | None) -> float:
"""Множитель к медиане по состоянию ремонта. None → 1.0 (без поправки)."""
if not repair_state:
return 1.0
return _REPAIR_COEF.get(repair_state, 1.0)
# Обратный `_IMV_REPAIR_MAP`: вокабуляр Avito → наш repair_state.
_IMV_REPAIR_MAP_REV: dict[str, str] = {v: k for k, v in _IMV_REPAIR_MAP.items() if k and v}
def _anchor_repair_factor(anchor_renovation: str | None, target_repair: str | None) -> float:
"""#2677: множитель, приводящий домовой IMV-якорь к базису ремонта target'а.
Домовая строка `house_imv_evaluations` запрошена у Avito с ОДНИМ ремонтом
(`renovation_type`, до #2675 — литерал 'cosmetic' у всех 2685 строк), а медиана
к моменту blend'а уже домножена на `_repair_coefficient(target_repair)`. Сравнивать
и смешивать их напрямую — значит класть два разных базиса на одну шкалу: у клиента
с «требует ремонта» медиана 6%, якорь нет, порог `median×1.15` фактически падает до
×1.081, а сработавший blend возвращает половину этих 6% обратно вверх.
Это НЕ заявка на правильность самих коэффициентов (`_REPAIR_COEF` — рыночная
эвристика, см. предупреждение выше): множитель берётся ровно тот, что код УЖЕ
применил к медиане, — чинится расхождение базисов, а не калибровка.
Неизвестный/незнакомый ремонт с любой стороны → 1.0 (no-op, прежнее поведение).
"""
base = _repair_coefficient(_IMV_REPAIR_MAP_REV.get(anchor_renovation or ""))
if base <= 0:
return 1.0
return _repair_coefficient(target_repair) / base
# ── Asking→sold correction ratio lookup (#648 Stage 3) ──────────────────────
# Таблица asking_to_sold_ratios (migration 080) хранит per-rooms коэффициент
# ratio = median(SOLD ppm²) / median(ASKING ppm²) (~0.720.93). Estimator
# домножает ASKING-медиану на этот ratio, получая параллельную expected_sold
# цену (релевантную для выкупа). Headline asking-медиана НЕ меняется.
#
# Кэш: tiny in-process dict {bucket: (ratio, basis, fetched_monotonic)} с TTL.
# Ratio дрейфует медленно (refresh-задача раз в сутки, Stage 4), поэтому 300с
# TTL более чем достаточно и снимает по SELECT'у с каждой оценки. Single-worker
# uvicorn/scheduler — GIL делает dict-доступ atomic enough (без явного lock).
#
# Tier-aware ppm²-путь (#928) УДАЛЁН (#2002): per-price-tier sold/asking — это
# артефакт ratio-of-truncated-medians. Бинирование SOLD-сделок по ASKING-перцентилям
# с делением within-tier медиан рождает мнимый «премиум продаётся ближе к asking»
# градиент (Monte-Carlo с КОНСТАНТНЫМ true sold/asking=0.84 точно воспроизводил
# прод-тиры 0.94/0.99/0.96 и перекошенный сплит сделок 57/27/15%; включение флага
# ухудшало прод — overall MAPE 14.6→17.2, эконом-bias +5.8→+18.1). Валидный
# within-price-tier sold/asking НЕВЫЧИСЛИМ из asking-less ДКП-сделок; корректное
# обусловливание дают per-rooms blend + захардженный hedonic (год+площадь).
_ASKING_SOLD_RATIO_CACHE_TTL_S = 300.0
# Cache key: rooms bucket (единственный legacy per-rooms путь после #2002).
_asking_sold_ratio_cache: dict[int, tuple[float | None, str | None, float]] = {}
def _get_asking_sold_ratio(
db: Session,
rooms: int | None,
area_m2: float | None = None,
anchor_ppm2: float | None = None,
) -> tuple[float | None, str | None]:
"""Возвращает (ratio, basis) asking→sold для area-бакета клиентской квартиры.
bucket = area_bucket(area_m2) при area_m2 > 0, иначе min(max(rooms or 0, 0), 4)
(rooms-фолбэк — только когда площадь неизвестна).
#2620-2 (deep-review, вторая половина #2620): расчёт ratio (asking_to_sold_ratio.py
ask_side) теперь ключуется по area-бакету (rooms_bucket в asking_to_sold_ratios —
это на самом деле area-бакет, легаси-имя колонки), но ДО этой правки применение
здесь читало rooms_bucket по РЕАЛЬНЫМ комнатам клиента — тот же mismatch, что чинили
в расчёте, просто переехавший в применение. Прод-замер ревьюера (2026-08): 310/1038
(29.9%) исторических клиентских запросов легли бы в другой ratio-бакет при
rooms-ключе vs area-ключе. area_bucket() — тот же Python-двойник, что и в
asking_to_sold_ratio.py (см. комментарий там, границы 30/44/62/85 = import-rosreestr.sh).
Запрос к asking_to_sold_ratios (migration 080): per-rooms строка
(WHERE rooms_bucket = bucket AND district = '') → fallback на global -1
(basis='global_fallback'). Ничего нет → (None, None).
anchor_ppm2 сохранён в сигнатуре для совместимости с call-site (резолвер
вызывается с ФИНАЛЬНЫМ headline ppm² после anchor/blend-мутаций — см.
_ratio_resolver), но БОЛЬШЕ НЕ используется: tier-aware путь удалён как
статистический артефакт (#2002, см. module-level комментарий выше).
Таблицы нет / любая ошибка → (None, None), НЕ raise (graceful).
Кэшируется на ключ bucket с TTL _ASKING_SOLD_RATIO_CACHE_TTL_S.
"""
bucket = area_bucket(area_m2) if area_m2 else min(max(rooms or 0, 0), 4)
cached = _asking_sold_ratio_cache.get(bucket)
if cached is not None:
ratio, basis, fetched = cached
if (time.monotonic() - fetched) < _ASKING_SOLD_RATIO_CACHE_TTL_S:
return ratio, basis
ratio: float | None = None
basis: str | None = None
# #2265 D2: SAVEPOINT + 1 retry вокруг ratio-lookup. Транзиентный сбой —
# обычно poisoned tx (InFailedSqlTransaction) от вышестоящего graceful-except:
# первый же SELECT падает, и раньше это гасило expected_sold для КОНКРЕТНОЙ
# оценки (3/224 за 14 дней при живых ratio-бакетах). Оборачиваем в begin_nested:
# при сбое savepoint откатывается, tx чистится (db.rollback), retry идёт на
# здоровом соединении. begin_nested также локализует benign-сбой (таблицы нет
# на свежей/старой БД, миграция 080 не применена) — без отравления outer tx.
last_exc: Exception | None = None
ok = False
for attempt in range(2):
try:
with db.begin_nested():
row = db.execute(
text(
"""
SELECT ratio, basis FROM asking_to_sold_ratios
WHERE rooms_bucket = CAST(:b AS int) AND district = ''
"""
),
{"b": bucket},
).fetchone()
if row is None:
# Бакет тонкий (n<30 при seed'е) или отсутствует → global (-1).
row = db.execute(
text(
"""
SELECT ratio, basis FROM asking_to_sold_ratios
WHERE rooms_bucket = -1 AND district = ''
"""
),
).fetchone()
if row is not None and row.ratio is not None:
ratio = float(row.ratio)
basis = row.basis
ok = True
break
except Exception as exc:
last_exc = exc
# begin_nested откатывает СВОЙ savepoint; но если outer tx уже была
# poisoned до savepoint — сам SAVEPOINT не встаёт → чистим всю tx,
# чтобы retry шёл на здоровом соединении.
try:
db.rollback()
except Exception:
pass
if attempt == 0:
logger.debug("asking_to_sold_ratio lookup failed, retrying on clean tx: %s", exc)
continue
if not ok:
# Оба захода упали — graceful без sold-коррекции. НЕ кэшируем этот None:
# ошибка транзиентна (poisoned tx, миг. лаг, коннект-хиккап). Раньше строка
# кэша ниже писалась безусловно → один сбой отравлял
# _asking_sold_ratio_cache[bucket] = (None, None) на весь TTL (300с) и молча
# гасил expected_sold для ВСЕХ оценок бакета до истечения TTL (#2175). Ранний
# return без записи в кэш → следующая оценка ретраит.
logger.debug("asking_to_sold_ratio lookup skipped (graceful, NOT cached): %s", last_exc)
return None, None
# Кэшируем ТОЛЬКО успешный lookup. ratio может быть None (строки нет —
# стабильный факт БД, безопасно кэшировать на TTL); транзиентный None выше
# уже вернулся ранним return и сюда не доходит.
_asking_sold_ratio_cache[bucket] = (ratio, basis, time.monotonic())
return ratio, basis
# ── Avito IMV cache lookup (Stage 3) ────────────────────────────────────────
IMV_CACHE_TTL_HOURS = 24
# Префиксы в адресе, которые Avito-геокодер не распознаёт (не жилые назначения).
# Пример: "Склад, ул. Заводская, д. 44-а" → "ул. Заводская, д. 44-а"
_NOISE_PREFIX_RE = re.compile(
r"(Склад|Гараж|Подсобка|Нежилое|Помещение|Цех),\s*",
flags=re.IGNORECASE,
)
YANDEX_VALUATION_CACHE_TTL_HOURS = 24
YANDEX_VALUATION_DEFAULT_CATEGORY = "APARTMENT"
YANDEX_VALUATION_DEFAULT_TYPE = "SELL"
async def _get_or_fetch_imv_cached(
db: Session,
*,
address: str,
rooms: int,
area_m2: float,
floor: int,
floor_at_home: int,
house_type: str,
renovation_type: str,
has_balcony: bool,
has_loggia: bool,
estimate_id_for_link: Any = None,
) -> IMVEvaluation | None:
"""Cached IMV lookup. TTL 24h по cache_key (sha256 of address + params).
1. compute cache_key
2. SELECT из avito_imv_evaluations WHERE cache_key = :ck AND fetched_at > NOW() - 24h
3. Если hit → возвращаем reconstructed IMVEvaluation
4. Cache miss → call evaluate_via_imv, save_imv_evaluation, return
Graceful: на любой error возвращаем None (estimator продолжает без IMV).
"""
try:
cache_key = compute_imv_cache_key(
address,
rooms,
area_m2,
floor,
floor_at_home,
house_type,
renovation_type,
has_balcony,
has_loggia,
)
existing = (
db.execute(
text(
"""
SELECT id, cache_key, address, rooms, area_m2, floor, floor_at_home,
house_type, renovation_type, has_balcony, has_loggia,
lat, lon, geo_hash, avito_address_id, avito_location_id,
avito_metro_id, avito_district_id,
recommended_price, lower_price, higher_price, market_count,
raw_response, fetched_at
FROM avito_imv_evaluations
WHERE cache_key = :ck
AND fetched_at > NOW() - (:ttl_hours || ' hours')::interval
ORDER BY fetched_at DESC
LIMIT 1
"""
),
{"ck": cache_key, "ttl_hours": IMV_CACHE_TTL_HOURS},
)
.mappings()
.first()
)
if existing is not None:
logger.info(
"imv: cache HIT key=%s recommended=%d",
cache_key[:8],
existing["recommended_price"],
)
from scraper_kit.providers.avito.imv import IMVGeo
return IMVEvaluation(
cache_key=existing["cache_key"],
address=existing["address"],
rooms=existing["rooms"],
area_m2=float(existing["area_m2"]),
floor=existing["floor"],
floor_at_home=existing["floor_at_home"],
house_type=existing["house_type"],
renovation_type=existing["renovation_type"],
has_balcony=existing["has_balcony"],
has_loggia=existing["has_loggia"],
geo=IMVGeo(
geo_hash=existing["geo_hash"] or "",
lat=existing["lat"],
lon=existing["lon"],
avito_address_id=existing["avito_address_id"],
avito_location_id=existing["avito_location_id"],
avito_metro_id=existing["avito_metro_id"],
avito_district_id=existing["avito_district_id"],
),
recommended_price=existing["recommended_price"],
lower_price=existing["lower_price"],
higher_price=existing["higher_price"],
market_count=existing["market_count"],
raw_response=existing.get("raw_response"),
)
# Cache miss — fresh fetch
logger.info("imv: cache MISS key=%s — fetching fresh", cache_key[:8])
result = await evaluate_via_imv(
address=address,
rooms=rooms,
area_m2=area_m2,
floor=floor,
floor_at_home=floor_at_home,
house_type=house_type,
renovation_type=renovation_type,
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
# #3386: без provider'а `curl_proxy_url` считает use_pool=False (флаг AND
# provider is not None) и уходит на env-прокси SCRAPER_PROXY_URL — мёртвый
# узел (#2613). Один lease на вызов: acquire/release внутри curl_proxy_url,
# release в finally на всех выходах (исключение/таймаут — тоже).
proxy_provider=RealProxyProvider(),
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
logger.info(
"imv: fresh recommended=%d range=(%d, %d) count=%d",
result.recommended_price,
result.lower_price,
result.higher_price,
result.market_count or 0,
)
return result
except IMVAddressNotFoundError as e:
logger.warning("imv: address not found in Avito geocoder: %s", e)
# Retry once with noise prefixes stripped (e.g. "Склад, ул. X" → "ул. X")
cleaned = _NOISE_PREFIX_RE.sub("", address)
if cleaned != address:
logger.info(
"imv: retry with cleaned address %r%r",
address[:60],
cleaned[:60],
)
try:
result = await evaluate_via_imv(
address=cleaned,
rooms=rooms,
area_m2=area_m2,
floor=floor,
floor_at_home=floor_at_home,
house_type=house_type,
renovation_type=renovation_type,
has_balcony=has_balcony,
has_loggia=has_loggia,
config=RealScraperConfig(),
proxy_provider=RealProxyProvider(), # #3386, см. первый вызов выше
)
save_imv_evaluation(db, result, estimate_id=estimate_id_for_link)
logger.info(
"imv: retry OK recommended=%d range=(%d, %d) count=%d",
result.recommended_price,
result.lower_price,
result.higher_price,
result.market_count or 0,
)
return result
except IMVAddressNotFoundError:
logger.warning("imv: cleaned address also not found — giving up")
except Exception as retry_exc:
logger.warning("imv: retry failed: %s", retry_exc)
return None
except IMVAuthError as e:
logger.error(
"imv: auth/quota error — manual action required: %s",
e,
)
return None
except IMVTransientError as e:
logger.warning("imv: transient error, skipping retry in estimator context: %s", e)
return None
except Exception as e:
if caused_by_no_proxy(e):
# #3386: «пул прокси пуст» — НАША инфраструктура, не сбой фетча и не
# transient-ошибка площадки; HTTP-запрос вообще не уходил. Отдельный текст,
# чтобы в логах /estimate это не читалось как «Авито отвалился».
# Возврат None — тот же graceful путь: ответ отдаётся без IMV-якоря, не 5xx.
logger.warning("imv: пул прокси пуст — estimator продолжает без IMV: %s", e)
else:
logger.warning("imv: fetch failed — estimator продолжает без IMV: %s", e)
return None
# ── Yandex Valuation cache lookup (Stage 8) ─────────────────────────────────
def _yandex_valuation_cache_key(address: str, offer_category: str, offer_type: str) -> str:
"""SHA256 cache key for Yandex Valuation lookups."""
payload = f"{address}|{offer_category}|{offer_type}"
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
# ── отложенная догрузка внешних источников (фоновый режим) ───────────────────
#
# Держим ссылки на задачи: asyncio не хранит их сам, и без этого сборщик мусора
# может убить задачу на полпути (документированное поведение create_task).
_DEFERRED_REFRESH_TASKS: set[asyncio.Task[None]] = set()
# Подобран под текущий прод: 1 воркер uvicorn, mem_limit 768m, max_connections
# 100 у Postgres. Догрузка почти всё время ждёт чужой HTTP, поэтому небольшой
# потолок не занижает пропускную способность прогрева.
_MAX_DEFERRED_REFRESH_TASKS = 8
def _defer_external_refresh(label: str, work: Callable[[Session], Awaitable[object]]) -> None:
"""Догрузить внешний источник ПОСЛЕ ответа, наполнив кэш к следующему разу.
Своя сессия — намеренно: сессия запроса закрывается вместе с ответом, а обе
функции источников делают внутри себя `db.commit()`. Переиспользование чужой
сессии зафиксировало бы её незавершённую работу.
Все ошибки гасятся: это прогрев кэша, а не часть ответа. Провал означает лишь
то, что следующий запрос по адресу снова промахнётся мимо кэша.
"""
async def _run() -> None:
db = SessionLocal()
try:
await work(db)
except Exception as exc:
if caused_by_no_proxy(exc):
# #3398: пустой пул — НАША инфраструктура, HTTP-запрос не уходил вовсе
# (NoProxyAvailableError поднимается в curl_proxy_url ДО запроса). WARNING,
# не ERROR: GlitchTip слушает event_level=ERROR, а это штатная деградация
# прогрева, не сбой площадки — событие тут было бы шумом.
# На проде ESTIMATE_EXTERNAL_SOURCES_BACKGROUND=true, поэтому настоящий
# фетч уходит именно сюда: synchronous-ветка с fetch_on_miss=False отдаёт
# None ДО прокси-слоя и до своего WARNING в estimate_quality не доходит.
logger.warning("deferred %s: пул прокси пуст — кэш не прогрет: %s", label, exc)
else:
logger.exception("deferred %s: догрузка не удалась (кэш не прогрет)", label)
finally:
db.close()
# Потолок одновременных догрузок. Без него всплеск по НОВЫМ адресам (ровно
# тот случай, ради которого режим и сделан) породил бы сотни параллельных
# задач, каждая с сессией к базе и HTTP-клиентом — при max_connections=100
# и mem_limit 768m у backend это отказ вместо ускорения.
# Переполнение не ошибка: прогрев кэша необязателен, следующий запрос по
# адресу попробует снова.
if len(_DEFERRED_REFRESH_TASKS) >= _MAX_DEFERRED_REFRESH_TASKS:
logger.info(
"deferred %s: очередь догрузки заполнена (%d) — пропуск",
label,
_MAX_DEFERRED_REFRESH_TASKS,
)
return
try:
task = asyncio.create_task(_run())
except RuntimeError:
# Нет активного loop (синхронный вызов из теста/скрипта) — молча пропускаем:
# прогрев кэша необязателен по определению.
logger.debug("deferred %s: нет event loop — пропуск", label)
return
_DEFERRED_REFRESH_TASKS.add(task)
task.add_done_callback(_DEFERRED_REFRESH_TASKS.discard)
async def _get_or_fetch_yandex_valuation_cached(
db: Session,
*,
address: str,
offer_category: str = YANDEX_VALUATION_DEFAULT_CATEGORY,
offer_type: str = YANDEX_VALUATION_DEFAULT_TYPE,
house_id: int | None = None,
fetch_on_miss: bool = True,
) -> YandexValuationResult | None:
"""Cached Yandex Valuation lookup. TTL 24h via external_valuations table.
Returns None on any error / cache miss + fetch failure — caller continues
without Yandex enrichment (graceful degradation).
house_id: канонический дом, уже разрезолвленный estimate-путём через
match_house_readonly. Пишется в external_valuations.house_id при fresh-fetch
(best-effort; None → колонка остаётся NULL). ON CONFLICT сохраняет уже
проставленный house_id через COALESCE.
"""
cache_key = _yandex_valuation_cache_key(address, offer_category, offer_type)
# Cache lookup
try:
cached = (
db.execute(
text(
"""
SELECT raw_payload, fetched_at
FROM external_valuations
WHERE source = 'yandex_valuation'
AND cache_key = :ck
AND expires_at > NOW()
ORDER BY fetched_at DESC
LIMIT 1
"""
),
{"ck": cache_key},
)
.mappings()
.first()
)
except Exception as e:
logger.warning("yandex_valuation: cache lookup failed: %s", e)
cached = None
if cached is not None and cached.get("raw_payload"):
try:
payload_dict = (
cached["raw_payload"]
if isinstance(cached["raw_payload"], dict)
else json.loads(cached["raw_payload"])
)
logger.info(
"yandex_valuation: cache HIT key=%s items=%d",
cache_key[:8],
len(payload_dict.get("history_items", [])),
)
return YandexValuationResult.model_validate(payload_dict)
except Exception as e:
logger.warning("yandex_valuation: cache deserialize failed — refetching: %s", e)
# fetch_on_miss=False — режим «только кэш» (фоновый режим внешних источников): вызывающий код
# не хочет ждать внешний HTTP в запросе и сам поставит догрузку в фон.
if not fetch_on_miss:
logger.info("yandex_valuation: cache MISS key=%s — fetch отложен", cache_key[:8])
return None
# Fresh fetch
try:
async with YandexValuationScraper(
RealScraperConfig(),
delay_provider=get_scraper_delay,
# #3398: без provider'а `curl_proxy_url` считает use_pool=False (флаг AND
# provider is not None) — пул выключен по построению, curl-сессия уходит на
# env-прокси SCRAPER_PROXY_URL (мёртвый узел #2613 → 407 на каждой пробе).
# Lease один на сессию: acquire в __aenter__, release в __aexit__ — в т.ч.
# на исключении и на отмене по таймауту _with_budget.
proxy_provider=RealProxyProvider(),
) as scraper:
result = await scraper.fetch_house_history(
address=address,
offer_category=offer_category,
offer_type=offer_type,
)
except Exception as e:
if caused_by_no_proxy(e):
# #3398: «пул прокси пуст» — НАША инфраструктура, HTTP-запрос не уходил вовсе.
# Отдельный текст (и WARNING, не ERROR): это не сбой Яндекса, GlitchTip-событие
# здесь было бы шумом. Возврат None — тот же graceful путь, /estimate не 5xx.
logger.warning("yandex_valuation: пул прокси пуст — продолжаем без Yandex: %s", e)
return None
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
# (только breadcrumb) — config-wiring регрессия здесь была бы не видна
# мониторингу. .exception() логирует на ERROR + traceback (#2337).
logger.exception("yandex_valuation: fetch failed — estimator продолжает без Yandex")
return None
if result is None:
# #3398: None здесь НЕразличимо «дом не найден» и «фетч не дошёл» — kit ловит
# ProxyError/HTTP-код внутри fetch_house_history и логирует своей строкой
# («yandex valuation fetch failed» / «returned NNN»). Прежний текст «empty result»
# читался как «у Яндекса нет данных», хотя при мёртвом прокси это был 407.
logger.info(
"yandex_valuation: пусто для address=%s — данных нет ЛИБО фетч не дошёл "
"(причина строкой выше от scraper_kit: прокси/HTTP)",
address[:60],
)
return None
# Save to cache (UPSERT on (source, cache_key))
try:
db.execute(
text(
"""
INSERT INTO external_valuations (
source, cache_key, address,
house_id,
raw_payload,
fetched_at, expires_at
) VALUES (
'yandex_valuation', :ck, :addr,
CAST(:hid AS bigint),
CAST(:payload AS jsonb),
NOW(), NOW() + (:ttl_hours || ' hours')::interval
)
ON CONFLICT (source, cache_key) DO UPDATE
SET raw_payload = EXCLUDED.raw_payload,
house_id = COALESCE(
EXCLUDED.house_id, external_valuations.house_id
),
fetched_at = NOW(),
expires_at = NOW() + (:ttl_hours || ' hours')::interval
"""
),
{
"ck": cache_key,
"addr": address,
"hid": house_id,
"payload": json.dumps(result.model_dump(mode="json"), ensure_ascii=False),
"ttl_hours": YANDEX_VALUATION_CACHE_TTL_HOURS,
},
)
db.commit()
logger.info(
"yandex_valuation: fresh fetch saved key=%s items=%d",
cache_key[:8],
len(result.history_items),
)
except Exception as e:
logger.warning("yandex_valuation: cache save failed (continuing): %s", e)
db.rollback()
return result
def _save_yandex_history_items(
db: Session,
result: YandexValuationResult,
) -> int:
"""Persist history items to house_placement_history. Returns saved count.
Resolves house_id ONCE per result via match_or_create_house() using the
valuation page's address + meta (year_built/total_floors). All items from
the same page share that house_id.
Confidence pipeline:
method_confidence <- match_or_create_house (1.0 cadastr/source, 0.9 fp, 0.7 geo, 1.0 new)
final_confidence = method_confidence
Idempotent via UNIQUE (source, ext_item_id); ext_item_id synthesized from
(address|publish_date|area|floor|prices) hash.
Batch semantics: single try/except; on any failure the batch rolls back.
#2674 (ревью): резолв дома идёт ДО раннего возврата по пустой истории. Раньше
возврат стоял первым, и страница, отрисованная идеально, но без единого объявления
в истории, дома не заводила — на проде это 1519 оценок против 1360 домов с
историей, ~10% страниц молча пропускались. Порядок сохранён и после сноса признака
«панорама» (#2674 хвост, мигр. 259): остаточный эффект переноса — сам
match_or_create_house, то есть дом и строка house_sources для таких страниц. Это
тот же вызов, с тем же адресом, что уже отрабатывает на остальных 90%.
"""
# Resolve house ONCE per page. Synthetic ext_id = sha256(address)[:16]
# — stable across re-runs, distinguishes pages for different addresses.
address_seed = (result.address or "").strip().lower()
house_ext_id = (
hashlib.sha256(address_seed.encode("utf-8")).hexdigest()[:16] if address_seed else "unknown"
)
try:
house_id, method_confidence, method = match_or_create_house(
db,
ext_source="yandex_valuation",
ext_id=house_ext_id,
address=result.address,
year_built=result.house.year_built,
)
except Exception as e:
logger.warning(
"yandex_valuation: house resolution failed for address=%r: %s"
" — saving with house_id=NULL",
result.address,
e,
)
db.rollback()
house_id = None
method_confidence = 0.0
method = "fail"
logger.info(
"yandex_valuation: house resolved house_id=%s method=%s confidence=%.2f addr=%r",
house_id,
method,
method_confidence,
result.address,
)
if not result.history_items:
return 0
rows = []
skipped_area = 0
for item in result.history_items:
# Фильтруем items с нулевой/отрицательной/отсутствующей площадью — битый парс
# («0,5 м²» и пр.) сохраняет мусор в house_placement_history и искажает
# price_trend. Estimator защищён NULLIF, но грязь копится → чистим на входе.
if item.area_m2 is None or item.area_m2 <= 0:
skipped_area += 1
continue
ext_seed = (
f"{result.address}|{item.publish_date}|{item.area_m2}|{item.floor}|"
f"{item.start_price}|{item.last_price}"
)
ext_item_id = hashlib.sha256(ext_seed.encode("utf-8")).hexdigest()[:32]
rows.append(
{
"ext_id": ext_item_id,
"house_id": house_id,
"rooms": item.rooms,
"area": item.area_m2,
"floor": item.floor,
"total_floors": result.house.total_floors,
"start_price": item.start_price,
"last_price": item.last_price,
"publish_date": item.publish_date,
"removed_date": item.removed_date,
"exposure": item.exposure_days,
"confidence": float(method_confidence),
"notes": f"match_method={method}" if method != "fail" else None,
"raw": json.dumps(item.model_dump(mode="json"), ensure_ascii=False),
}
)
if skipped_area > 0:
logger.info(
"yandex_valuation: skipped %d/%d history items with area_m2 <= 0 or None (addr=%r)",
skipped_area,
len(result.history_items),
result.address,
)
sql = text(
"""
INSERT INTO house_placement_history (
source, ext_item_id, house_id,
rooms, area_m2, floor, total_floors,
start_price, start_price_date,
last_price, last_price_date,
removed_date,
exposure_days,
source_confidence, notes,
raw_payload
) VALUES (
'yandex_valuation', :ext_id, :house_id,
:rooms, :area, :floor, :total_floors,
:start_price, :publish_date,
:last_price, :publish_date,
:removed_date,
:exposure,
:confidence, :notes,
CAST(:raw AS jsonb)
)
ON CONFLICT (source, ext_item_id) DO NOTHING
"""
)
try:
if rows:
db.execute(sql, rows)
db.commit()
return len(rows)
except Exception as e:
logger.warning(
"yandex_valuation: failed to save history batch (%d items): %s",
len(rows),
e,
)
db.rollback()
return 0
# #2674 (хвост): _save_yandex_house_panorama удалена вместе с одноимённой колонкой houses
# (мигр. 259). Гейт «пишем только с отрисованной страницы» работал как задумано — и не мог
# ничего спасти: измерять было нечего. На проде 0 true из 1536 страниц yandex_valuation за
# 24.0512.08.2026, а живая проверка боевым трактом 13.08.2026 (три адреса ЕКБ, HTTP 200,
# полный HTML) не нашла слова «панорам» ни в одном документе — ни в тексте, ни в атрибутах,
# ни в JSON-стейте. Признака на площадке нет, а поиск подстроки по тексту, где такого слова
# не бывает, гарантирует False механически: о доме этот ноль не говорил ничего.
# ── #651: IMV / Yandex blend (killer accuracy fix) ─────────────────────────────
def _fetch_house_imv_anchor(
db: Session,
*,
target_house_id: int | None,
rooms: int | None,
area: float | None,
) -> dict[str, Any] | None:
"""Достаёт РЕАЛЬНУЮ Avito IMV-оценку target-дома из `house_imv_evaluations`.
В отличие от `avito_imv_evaluations` (keyed estimate_id — пустая, on-demand
скрейп), `house_imv_evaluations` keyed по house_id. Резолвим строку:
WHERE house_id = target_house_id + band-guard по rooms/area. Best-effort:
None при любой ошибке / отсутствии house_id / пустой таблице — estimator
продолжает на гео-tier'ах (no regress).
СОСТОЯНИЕ ТАБЛИЦЫ, замер 2026-08-12 (прежний текст обещал «популирована
~2951 домов, fresh» — неправда по обоим пунктам, #2674):
2680 строк, одна на дом (house_imv_eval_house_uniq_idx UNIQUE(house_id) —
поэтому ORDER BY ниже выбирает из ≤1 кандидата, а не ранжирует). 2633 из
них (98.2%) старше 40 суток, 2366 — один прогон 2026-05-24; свежих, после
фикса параметров 2026-08-06, всего 47.
Дореформенные 2633 спрошены у Авито с ЛИТЕРАЛОМ renovation_type='cosmetic'
и с дефолтом house_type='panel' при нераспознанном типе: мода ремонта по
объявлениям того же дома совпала лишь у 972 (36.9%), разошлась у 1217
(46.2%), у 444 моды нет; тип дома разошёлся у 74 из 2633 (46 раз ушла
«панель» вместо «монолита»).
ЧТО С ЭТИМ РЕШЕНО (замер на проде, 1081 персистированная оценка):
· Гейт «не брать дореформенные строки» — ОТКЛОНЁН: band-совместимая строка
находится у 224 оценок, и все 224 дореформенные. Гейт снял бы якорь у
224 из 224, то есть просто выключил бы механизм.
· Массовая переоценка — не чинит корень и упирается в очередь: 1628 из
2633 домов уже в imv_status='ok', а батч берёт только pending/
transient_error, где сейчас 7144 дома при темпе ~25/прогон.
· Корень — не свежесть, а разные базисы ремонта; починено в #2677
(`_anchor_repair_factor`), см. ниже.
Returns dict {recommended_price, lower_price, higher_price, market_count,
rooms, area_m2, renovation_type} или None.
#2677: renovation_type — ремонт, С КОТОРЫМ строка была запрошена у Avito.
Без него якорь нельзя привести к базису ремонта оцениваемой квартиры
(см. `_anchor_repair_factor`), и правка молча выродилась бы в no-op.
"""
if target_house_id is None:
return None
try:
row = (
db.execute(
text(
"""
SELECT recommended_price, lower_price, higher_price,
market_count, rooms, area_m2, renovation_type
FROM house_imv_evaluations
WHERE house_id = CAST(:hid AS bigint)
AND recommended_price > 0
-- Band-guard: строка пригодна как anchor только при правдоподобном
-- совпадении rooms/area. Иначе studio-only IMV запись «прилипала»
-- к 3-комн. target'у (ORDER BY всё равно вернёт LIMIT 1) и при
-- anchor > median×1.15 раздувала blend. NULL target/row → не
-- режем (graceful, нет данных для сравнения).
AND (
CAST(:rooms AS integer) IS NULL OR rooms IS NULL
OR abs(rooms - CAST(:rooms AS integer)) <= 1
)
AND (
CAST(:area AS double precision) IS NULL
OR area_m2 IS NULL OR area_m2 <= 0
OR area_m2 BETWEEN CAST(:area AS double precision) * 0.7
AND CAST(:area AS double precision) * 1.3
)
ORDER BY
-- ближе по комнатам и площади → меньше score; NULL target → 0
(CASE WHEN CAST(:rooms AS integer) IS NOT NULL AND rooms IS NOT NULL
THEN abs(rooms - CAST(:rooms AS integer)) * 10 ELSE 0 END)
+ (CASE WHEN CAST(:area AS double precision) IS NOT NULL
AND area_m2 IS NOT NULL AND area_m2 > 0
THEN abs(area_m2 - CAST(:area AS double precision))
/ area_m2 * 100 ELSE 0 END) ASC,
fetched_at DESC
LIMIT 1
"""
),
{"hid": target_house_id, "rooms": rooms, "area": area},
)
.mappings()
.first()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("house_imv anchor lookup failed (graceful): %s", exc)
return None
return dict(row) if row is not None else None
def _apply_imv_blend(
*,
median_price: int,
range_high: int,
median_ppm2: float,
area: float,
anchor_total: int | None,
anchor_higher: int | None,
weight: float,
threshold: float,
market_count: int | None = None,
thin_market_threshold: int = 0,
) -> tuple[int, int, float, bool, int | None]:
"""Чистая (testable без БД) blend-трансформация для #651.
Если есть надёжный якорь A (`anchor_total`, ПОЛНАЯ цена за квартиру) и он
выше median_price × threshold (сигнал занижения) → поднимаем медиану до
blend = median*(1-w) + A*w и расширяем верх диапазона до max(range_high,
anchor_higher или A). ОДНОНАПРАВЛЕННО: только повышаем (баг — занижение).
Если A ниже медианы — медиану НЕ трогаем, но диапазон можем расширить, чтобы
включить A (информативность). Null-guard: при anchor_total=None — no-op.
#3323: тонкий рынок (`market_count` < `thin_market_threshold`) → якорь
статистически ненадёжен и ОТБРАСЫВАЕТСЯ из денежного пути целиком: ни blend
медианы, ни расширение range_high. Гейт стоит здесь — в единственной точке,
через которую IMV влияет на деньги, а не в ветках построения якоря.
`thin_market_threshold=0` (default) = гейт выключен: market_count неизвестен →
поведение прежнее.
Returns (new_median_price, new_range_high, new_median_ppm2, blended,
anchor_used_total).
"""
if anchor_total is None or anchor_total <= 0 or median_price <= 0 or area <= 0:
return median_price, range_high, median_ppm2, False, None
if market_count is not None and market_count < thin_market_threshold:
return median_price, range_high, median_ppm2, False, None
blended = False
new_median = median_price
new_ppm2 = median_ppm2
if anchor_total > median_price * threshold:
w = max(0.0, min(1.0, weight))
new_median = round(median_price * (1.0 - w) + anchor_total * w)
new_ppm2 = new_median / area
blended = True
# Расширяем верх диапазона: предпочитаем верхнюю границу IMV-коридора, иначе сам
# якорь. Только вверх (никогда не сужаем).
range_top_candidate = anchor_higher if (anchor_higher and anchor_higher > 0) else anchor_total
new_range_high = max(range_high, range_top_candidate, new_median)
return new_median, new_range_high, new_ppm2, blended, anchor_total
# ── #764: per-cadastral-quarter price index correction ───────────────────────
def _quarter_from_cadastre(cad_num: str | None) -> str | None:
"""Извлечь кадастровый номер квартала из кадастрового номера дома/квартиры.
Формат: AA:BB:CCCCCC или AA:BB:CCCCCCC (6 или 7 цифр в третьей части).
Возвращаем первые три двоеточие-разделённых компонента.
Пример: "66:41:0204016:350""66:41:0204016".
При отсутствии или некорректном формате → None.
"""
if not cad_num:
return None
parts = cad_num.split(":")
if len(parts) < 3:
return None
quarter = ":".join(parts[:3])
# Проверяем что третья часть — числовая (квартал, не мусор)
if not parts[2].isdigit():
return None
return quarter
def _lookup_quarter_index(
db: Session,
*,
quarter_cad_number: str,
min_n_deals: int,
) -> tuple[float, int] | None:
"""Поиск price_index для кадастрового квартала в FDW-таблице quarter_price_index.
Возвращает (price_index, n_deals) или None при отсутствии строки / n_deals < min_n_deals
/ любой FDW-ошибке (graceful — backward-compatible).
Использует CAST(:q AS varchar) — psycopg v3 convention.
"""
try:
row = (
db.execute(
text(
"""
SELECT price_index, n_deals
FROM quarter_price_index
WHERE quarter_cad_number = CAST(:q AS varchar)
AND n_deals >= CAST(:min_n AS bigint)
LIMIT 1
"""
),
{"q": quarter_cad_number, "min_n": min_n_deals},
)
.mappings()
.first()
)
except Exception as exc:
logger.warning("quarter_price_index FDW lookup failed (graceful, no-op): %s", exc)
return None
if row is None:
return None
return float(row["price_index"]), int(row["n_deals"])
def _lookup_quarter_indexes(
db: Session,
*,
quarter_cad_numbers: list[str],
min_n_deals: int,
) -> dict[str, float]:
"""Батч-поиск price_index для списка кадастровых кварталов (одним SQL-запросом).
Возвращает {quarter_cad_number: price_index} только для кварталов, у которых
n_deals >= min_n_deals. Кварталы без записи или с n_deals < порога — не попадают
в словарь. При любой FDW-ошибке → {} (graceful, avg_analog_index остаётся 1.0).
"""
if not quarter_cad_numbers:
return {}
distinct = list(dict.fromkeys(quarter_cad_numbers)) # сохраняем порядок, убираем дубли
try:
rows = (
db.execute(
text(
"""
SELECT quarter_cad_number, price_index
FROM quarter_price_index
WHERE quarter_cad_number = ANY(CAST(:quarters AS varchar[]))
AND n_deals >= CAST(:min_n AS bigint)
"""
),
{"quarters": distinct, "min_n": min_n_deals},
)
.mappings()
.all()
)
except Exception as exc:
logger.warning("quarter_price_index batch FDW lookup failed (graceful, no-op): %s", exc)
return {}
return {str(row["quarter_cad_number"]): float(row["price_index"]) for row in rows}
# Порог KNN для квартала ЦЕЛИ. 50 м (как у geo-nearest матчинга аналогов,
# tasks/cadastral_geo_match.DEFAULT_THRESHOLD_M) замером НЕ подтвердились —
# точность проверена на 2398 домах ЕКБ, где квартал известен независимо (кадастр
# от DaData по адресу, а не KNN-подсказка), 2026-08-12:
# порог ответ выдан чужой квартал точность покрытие
# 15 м 475 26 94.5% 19.8%
# 25 м 748 59 92.1% 31.2%
# 50 м 1341 271 79.8% 55.9%
# 100 м 2090 644 69.2% 87.2%
# На 50 м каждый пятый ответ — чужой квартал, а промах стоит 1.111× по медиане
# и 1.447× в p90 (отношение индексов чужого и своего квартала). Берём 25 м:
# точность 92%, и это ровно тот компромисс, который видно в замере, а не
# заимствованная у соседнего модуля константа.
_TARGET_QUARTER_RADIUS_M = 25.0
# Градусные ворота под GIST-индекс (планарные градусы, надмножество круга 50 м на
# широте ЕКБ); точную дистанцию решает ST_DistanceSphere ниже.
_TARGET_QUARTER_DEG_GATE = 0.0015
def _lookup_target_quarter_by_coords(
db: Session,
lat: float | None,
lon: float | None,
) -> str | None:
"""Кадастровый квартал ЦЕЛИ по её собственным координатам (ближайшее здание ≤50 м).
Зачем: `dadata.house_cadnum` пуст в 15 из 15 применений квартального индекса на
проде (замер 2026-08-12) — то есть свой кадастр цели известен почти никогда.
Тот же geo-nearest приём уже наполняет `listings.building_cadastral_number`
(tasks/cadastral_geo_match) — здесь он применён к координатам САМОЙ цели, а не
к координатам аналога.
Это ПОДСКАЗКА, не идентичность здания (#2674): для квартального индекса нужен
только 3-й компонент кадастрового номера (квартал), а он у соседних зданий
одного двора совпадает. Возвращаем сразу квартал (`AA:BB:CCCCCCC`), а не
полный кадастр, чтобы вызывающий код не принял его за кадастр дома.
None, если ЕКБ-зеркало `cad_buildings_local` пусто/недоступно, координат нет
или ближайшее здание дальше _TARGET_QUARTER_RADIUS_M.
"""
if lat is None or lon is None:
return None
try:
row = db.execute(
text(
"""
WITH p AS (
SELECT ST_SetSRID(
ST_MakePoint(CAST(:lon AS double precision),
CAST(:lat AS double precision)), 4326) AS g
)
SELECT cb.cad_num
FROM cad_buildings_local cb, p
WHERE ST_DWithin(cb.geom, p.g, CAST(:deg AS double precision))
AND ST_DistanceSphere(cb.geom, p.g) <= CAST(:radius AS double precision)
ORDER BY cb.geom <-> p.g
LIMIT 1
"""
),
{
"lat": lat,
"lon": lon,
"deg": _TARGET_QUARTER_DEG_GATE,
"radius": _TARGET_QUARTER_RADIUS_M,
},
).first()
except Exception as exc:
logger.warning("target quarter by coords lookup failed (graceful, no-op): %s", exc)
return None
if row is None:
return None
return _quarter_from_cadastre(str(row[0]))
def _apply_quarter_index(
*,
base_median_ppm2: float,
base_median_price: int,
base_range_low: int,
base_range_high: int,
target_index: float,
avg_analog_index: float,
min_factor: float = 0.6,
max_factor: float = 1.8,
) -> tuple[float, int, int, int, float]:
"""Чистая (testable без БД) gap-correction квартального индекса (#764).
Корректирует ТОЛЬКО разрыв между квартальным уровнем целевого объекта и
усреднённым квартальным уровнем аналогов:
factor = target_index / avg_analog_index
adjusted_ppm2 = base_median_ppm2 × factor
Все ценовые выходы масштабируются одним и тем же factor → median/range
остаются геометрически консистентными.
min_factor / max_factor — sanity-clamp (#859): belt-and-suspenders против
патологичных FDW-данных. Калибруются через settings и передаются из
вызывающего кода, чтобы хелпер оставался чистым (без импорта settings).
Когда clamp меняет raw factor — логируется (см. caller).
Returns (adjusted_ppm2, adjusted_median_price, adjusted_range_low,
adjusted_range_high, factor).
"""
raw_factor = target_index / avg_analog_index
factor = max(min_factor, min(max_factor, raw_factor))
if raw_factor != factor:
logger.info(
"quarter_index: factor clamped raw=%.4f%.4f (bounds [%.2f, %.2f])"
" target_index=%.3f avg_analog_index=%.3f",
raw_factor,
factor,
min_factor,
max_factor,
target_index,
avg_analog_index,
)
adjusted_ppm2 = base_median_ppm2 * factor
adjusted_median_price = round(base_median_price * factor)
adjusted_range_low = round(base_range_low * factor)
adjusted_range_high = round(base_range_high * factor)
return adjusted_ppm2, adjusted_median_price, adjusted_range_low, adjusted_range_high, factor
def _segment_for_ppm2(ppm2: float) -> str:
"""Ценовой сегмент (label) для значения ₽/м² по границам PRICE_SEGMENTS_PPM2.
Значение попадает в первый бэнд, чей upper_bound (exclusive) оно НЕ превышает.
Последний бэнд (+inf) ловит хвост. Чистая функция — общий бэндинг для
эстиматора и бэктеста.
"""
for label, upper in PRICE_SEGMENTS_PPM2:
if ppm2 < upper:
return label
return PRICE_SEGMENTS_PPM2[-1][0] # +inf-хвост — недостижимо, defensive
def _apply_segment_multiplier(
*,
median_price: int,
median_ppm2: float,
range_low: int,
range_high: int,
enabled: bool,
multipliers: dict[str, float],
) -> tuple[float, int, int, int, str | None, float]:
"""#2255: сегментная поправка эстиматора по ценовому бэнду (₽/м²).
Чистая (testable без БД) функция по образцу _apply_quarter_index. Бэнд
определяется по median_ppm2 границами PRICE_SEGMENTS_PPM2; множитель для
этого бэнда берётся из multipliers. Умножается median_price, median_ppm2 и
ПРОПОРЦИОНАЛЬНО range_low/range_high (тот же factor → asking↔ppm²↔range
остаются геометрически консистентны, как в quarter-index/corridor-clamp).
No-op (factor=1.0, band=None) когда:
- enabled=False (флаг OFF → путь байт-в-байт идентичен),
- multipliers пуст/битый (нет ключа бэнда, значение не приводится к float,
либо ≤ 0) → warning + no-op (не роняем оценку из-за кривого конфига),
- median_ppm2 <= 0 (нет headline),
- множитель бэнда == 1.0 (премиум и любой явно-нейтральный сегмент).
Returns (median_ppm2, median_price, range_low, range_high, band, factor).
band=None при no-op; иначе label сегмента, к которому применён множитель.
"""
if not enabled or median_ppm2 <= 0:
return median_ppm2, median_price, range_low, range_high, None, 1.0
if not multipliers:
logger.warning("segment_mult: пустой multipliers-dict (флаг ON) — no-op")
return median_ppm2, median_price, range_low, range_high, None, 1.0
band = _segment_for_ppm2(median_ppm2)
raw = multipliers.get(band)
if raw is None:
# Бэнд без записи (напр. премиум намеренно отсутствует) — no-op без шума.
return median_ppm2, median_price, range_low, range_high, None, 1.0
try:
factor = float(raw)
except (TypeError, ValueError):
logger.warning("segment_mult: битый множитель band=%s raw=%r — no-op", band, raw)
return median_ppm2, median_price, range_low, range_high, None, 1.0
if factor <= 0:
logger.warning("segment_mult: неположительный множитель band=%s ×%r — no-op", band, factor)
return median_ppm2, median_price, range_low, range_high, None, 1.0
if factor == 1.0:
# Нейтральный сегмент (эконом=1.00 и т.п.) — no-op без мутации/лога.
return median_ppm2, median_price, range_low, range_high, None, 1.0
new_price = round(median_price * factor)
logger.info(
"segment_mult: band=%s ×%.3f median %d%d",
band,
factor,
median_price,
new_price,
)
return (
median_ppm2 * factor,
new_price,
round(range_low * factor),
round(range_high * factor),
band,
factor,
)
def _load_sber_index_series(db: Session, *, region: str) -> dict[date, float]:
"""#794: monthly {period_month: index_value} for region from sber_price_index.
Tries SBER_COEFF_DASHBOARDS in order; returns first non-empty series. {} on any error.
#2846: per-estimate guard свежести отсюда УБРАН. Он сравнивал возраст latest
периода с settings.sber_index_max_age_days=35, а такой возраст недостижим по
построению: period_month — метка ПЕРВОГО числа месяца (≥30 суток уже на
закрытии месяца) плюс лаг публикации источника; на проде за 31 сутки прямых
наблюдений возраст не опускался ниже 46. Guard был истинным 100% времени —
нулевой сигнал в per-estimate логе, который вдобавок не долетал до GlitchTip
(event_level=ERROR). Свежесть теперь мерит ОДНО место — tasks/sber_freshness_monitor,
и мерит отставание ЗАГРУЗКИ (последний полный прогон vs её собственный такт),
а не календарь.
"""
for dash in SBER_COEFF_DASHBOARDS:
try:
rows = (
db.execute(
text("""
SELECT period_month, index_value_rub_m2
FROM sber_price_index
WHERE city = CAST(:region AS text)
AND dashboard = CAST(:dash AS text)
-- #R2-H1: только вторичный рынок (эстиматор — вторичка);
-- первичка (новостройки) = направленно неверная коррекция.
AND (segment IS NULL OR segment ILIKE '%вторичн%')
ORDER BY period_month
"""),
{"region": region, "dash": dash},
)
.mappings()
.all()
)
except Exception as exc:
logger.warning("sber_price_index lookup failed for %s (graceful): %s", dash, exc)
continue
if rows:
series = {r["period_month"]: float(r["index_value_rub_m2"]) for r in rows}
if not series:
continue
return series
return {}
def _sber_time_factor(series: dict[date, float], deal_month: date) -> float:
"""#794: factor = idx[latest] / idx[deal_month], clamped. 1.0 when no data / recent deal.
series: {first-of-month date -> index value}. deal_month: first-of-month of the deal.
If deal_month absent, use the nearest available month <= deal_month; if none, nearest overall.
If deal_month >= latest available month -> 1.0 (no extrapolation of recent deals).
"""
if not series:
return 1.0
latest = max(series)
if deal_month >= latest:
return 1.0
base = series.get(deal_month)
if base is None:
earlier = [m for m in series if m <= deal_month]
if earlier:
base = series[max(earlier)]
else:
base = series[min(series)] # deal older than series start → use earliest
if not base or base <= 0:
return 1.0
factor = series[latest] / base
return max(SBER_TIME_FACTOR_MIN, min(SBER_TIME_FACTOR_MAX, factor))
# Word/phrase-boundary regex поверх geocoder.SVERDLOVSK_OBLAST_CITIES (~30 городов
# региона 66) — resolve target-города для city-scoping ДКП-коридора (см.
# _resolve_target_city). Длинные имена раньше коротких (сортировка по len desc),
# чтобы «нижний тагил» матчился целиком, а не как «тагил» после «нижний» отдельно
# (тут это не критично т.к. паттерн — alternation по ЦЕЛЫМ именам городов, но
# сохраняем ту же дисциплину сортировки, что и geocoder._OBLAST_CITY_RE).
_TARGET_CITY_RE = re.compile(
r"\b(?:"
+ "|".join(re.escape(c) for c in sorted(SVERDLOVSK_OBLAST_CITIES, key=len, reverse=True))
+ r")\b"
)
def _resolve_target_city(address_text: str | None) -> str | None:
"""Извлекает город региона 66 из геокодированного адреса (best-effort).
Ищет ЛЮБОЕ известное имя города (geocoder.SVERDLOVSK_OBLAST_CITIES) по
границе слова/фразы — работает и для forward-формата («Екатеринбург, ул.
Ленина, 5»), и для reverse/Yandex raw-текста («Россия, Свердловская
область, Нижний Тагил, улица Ленина, 50»), позиция города в строке не
важна. ё→е нормализация ДО поиска (симметрично _normalize_building_key).
Returns None если ни один город не распознан (адрес вне известного
списка) — caller (_fetch_dkp_corridor) откатывается на behaviour без
city-фильтра. NB: коридор НЕ чисто advisory — при
n>=estimate_corridor_clamp_min_n участвует в clamp headline + radius-floor +
Tier-C anchor gate, поэтому city-scope влияет на итоговую цену.
"""
if not address_text:
return None
norm = address_text.replace("ё", "е").replace("Ё", "Е").lower()
# #C2: пропускаем город-имя, которому предшествует district-маркер — «мкр
# Заречный» это микрорайон ВНУТРИ другого города, а не город-ЗАТО Заречный
# (единственная ЕКБ-коллизия имени города и микрорайона). Первый non-district
# match по позиции = целевой город.
district_markers = {"мкр", "мкрн", "микрорайон", "р", "рн", "район"}
for m in _TARGET_CITY_RE.finditer(norm):
prefix_words = norm[: m.start()].split()
if prefix_words and prefix_words[-1].rstrip(".") in district_markers:
continue
return m.group(0)
return None
def _fetch_dkp_corridor(
db: Session,
*,
address: str | None,
rooms: int | None,
area: float | None,
city: str | None = None,
period_months: int = DEALS_PERIOD_MONTHS,
area_tolerance: float = AREA_TOLERANCE,
) -> dict[str, Any] | None:
"""#652: коридор ₽/м² по реальным ДКП-сделкам Росреестра для target.
Reuse паттерна street-deals (api/v1/trade_in.py): извлекаем улицу из адреса,
фильтруем `deals` (source='rosreestr', та же rooms, площадь ±tolerance, окно
period_months) и нормализуем per-m². Возвращаем low/median/high ₽/м².
ADVISORY — caller не клампит, только сурфейсит + опциональная пометка.
Best-effort: None при отсутствии улицы / сделок / любой ошибке.
NB: при n>=clamp_min_n коридор клампит headline + radius-floor (НЕ чисто
advisory) — city-scope важен для корректной итоговой цены.
city: (oblast C2) — резолвленный целевой город (см. _resolve_target_city,
вызывающая сторона передаёт lower-case имя). Улица БЕЗ city-фильтра ловит
одноимённые улицы ЛЮБОГО города региона (напр. «Ленина» есть в Нижнем
Тагиле/Лесном/Краснотурьинске/Серове/ЕКБ/Первоуральске) — коридор превращался
в кросс-областной мусор для не-ЕКБ таргетов. None (город не определился) →
фильтр не применяется, прежнее (pre-oblast) поведение сохраняется как есть.
"""
if not address or rooms is None or not area:
return None
street_name = extract_street_name(address)
if not street_name:
return None
area_min = area * (1.0 - area_tolerance)
area_max = area * (1.0 + area_tolerance)
# #C1/#C2 city-scope (консолидировано, п.3 dedup #2488↔#2489): сделка обязана
# быть в ГОРОДЕ целевого адреса. city приходит из _resolve_target_city (lower-case,
# словарь ~30 крупных городов обл.66 вкл. ЕКБ + все sweep-города). deals.city в БД
# с исходным регистром → сравниваем через LOWER(d.city). city=None (адрес вне
# словаря, редкий мелкий н.п.) → фильтр не применяется (прежнее #2488-поведение).
# Прежний #2489 substring-фильтр УБРАН — был двойным с этим (benign, но ё/е edge).
city_filter_sql = "AND LOWER(d.city) = CAST(:target_city AS text)" if city else ""
try:
rows = (
db.execute(
text(
f"""
SELECT d.price_per_m2, d.deal_date
FROM deals d
LEFT JOIN deal_city_price_bands b ON b.city = d.city
WHERE d.source = 'rosreestr'
AND d.address ILIKE :street_pattern
AND d.address ~* :street_regex
AND d.rooms = CAST(:rooms AS integer)
AND d.area_m2 BETWEEN :area_min AND :area_max
AND d.deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval
AND d.price_per_m2 > 0
{city_filter_sql}
-- #699 + #2478: режем нерыночные ppm²-выбросы из коридора
-- expected_sold. Per-city band (deal_city_price_bands, миграция
-- 178) когда для города сделки есть строка (не-ЕКБ область);
-- иначе (в т.ч. Екатеринбург, НАМЕРЕННО не в таблице) fallback
-- на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 — byte-identical.
AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int))
AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int))
"""
),
{
"street_pattern": "%" + street_name + "%",
"street_regex": r"\m" + street_name + r"\M",
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
"target_city": city.lower() if city else None,
},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("dkp_corridor lookup failed (graceful): %s", exc)
return None
# #794: apply СберИндекс time-adjustment to re-base stale Rosreestr ДКП ppm²
# to the latest available index month. Graceful: factor=1.0 when table is empty.
series = _load_sber_index_series(db, region=SBER_TIME_ADJUST_REGION)
adjusted: list[float] = []
factors_applied: list[float] = []
# #2846: возраст выборки. Копится по ТЕМ ЖЕ строкам, что попали в adjusted —
# `continue` выше выкидывает и дату тоже, иначе сделка без ppm² подняла бы
# заявленную свежесть коридора, не участвуя в его границах.
latest_deal: date | None = None
for r in rows:
ppm2 = r["price_per_m2"]
if not ppm2:
continue
dd = r.get("deal_date")
factor = 1.0
if series and dd is not None:
deal_month = date(dd.year, dd.month, 1)
factor = _sber_time_factor(series, deal_month)
adjusted.append(float(ppm2) * factor)
factors_applied.append(factor)
if dd is not None and (latest_deal is None or dd > latest_deal):
latest_deal = dd
ppm2_values = sorted(adjusted)
# #oblast-D widen: a single street in a small non-EKB town can easily have
# <3 (or 0) ДКП deals in the last 12 months even though the CITY overall
# has plenty (migration 177 loaded 47k+ oblast-wide deals across ~368
# cities) — the street-only corridor was silently unusable for exactly the
# towns that most need it (Нижний Тагил/Серов/Каменск-Уральский/...).
# Widen to a CITY-scoped (no street filter) sample when the street sample
# is too thin. EKB is explicitly EXCLUDED (city == "екатеринбург" is
# always dense at street level) — this widen path NEVER engages for EKB,
# so live EKB behaviour is unaffected. The frozen regression gate never
# calls this function at all (it replays a captured `dkp_raw` dict), so
# this change carries zero risk to the gate either way.
if len(ppm2_values) < DKP_CORRIDOR_CITY_WIDE_MIN_N and city and city != "екатеринбург":
try:
city_rows = (
db.execute(
text(
"""
SELECT d.price_per_m2, d.deal_date
FROM deals d
LEFT JOIN deal_city_price_bands b ON b.city = d.city
WHERE d.source = 'rosreestr'
AND d.city IS NOT NULL
AND LOWER(d.city) = CAST(:target_city AS text)
AND d.rooms = CAST(:rooms AS integer)
AND d.area_m2 BETWEEN :area_min AND :area_max
AND d.deal_date > NOW()
- (CAST(:period_months AS integer) || ' months')::interval
AND d.price_per_m2 > 0
AND d.price_per_m2 BETWEEN COALESCE(b.ppm2_min, CAST(:ppm_min AS int))
AND COALESCE(b.ppm2_max, CAST(:ppm_max AS int))
"""
),
{
"target_city": city.lower(),
"rooms": rooms,
"area_min": area_min,
"area_max": area_max,
"period_months": period_months,
"ppm_min": DEAL_MIN_PPM2,
"ppm_max": DEAL_MAX_PPM2,
},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("dkp_corridor city-wide widen failed (graceful): %s", exc)
city_rows = []
city_adjusted: list[float] = []
city_latest: date | None = None
for r in city_rows:
ppm2 = r["price_per_m2"]
if not ppm2:
continue
dd = r.get("deal_date")
factor = 1.0
if series and dd is not None:
deal_month = date(dd.year, dd.month, 1)
factor = _sber_time_factor(series, deal_month)
city_adjusted.append(float(ppm2) * factor)
if dd is not None and (city_latest is None or dd > city_latest):
city_latest = dd
if len(city_adjusted) > len(ppm2_values):
logger.info(
"dkp_corridor widen #oblast-D: street n=%d < %d → city-wide n=%d (city=%s)",
len(ppm2_values),
DKP_CORRIDOR_CITY_WIDE_MIN_N,
len(city_adjusted),
city,
)
ppm2_values = sorted(city_adjusted)
# #2846: коридор теперь описывает city-выборку — и возраст обязан
# переехать вместе с числами, иначе подпись осталась бы от street-
# выборки, которую на витрине уже никто не видит.
latest_deal = city_latest
if not ppm2_values:
return None
if series and factors_applied:
logger.info(
"dkp_corridor #794 time-adjust: n=%d factor min=%.3f max=%.3f region=%s",
len(factors_applied),
min(factors_applied),
max(factors_applied),
SBER_TIME_ADJUST_REGION,
)
# #1520: используем P10/P90 вместо абсолютных min/max, чтобы коридор был
# устойчив к выбросам (один нерыночный ДКП не сдвигает границу).
# При маленьких выборках (n < 10) _percentile с линейной интерполяцией
# плавно сжимается к первому/последнему элементу — специального fallback
# не нужно (n=3: P10 ≈ индекс 0.2, P90 ≈ индекс 2.8 → оба внутри диапазона).
return {
"count": len(ppm2_values),
"low_ppm2": int(_percentile(ppm2_values, 0.10)),
"median_ppm2": int(_percentile(ppm2_values, 0.5)),
"high_ppm2": int(_percentile(ppm2_values, 0.90)),
"period_months": period_months,
"latest_deal_date": latest_deal,
}
# ── #651/#652 v2: same-building anchor (validated, 55 golden cases) ─────────────
#
# Радиусная медиана ₽/м² системно занижает премиум/видовые квартиры — она мешает
# топовый дом с массовой застройкой рядом. v2 строит PRIMARY якорь из комплов ТОГО
# ЖЕ ДОМА (Tier A), similarity-weighted по площади+комнатам, с premium-uplift и
# hard guardrail. Industry-grounded (Fannie Mae «same-project comps preferred» +
# inverse-adjustment weighting + FSD-as-confidence). Полностью за флагом.
# Street-alias map: ЕКБ-специфичные расхождения между golden/source-адресами и БД.
# Golden «Ткачёва 13» = БД «Ткачей 13» — без алиаса 0 комплов для 5 business-кейсов
# (Clever Park). Ключи/значения уже ё→е-нормализованы и lowercase. Расширяемо.
# ВАЖНО: применяется к ЦЕЛЬНОМУ street_core (после strip type-words), не к токену.
_STREET_ALIAS_MAP: dict[str, str] = {
"ткачева": "ткачей", # «ул. Ткачёва» (родит. падеж) ↔ БД «ул. Ткачей»
}
# Street-type токены: канонизируем (drop type-слово, оставляем имя). Реальные prod-
# адреса ставят тип ДО или ПОСЛЕ имени («улица Ткачей» И «Олимпийская наб.») — поэтому
# дропаем тип ОТКУДА УГОДНО в строке, а не только ведущий keyword. Лемматизированы по
# точкам/окончаниям: матчим точное слово ИЛИ «<core>.»-сокращение.
_STREET_TYPE_TOKENS: frozenset[str] = frozenset(
{
"улица",
"ул",
"у",
"переулок",
"пер",
"проспект",
"пр",
"прт",
"пркт",
"пр-кт",
"пр-т",
"проезд",
"бульвар",
"бр",
"б-р",
"набережная",
"наб",
"шоссе",
"ш",
"площадь",
"пл",
"тракт",
"аллея",
"тупик",
}
)
# Административные токены-маркеры: всё, что относится к городу/району/мкр — НЕ часть
# имени дома. Дропаем токен-маркер ВМЕСТЕ со следующим за ним словом-значением
# («р-н Октябрьский», «мкр Парковый», «г Екатеринбург»). Города региона тоже
# чистим (стоят как ведущий токен «Екатеринбург,»/«Первоуральск,»/«Нижний Тагил,»).
_ADMIN_MARKER_TOKENS: frozenset[str] = frozenset(
{"рн", "р", "район", "мкр", "микрорайон", "г", "гор", "город", "обл", "область"}
)
# (oblast C2): было hardcoded 6-town ЕКБ-агломерация
# ({"екатеринбург","первоуральск","березовский","верхняя","пышма","среднеуральск"}).
# Генерализовано на весь регион 66 — derived из geocoder.SVERDLOVSK_OBLAST_CITIES
# (~30 городов области), НЕ повторно захардкожено здесь. Многословные имена
# («нижний тагил», «верхняя пышма», «верхняя салда») бьём на отдельные слова —
# _normalize_building_key сравнивает ОДИН токен адреса за раз (адрес уже разбит
# на токены по пробелу/запятой _TOKEN_SPLIT_RE), а не всю фразу целиком. ё→е
# нормализуем при построении сета — симметрично _normalize_building_key
# (норм. `norm.replace("ё","е")` идёт ДО токенизации), избегаем недостижимых
# ё-токенов.
_OBLAST_CITY_TOKENS: frozenset[str] = frozenset(
word.replace("ё", "е") for city in SVERDLOVSK_OBLAST_CITIES for word in city.split()
)
_CITY_TOKENS: frozenset[str] = _OBLAST_CITY_TOKENS | frozenset({"россия"})
# Литера корпуса, прилипшая к номеру: «16а», «204г», «57а» → base + letter (рус/лат).
_HOUSE_LETTER_RE = re.compile(r"^(?P<num>\d+)\s*(?P<letter>[а-яa-z])?$", flags=re.UNICODE)
# Разбивка на токены: слова/числа, отбрасывая пунктуацию (',', '.', '·', '/', '-').
_TOKEN_SPLIT_RE = re.compile(r"[\s,;·]+", flags=re.UNICODE)
def _normalize_building_key(
address: str | None,
) -> tuple[str | None, int | None, str | None]:
"""Нормализует адрес в robust-ключ «того же дома»: (street_core, base_no, letter).
Инвариантен к prod-форматам ЕКБ-вторички:
- ё→е, lowercase.
- отрезает город / «р-н …» / «мкр …» / «г. …» / «· …»-хвост (district-suffix).
- дропает street-type слова ОТКУДА УГОДНО (улица/ул./наб./набережная/пр./… —
тип может стоять ДО или ПОСЛЕ имени: «улица Ткачей» И «Олимпийская наб.»);
остаток alpha-токенов = street_core («бориса ельцина», «сакко и ванцетти»).
- base_no = первый числовой токен ПОСЛЕ имени улицы, толерантно к
«Ткачей, 13» = «Ткачей,13» = «Ткачей,д. 13» = «Ткачей дом 13».
- letter = прилипшая литера корпуса («16а»→'а', «204г»→'г'); «/N» и «кN»
(корпус) схлопываются к base (тот же дом). Литеры — РАЗНЫЕ дома (204г ≠ 204д).
- street_core прогоняется через _STREET_ALIAS_MAP (ткачева→ткачей).
#2581: город/рНАМЕРЕННО дропается (не возвращается в ключе) — это
единственное, что делает «Ткачёва 13»-вариант с городом и без города давать
один ключ (см. test_normalize_tkachei13_all_db_variants_same_key). Городской
токен как 4-й элемент ключа НЕ добавлен: (а) часть источников (Avito
anonymous-адреса) вообще не несёт городской токен в тексте — ключ с
обязательным городом сломал бы их матчинг; (б) написание города варьируется
(ЕКБ/Екатеринбург/г. Екатеринбург) — ещё один normalization-слой, дающий
те же false-negative риски, которые уже решает `_CITY_TOKENS`-дропинг.
Кросс-городская коллизия (одноимённая улица+дом в разных городах области)
закрыта на SQL-уровне через ST_DWithin от subject-координат — см. Tier A
в `_fetch_anchor_comps` (#2581) и Tier S в `_fetch_analogs` (#oblast-D,
f9ae6f0c) — геопредикат надёжнее строкового city-токена и не ломает то,
что уже нормализуется здесь.
Returns (street_core, base_no, letter) — любой элемент None если не извлёкся.
Best-effort: при пустом адресе → (None, None, None).
"""
if not address:
return None, None, None
norm = address.replace("ё", "е").replace("Ё", "Е").lower()
# Отрезаем «· …»-хвост (Avito-формат «… · р-н Центр»): district всегда после «·».
norm = norm.split("·")[0]
# «д.»/«дом» перед номером → пробел (чтобы числовой токен встал отдельно).
norm = re.sub(r"\b(?:д\.?|дом)\s*(?=\d)", " ", norm, flags=re.UNICODE)
raw = [t for t in _TOKEN_SPLIT_RE.split(norm) if t]
# 1. Вычищаем admin-маркеры (+следующее за ними значение) и города.
cleaned: list[str] = []
skip_next = False
for tok in raw:
if skip_next:
skip_next = False
continue
bare = tok.rstrip(".")
if bare in _ADMIN_MARKER_TOKENS:
skip_next = True # дропаем и сам маркер, и следующее слово-значение
continue
if bare in _CITY_TOKENS:
continue
cleaned.append(tok)
# 2. House-токен = ПОСЛЕДНИЙ токен, начинающийся с цифры. Берём последний (а не
# первый), чтобы числа ВНУТРИ имени улицы («8 Марта», «1905 года») не съелись
# как номер дома — настоящий номер всегда в хвосте, за именем. Хвост за номером
# (корпус «/N», «кN») игнорируем; всё остальное — токены улицы.
base_no: int | None = None
letter: str | None = None
house_idx: int | None = None
for i, tok in enumerate(cleaned):
if tok[0].isdigit():
head = re.split(r"[/\\]", tok, maxsplit=1)[0] # «4/2» → «4»; корпус отброшен
head = re.split(r"к\d", head, maxsplit=1)[0] # «105к1» → «105»
m = _HOUSE_LETTER_RE.match(head)
if m:
base_no = int(m.group("num"))
letter = m.group("letter") or None
house_idx = i
# 3. street_core = токены до номера дома, минус type-слова (улица/наб./пр./…) и
# минус сам house-токен. Числовые префиксы имени («8 марта») сохраняем.
street_tokens = [
tok
for i, tok in enumerate(cleaned)
if i != house_idx and tok.rstrip(".") not in _STREET_TYPE_TOKENS
]
street_core = " ".join(street_tokens).strip() or None
if street_core:
street_core = _STREET_ALIAS_MAP.get(street_core, street_core)
return street_core, base_no, letter
def _house_boundary_regex(base_no: int, letter: str | None) -> str:
"""Numeric-boundary regex для «того же дома» (общий для Tier A anchor и Tier S radius).
Дом 204 не матчит 2040/1204 (нет цифры-соседа слева/справа); литера при
наличии обязательна (204г ≠ 204д) — без неё сравниваем «голый» номер.
Корпус «/N» допускаем (тот же дом, слэш явно разрешён альтернативой в классе).
Применяется через SQL `~` к `lower(translate(address, 'ёЁ', 'ее'))`.
"""
if letter:
return rf"(^|[^0-9]){base_no}\s*{letter}([^а-яёa-z0-9/]|/|$)"
return rf"(^|[^0-9]){base_no}([^а-яёa-z0-9/]|/|$)"
def _anchor_comp_from_row(r: Any) -> dict[str, Any]:
"""Строит comp-dict из строки SQL same-building/micro-radius (#694).
Несёт 5 числовых полей для _compute_same_building_anchor
(price_per_m2/area_m2/rooms/floor/total_floors) + display-поля listings
(address/source/source_url/price_rub/listing_date/days_on_market/photo_urls/
lat/lon), чтобы UI-аналоги отражали ИМЕННО комплы, на которых построен якорь,
а не радиусные (cheaper/empty). Display-ключи best-effort: SELECT их тянет, но
helper устойчив к их отсутствию (тестовые моки могут давать только числа).
"""
return {
"price_per_m2": int(r["price_per_m2"]),
"area_m2": float(r["area_m2"]) if r.get("area_m2") is not None else None,
"rooms": int(r["rooms"]) if r.get("rooms") is not None else None,
"floor": int(r["floor"]) if r.get("floor") is not None else None,
"total_floors": int(r["total_floors"]) if r.get("total_floors") is not None else None,
"address": r.get("address"),
"source": r.get("source"),
"source_url": r.get("source_url"),
"price_rub": int(r["price_rub"]) if r.get("price_rub") is not None else None,
"listing_date": r.get("listing_date"),
"days_on_market": r.get("days_on_market"),
"photo_urls": r.get("photo_urls"),
"lat": float(r["lat"]) if r.get("lat") is not None else None,
"lon": float(r["lon"]) if r.get("lon") is not None else None,
}
# #2581: Tier A ("same building") ST_DWithin safety-radius. Reuses the SAME
# city-scale DEFAULT_RADIUS_M already used by Tier S's mirrored geo-bound fix
# (f9ae6f0c, #oblast-D). The address-string match (_normalize_building_key +
# _house_boundary_regex) already establishes "same street + house number" —
# this radius only needs to reject GENUINELY cross-city collisions (e.g.
# «улица Ленина» exists in both Екатеринбург AND Серов/Нижний Тагил, 150+ km
# apart) — it does not need to be building-tight like Tier C's 500m
# micro-radius (that tier's precision comes from proximity alone, without a
# street/house string match to lean on).
ANCHOR_TIER_A_RADIUS_M = DEFAULT_RADIUS_M
def _fetch_anchor_comps(
db: Session,
*,
address: str | None,
target_house_id: int | None,
lat: float | None,
lon: float | None,
rooms: int | None,
area: float | None,
) -> tuple[list[dict[str, Any]], str | None]:
"""Тированный набор комплов для same-building якоря. Стоп на 1-м тире с ≥ min_comps.
Tier A — SAME BUILDING: normalized street + base house no (+ литера если есть)
+ ST_DWithin(ANCHOR_TIER_A_RADIUS_M) от subject lat/lon (#2581 — до этого
SQL не имел ГЕО-предиката вовсе, и одноимённая улица+дом в ДРУГОМ городе
(область — 368 городов, «Ленина»/«Мира»/... повторяются) молча матчила
ЕКБ-листинги для областного subject'а — ~40 191 из ~40 200 активных
листингов ЕКБ, distance неизвестна без фильтра). RELAXED rooms (без
фильтра), БЕЗ area±15%. Не группируем по house_id_fk — один дом дробится
на несколько fk (Хохрякова 48 = 7085/9878/12797); ST_DWithin — тот же
компромисс, что и Tier S ниже (см. _fetch_analogs), не house_id_fk.
lat/lon subject'а обязательны (гейт как у Tier C) — без них геопредикат
невозможен, и Tier A целиком пропускается (в проде geo ВСЕГДА есть —
estimate_quality возвращает _empty_estimate раньше при неудачном
geocode, см. `if geo is None`).
Tier C — micro-radius ≤500m (ST_DWithin) + вторичка-канон guard (#1186): NULL = legacy
вторичка + rooms match + area±25%. (Tier B «тот же ЖК» — skip: complex_id/cian_zhk_url
ненадёжны.)
Tier D — фолбэк: None tier (caller остаётся на радиусном median-пути).
Excludes lots без price_per_m2. is_active=true И scraped_at свежее
LISTINGS_FRESH_DAYS (#2656 — то же окно, что у радиусного пути; см.
комментарий в SQL Tier A). Best-effort: ([], None) на ошибке.
Returns (comps, tier) где tier ∈ {'A','C', None}. comps — list dict с
ключами price_per_m2 (int>0), area_m2 (float|None), rooms (int|None),
floor (int|None), total_floors (int|None) — последние два для floor-веса (#680-WB).
"""
min_comps = settings.estimate_sb_min_comps
# ── Tier A: same building ────────────────────────────────────────────────
street, base_no, letter = _normalize_building_key(address)
if street and base_no is not None and lat is not None and lon is not None:
# ё→е в SQL для symmetry с нормализатором. psycopg v3: bind через :param,
# оператор ~. Boundary-regex вынесен в _house_boundary_regex (общий с
# Tier S radius-fallback ниже, см. _fetch_analogs).
house_re = _house_boundary_regex(base_no, letter)
try:
rows = (
db.execute(
text(
"""
SELECT price_per_m2, area_m2, rooms, floor, total_floors,
address, source, source_url, price_rub, listing_date,
days_on_market, photo_urls, lat, lon,
listing_segment, source_id
FROM listings
WHERE is_active = true
-- #2656: то же окно свежести, что несёт _COMMON_WHERE
-- радиусного пути. Без него якорь строился на
-- объявлениях, которых никто не видел месяц (is_active
-- живёт по TTL деактивации 30д, у NULL-сегмента — вообще
-- бессрочно), и они ЗАМЕЩАЛИ headline (median_ppm2/
-- median_price/n_analogs ниже), т.е. двигали деньги
-- напрямую. Прод 2026-08: 31 дом из 952 получал якорь
-- Tier A исключительно за счёт протухших строк.
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND price_per_m2 > 0
AND lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
AND geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
:radius
)
"""
),
{
"street_like": "%" + street + "%",
"house_re": house_re,
"lon": lon,
"lat": lat,
"radius": ANCHOR_TIER_A_RADIUS_M,
"fresh_days": LISTINGS_FRESH_DAYS,
},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("anchor Tier A lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception:
pass
rows = []
# #1774: gated relaxation of the #1186 guard, Tier A ONLY. В сданном доме
# cian тегирует переуступки/перепродажи собственниками как 'novostroyki';
# впускаем novostroyki только если вторичных (vtorichka/NULL) НЕ МЕНЬШЕ, чем
# первичных — иначе MAD-clip может выкинуть редкую вторичку и заякорить на
# ценах застройщика. Чисто-первичный / primary-dominated дом → гард #1186.
raw_rows = list(rows)
secondary_count = sum(
1 for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")
)
primary_count = len(raw_rows) - secondary_count
include_primary = secondary_count >= 1 and secondary_count >= primary_count
if include_primary:
kept = raw_rows # mixed/delivered house: include novostroyki resales
else:
kept = [r for r in raw_rows if r.get("listing_segment") in (None, "vtorichka")]
# Dedup дублей одного объявления (#1774: source_id=330047129 → 2 active-строки,
# одна с house_id_fk, одна NULL). Ключ — (source, source_id) PRIMARY (codebase
# canon: скрейперы дедупят на source_id; source_url может отличаться trailing
# slash/query-param). Fallback source_url, затем атрибуты. Namespaced-теги
# ('id'/'url') защищают от value-collision между id и url.
deduped: dict[Any, dict[str, Any]] = {}
for r in kept:
sid = r.get("source_id")
url = r.get("source_url")
if sid is not None:
key: Any = (r.get("source"), "id", sid)
elif url is not None:
key = (r.get("source"), "url", url)
else:
key = (
r.get("source"),
r.get("address"),
r.get("area_m2"),
r.get("price_per_m2"),
)
if key not in deduped:
deduped[key] = r
deduped_rows = list(deduped.values())
comps = [_anchor_comp_from_row(r) for r in deduped_rows if r["price_per_m2"]]
if len(comps) >= min_comps:
primary_n = sum(1 for r in deduped_rows if r.get("listing_segment") == "novostroyki")
logger.info(
"anchor tier=A street=%r base=%s letter=%s%d comps (primary=%d)",
street,
base_no,
letter,
len(comps),
primary_n,
)
return comps, "A"
# ── Tier C: micro-radius ≤500m + same segment + rooms + area±25% ─────────
if lat is not None and lon is not None and rooms is not None and area:
try:
rows = (
db.execute(
text(
"""
SELECT price_per_m2, area_m2, rooms, floor, total_floors,
address, source, source_url, price_rub, listing_date,
days_on_market, photo_urls, lat, lon
FROM listings
WHERE is_active = true
-- #2656: окно свежести, симметрично Tier A выше и
-- _COMMON_WHERE радиусного пути (см. комментарий там).
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND price_per_m2 > 0
AND rooms = CAST(:rooms AS integer)
AND area_m2 BETWEEN :area_min AND :area_max
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
AND (listing_segment IS NULL OR listing_segment = 'vtorichka')
AND geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
500
)
"""
),
{
"rooms": rooms,
"area_min": area * 0.75,
"area_max": area * 1.25,
"lon": lon,
"lat": lat,
"fresh_days": LISTINGS_FRESH_DAYS,
},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("anchor Tier C lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception:
pass
rows = []
comps = [_anchor_comp_from_row(r) for r in rows if r["price_per_m2"]]
if len(comps) >= min_comps:
logger.info("anchor tier=C micro-radius → %d comps", len(comps))
return comps, "C"
# Tier D — caller fallback (радиусный median-путь без anchor).
return [], None
@dataclass(frozen=True)
class _HouseFacts:
"""Характеристики дома из справочника `houses` (#3234).
total_floors / year_built / house_type — fallback для пропущенных
payload-полей ПЕРЕД OSM Overpass (houses на два порядка полнее и не тратит
внешний вызов). material_walls читается на будущее — потребителя ниже по
estimator пока нет, но данные есть: 7838 домов из 10221 (77%) на проде после
наполнения из ГИС ЖКХ + DOM.РФ 2026-08-29.
"""
house_id: int
total_floors: int | None
year_built: int | None
house_type: str | None
material_walls: str | None
def _lookup_house_facts(
db: Session,
*,
target_house_id: int | None,
lat: float | None,
lon: float | None,
) -> _HouseFacts | None:
"""Читает total_floors/year_built/house_type/material_walls из `houses`.
Ключ — canonical target_house_id (уже резолвлен match_house_readonly выше
по estimate-функции); если он None — фолбэк на ближайший дом в радиусе 60м
от subject lat/lon (ST_DWithin + сортировка по расстоянию, LIMIT 1).
Best-effort: любая ошибка БД → None + WARNING, оценка не должна падать
(тот же паттерн try/except + db.rollback(), см. _fetch_anchor_comps выше).
"""
try:
if target_house_id is not None:
row = (
db.execute(
text(
"""
SELECT id, total_floors, year_built, house_type, material_walls
FROM houses
WHERE id = CAST(:hid AS bigint)
"""
),
{"hid": target_house_id},
)
.mappings()
.first()
)
elif lat is not None and lon is not None:
row = (
db.execute(
text(
"""
SELECT id, total_floors, year_built, house_type, material_walls
FROM houses
WHERE geom IS NOT NULL
AND ST_DWithin(
geom::geography,
ST_MakePoint(:lon, :lat)::geography,
60
)
ORDER BY geom::geography <-> ST_MakePoint(:lon, :lat)::geography
LIMIT 1
"""
),
{"lon": lon, "lat": lat},
)
.mappings()
.first()
)
else:
return None
except Exception as exc: # pragma: no cover — defensive
logger.warning("house facts lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception: # pragma: no cover — defensive
pass
return None
if row is None:
return None
# Guard типов строки: houses.id — NOT NULL bigint, любая другая форма (в т.ч.
# тестовый db-double без явного мока запроса) трактуется как «строки нет» —
# тот же best-effort, что и явная ошибка выше. Остальные поля best-effort
# каждое по отдельности — неожиданный тип поля не должен ронять facts целиком.
house_id = row["id"]
if not isinstance(house_id, int):
return None
def _as_int(value: Any) -> int | None:
return value if isinstance(value, int) else None
def _as_str(value: Any) -> str | None:
return value if isinstance(value, str) else None
return _HouseFacts(
house_id=house_id,
total_floors=_as_int(row["total_floors"]),
year_built=_as_int(row["year_built"]),
house_type=_as_str(row["house_type"]),
material_walls=_as_str(row["material_walls"]),
)
def _band_haircut(anchor_ppm2: float) -> float:
"""asking→sold haircut, banded по ppm² (class-label в prod пуст — band на цену).
Премиум (высокий ppm²) торгуется плотнее → меньше скидка; эконом — больше.
Пороги ЕКБ-вторички: ≥350k → 4%; 180-350k → 5%; <180k → 7%. Дефолт из config.
LOW audit #3: 0.04/0.07 (и mid из settings.asking_to_sold_haircut) —
EKB-secondary-market calibration constants, но применяются ENGINE-WIDE (нет
city-параметра ни здесь, ни у единственного вызывающего
`_compute_same_building_anchor`). #2581 update: та формулировка была НЕВЕРНОЙ —
same-building anchor pool для oblast ВСЕГДА мог сформироваться (Tier A до
#2581 не имел гео-предиката вообще, поэтому for oblast-subject'ов он либо
молча тянул ЕКБ-листинги по одноимённой улице/дому, либо — для действительно
уникальных названий — честно матчил местные листинги, если они были). После
#2581 (ST_DWithin(ANCHOR_TIER_A_RADIUS_M) на Tier A) anchor_ppm2 ДЛЯ ОБЛАСТИ
доходит сюда легитимно (местные комплы того же дома в Серове/Тагиле/etc, если
они есть в БД), но всё ещё через ЕКБ-калиброванный haircut — эти пороги
по-прежнему стоит пересмотреть/сделать per-city, не оставлять ЕКБ-калибровку
по умолчанию для другого рынка. No behavior change here (doc-only, кроме
исправления ложной посылки).
"""
if anchor_ppm2 >= 350_000:
return 0.04
if anchor_ppm2 >= 180_000:
return settings.asking_to_sold_haircut # 5% mid
return 0.07
def _mad_clip(values: list[float], k: float) -> list[int]:
"""MAD-клип: возвращает индексы элементов values, не являющихся выбросами.
Выброс: |v median| > k × MAD, где MAD = median(|v median|).
Чистая функция без side-effect'ов — возвращает список индексов выживших
(не сами значения, чтобы caller мог фильтровать list[dict] по ним).
При MAD == 0 (все значения одинаковы) — все элементы проходят (ни один
не считается выбросом в вырожденном случае).
Ожидает непустой список; caller гарантирует len(values) >= 1.
"""
sorted_v = sorted(values)
median = _percentile(sorted_v, 0.5)
deviations = sorted([abs(v - median) for v in values])
mad = _percentile(deviations, 0.5)
if mad == 0.0:
# Все значения идентичны — нечего отсекать.
return list(range(len(values)))
threshold = k * mad
return [i for i, v in enumerate(values) if abs(v - median) <= threshold]
def _compute_same_building_anchor(
comps: list[dict[str, Any]],
*,
area_target: float,
rooms_target: int | None,
tier: str,
sigma: float,
rooms_boost: float,
floor_target: int | None = None,
total_floors_target: int | None = None,
floor_sigma: float = 0.0,
min_comps: int = 4,
mad_k: float = 3.5,
) -> dict[str, Any] | None:
"""Чистая (testable без БД) свёртка комплов в anchor-оценку.
1. similarity-weighted mean ppm²: w_i = exp((ln(area_i/area_target))²/(2σ²))
× (rooms_boost если rooms_i==rooms_target) × FLOOR-вес. area_i=None →
area-вес 1.0 (нейтрально). FLOOR-вес (#680-WB) — Gaussian по ОТНОСИТЕЛЬНОЙ
вертикальной позиции pos=floor/total_floors: exp((pos_ipos_t)²/(2σ_f²)),
σ_f=floor_sigma. Прижимает якорь к комплам с похожим этажом (1-й/последний
и видовые этажи реально отличаются по цене). floor_sigma=0 ИЛИ нет floor у
target/компла → вес 1.0 (выключено / нейтрально — no regress).
2. PREMIUM uplift (class-free): target — топ-юнит ДОМА (area ≥ p66 площадей
комплов) И rooms_target ≥ медианы комнат комплов И tier == 'A' → берём
weighted ~p70 верхний квантиль ppm² (консервативно, только вверх). Условие
по комнатам (#680-WB) не даёт мелкокомнатному юниту во флагман-доме унаследовать
headline-премию флагмана (3к/153 в доме с 4к-флагманом ≠ цена флагмана).
3. haircut asking→sold (banded по anchor ppm²): anchor_sold = anchor×(1haircut).
4. FSD = 0.07 + 0.25·CV(comp ppm²) + tier_penalty + n_penalty; range полуширина
= k·fsd. confidence-банд по fsd.
Returns dict {anchor_ppm2, anchor_sold_ppm2, fsd, confidence, n, cv,
comp_min_ppm2, used_uplift, haircut, comps} или None если комплов нет.
"comps" — ФИНАЛЬНЫЙ (пост-MAD-clip, оба прохода) список выживших comp-словарей,
len(comps) == n. Это ЕДИНСТВЕННЫЙ источник правды для того, какие именно
комплы сформировали anchor_ppm2/n — caller обязан строить UI-карточки
(anchor_comps_used) из НЕГО, а не из исходного (пред-clip) списка, иначе
MAD-clip-отсечённый ценовой выброс всё ещё показывается пользователю, а
n_analogs (=n) расходится с фактическим числом карточек (display-consistency
fix, см. estimate_quality #2527).
"""
if not comps:
return None
# Строим параллельные списки comps/ppm2 с гарантированным соответствием индексов.
priced_pairs = [(c, float(c["price_per_m2"])) for c in comps if c.get("price_per_m2")]
if not priced_pairs:
return None
# #755 param-3: MAD-clip — отсекаем выбросы по price_per_m2 до агрегации.
# Если после клипа выживает < min_comps — якорь НЕ срабатывает (→ None → fallback).
raw_ppm2 = [p for _, p in priced_pairs]
# #1795 шаг 2: ужесточаем MAD-clip на малых выборках. При n < small_n_threshold
# дефолтный mad_k=3.5 слишком мягок (n=7 не срезает элитные хвосты на право-
# скошенном премиум-распределении → similarity-weighted mean тянется вверх).
# Эффективный k выбирается ВНУТРИ функции до _mad_clip (сигнатура не меняется).
# mad_k_small >= mad_k → no-op (старое поведение).
# ВАЖНО: Tier A (комплы ТОГО ЖЕ дома) EXEMPT — внутридомовой спред (этаж/вид)
# легитимен, агрессивный clip там съел бы реальные топ-юниты и обрушил бы якорь
# < min_comps → fallback на заниженную радиусную медиану. Tier C/прочие — clip.
effective_mad_k = mad_k
if (
tier != "A"
and settings.estimate_sb_mad_k_small_n < mad_k
and len(raw_ppm2) < settings.estimate_sb_small_n_threshold
):
effective_mad_k = settings.estimate_sb_mad_k_small_n
surviving_idx = _mad_clip(raw_ppm2, effective_mad_k)
if len(surviving_idx) < min_comps:
logger.info(
"anchor MAD-clip: %d comps → %d survived (< min_comps=%d) → fallback",
len(priced_pairs),
len(surviving_idx),
min_comps,
)
return None
if len(surviving_idx) < len(priced_pairs):
logger.info(
"anchor MAD-clip: %d comps → %d after k=%.1f×MAD clip",
len(priced_pairs),
len(surviving_idx),
effective_mad_k,
)
priced_pairs = [priced_pairs[i] for i in surviving_idx]
comps = [c for c, _ in priced_pairs]
ppm2 = [p for _, p in priced_pairs]
n = len(ppm2)
# target relative vertical position (None → floor-вес отключён/нейтрален).
target_pos: float | None = None
if floor_sigma > 0 and floor_target and total_floors_target and total_floors_target > 0:
target_pos = floor_target / total_floors_target
# #audit-4: sigma > 0 guard — при sigma=0 Gaussian exp(-x²/0) → div/0/NaN.
# area_sigma=0 (отключено) → нейтральный вес 1.0; floor_sigma=0 уже гейтится выше.
safe_area_sigma2 = 2.0 * sigma * sigma if sigma > 0 else 0.0
safe_floor_sigma2 = 2.0 * floor_sigma * floor_sigma if floor_sigma > 0 else 0.0
# 1. similarity weights (area × rooms × floor-position)
weights: list[float] = []
for c in comps:
a = c.get("area_m2")
if a and area_target > 0 and safe_area_sigma2 > 0:
area_w = math.exp(-((math.log(a / area_target)) ** 2) / safe_area_sigma2)
else:
area_w = 1.0 # площадь неизвестна или sigma=0 → нейтральный area-вес
rooms_match = rooms_target is not None and c.get("rooms") == rooms_target
rooms_w = rooms_boost if rooms_match else 1.0
floor_w = 1.0
if target_pos is not None and safe_floor_sigma2 > 0:
cf = c.get("floor")
ctf = c.get("total_floors")
if cf and ctf and ctf > 0:
comp_pos = cf / ctf
floor_w = math.exp(-((comp_pos - target_pos) ** 2) / safe_floor_sigma2)
# компл без floor/total_floors → нейтральный floor-вес 1.0
weights.append(area_w * rooms_w * floor_w)
wsum = sum(weights)
if wsum > 0:
anchor = sum(w * p for w, p in zip(weights, ppm2, strict=True)) / wsum
else:
anchor = _percentile(sorted(ppm2), 0.5)
# #audit-4: MAD-clip ПОСЛЕ similarity-weighting.
# Видовые/топ-юниты с высоким ppm² могут быть выкинуты сырым clip'ом ДО weighting —
# они легитимны. После weighting (anchor = weighted mean) ищем выбросы
# относительно self-consistent взвешенного пространства.
# Используем те же effective_mad_k; если после clip < min_comps → anchor=None.
if n >= 2:
post_clip_idx = _mad_clip(ppm2, effective_mad_k)
if len(post_clip_idx) < min_comps:
logger.info(
"anchor post-weight MAD-clip #audit-4: %d comps → %d survived"
" (< min_comps=%d) → fallback",
n,
len(post_clip_idx),
min_comps,
)
return None
if len(post_clip_idx) < n:
logger.info(
"anchor post-weight MAD-clip #audit-4: %d comps → %d after k=%.1f×MAD",
n,
len(post_clip_idx),
effective_mad_k,
)
comps = [comps[i] for i in post_clip_idx]
ppm2 = [ppm2[i] for i in post_clip_idx]
weights = [weights[i] for i in post_clip_idx]
n = len(ppm2)
wsum = sum(weights)
if wsum > 0:
anchor = sum(w * p for w, p in zip(weights, ppm2, strict=True)) / wsum
else:
anchor = _percentile(sorted(ppm2), 0.5)
# 2. premium uplift — топ-юнит дома (площадь ≥ p66 И комнаты ≥ медианы) И Tier A
# → weighted p70. Условие по комнатам отсекает мелкие юниты во флагман-домах.
used_uplift = False
areas = [c.get("area_m2") for c in comps if c.get("area_m2")]
comp_rooms = [c.get("rooms") for c in comps if c.get("rooms") is not None]
if tier == "A" and areas and area_target > 0:
p66_area = _percentile(sorted(areas), 0.66)
rooms_ok = True
if rooms_target is not None and comp_rooms:
median_rooms = _percentile(sorted(comp_rooms), 0.5)
rooms_ok = rooms_target >= median_rooms
if area_target >= p66_area and rooms_ok:
p70 = _percentile(sorted(ppm2), 0.70)
if p70 > anchor:
anchor = p70
used_uplift = True
# 3. asking→sold haircut (banded)
haircut = _band_haircut(anchor)
anchor_sold = anchor * (1.0 - haircut)
# 4. FSD-диапазон (tight). CV = std/mean comp ppm².
mean_ppm2 = sum(ppm2) / n
if mean_ppm2 > 0 and n >= 2:
var = sum((p - mean_ppm2) ** 2 for p in ppm2) / n
cv = math.sqrt(var) / mean_ppm2
else:
cv = 0.0
tier_penalty = {"A": 0.0, "C": 0.05}.get(tier, 0.09)
n_penalty = 0.05 if n < 3 else (0.02 if n < 5 else 0.0)
fsd = 0.07 + 0.25 * cv + tier_penalty + n_penalty
if fsd <= 0.13:
confidence = "high"
elif fsd <= 0.20:
confidence = "medium"
else:
confidence = "low"
# #755 param-2: confidence cap — при n < 5 комплах anchor не может быть "high"
# даже если FSD укладывается в 0.13 (мало данных — самоуверенный headline опасен).
if n < 5 and confidence == "high":
confidence = "medium"
return {
"anchor_ppm2": anchor,
"anchor_sold_ppm2": anchor_sold,
"fsd": fsd,
"confidence": confidence,
"n": n,
"cv": cv,
"comp_min_ppm2": min(ppm2),
"comp_max_ppm2": max(ppm2),
"used_uplift": used_uplift,
"haircut": haircut,
# финальный пост-MAD-clip пул, len(comps) == n (см. docstring выше).
"comps": comps,
}
# ── #693 prod-fix: coarse-geocode detector (DaData-independent) ─────────────
# Дом всегда оканчивается номером (1-3 цифры, опц. литера корпуса). Centroid
# города/региона его НЕ содержит. Прод-сигнал грубости геокода, работающий БЕЗ
# DaData: на проде DaData может быть off (token не сконфигурирован) → dadata.qc_geo
# всегда None → старый гейт #707 (условный на qc_geo>=2) НЕ срабатывал НИКОГДА,
# даже для региона/города. Кэш-агностичен — смотрит на geo.full_address, каким бы
# провайдером/кэшем он ни был получен.
#
# Граница (?<!\d)\d{1,3}(?!\d) исключает почтовый индекс (6 цифр) и год постройки
# (4 цифры) — они не матчат «дом». Числовые улицы («8 Марта») матчат свой номер →
# НЕ даунгрейдятся (консервативно: реальная улица, аналоги рядом есть; принцип
# #707 — никаких ложных downgrade важнее, чем отлов всех coarse-кейсов).
_HOUSE_NUMBER_RE = re.compile(r"(?<!\d)\d{1,3}[а-яёa-z]?(?!\d)", flags=re.IGNORECASE)
def _geocode_is_coarse(geo: GeocodeResult) -> bool:
"""True если геокод разрешился лишь до centroid'а НП/города/региона (без дома).
Два сигнала (OR):
1. provider confidence == 'locality' — явный centroid-маркер (на будущее:
текущие провайдеры его не эмитят, но enum/кэш-колонка допускают, и так
геокодер можно улучшить позже без правки гейта).
2. в geo.full_address нет house-number токена (1-3 цифры) → геокодер не дошёл
до дома, вернул центр НП/города/региона.
Гарантирует ZERO ложных downgrade на реальных адресах: у любого реального дома
есть номер → токен матчится → не coarse.
"""
if geo.confidence == "locality":
return True
return _HOUSE_NUMBER_RE.search(geo.full_address or "") is None
# ── Time-budget guard (#654) ────────────────────────────────────────────────
async def _with_budget(coro: Any, budget_s: float, *, label: str) -> Any:
"""Await `coro` under an asyncio.wait_for() time budget.
On timeout the coroutine is cancelled and we return None — mapping a slow
upstream onto the SAME graceful "None" path these enrichments already take
on network error, so a single slow source degrades the estimate instead of
blowing the gateway read timeout (#654: opaque Caddy 502/504).
budget_s <= 0 disables the guard (await directly) — escape hatch via config.
"""
if budget_s is None or budget_s <= 0:
return await coro
try:
return await asyncio.wait_for(coro, timeout=budget_s)
except TimeoutError:
# asyncio.TimeoutError is an alias of builtin TimeoutError (py3.11+).
logger.warning("%s exceeded %.1fs budget — degrading to None (#654)", label, budget_s)
return None
# ── PricingResult dataclass (pure, no I/O) ──────────────────────────────────
def _cv_from_ppm2(values: list[float | int | None]) -> float | None:
"""Коэффициент вариации ₽/м² (std/mean) по выборке — #2043 (BE-1).
Метрика разброса цен аналогов: делит population std на среднее. Совпадает с
CV, который _compute_same_building_anchor уже считает для anchor-комплов
(та же формула), но применима и к радиусной выборке / rehydrate из
сохранённых analogs. Возвращает None при <2 валидных (>0) значениях или
нулевом среднем (недостаточно данных → честный None, а не 0.0).
"""
vals = [float(v) for v in values if v]
n = len(vals)
if n < 2:
return None
mean = sum(vals) / n
if mean <= 0:
return None
var = sum((v - mean) ** 2 for v in vals) / n
return math.sqrt(var) / mean
def _source_counts(sources: list[str | None]) -> dict[str, int]:
"""Счётчики по источнику ({'avito': 12, 'cian': 5}) — #2043 (BE-1).
Считает по ПОЛНОЙ выборке аналогов (до top-N отсечки для UI). Пустые/None
источники пропускаются. Порядок ключей стабильно отсортирован для
детерминированного ответа.
"""
counts: dict[str, int] = {}
for s in sources:
if s:
counts[s] = counts.get(s, 0) + 1
return dict(sorted(counts.items()))
# Оценочные (не-листинговые) источники канона «7» источников. Листинговые
# (avito/cian/yandex/domklik) выводятся динамически из реальных analog-карточек;
# эти три — внешние оценки, которые не порождают analog-строк.
_CANONICAL_VALUATION_SOURCES: frozenset[str] = frozenset(
{"avito_imv", "yandex_valuation", "cian_valuation"}
)
def _canonical_sources(
analog_sources: Iterable[str | None],
valuation_flags: Iterable[str | None],
) -> list[str]:
"""Единый источник правды для sources_used — #2087 (M1).
sources_used = {листинговые источники, РЕАЛЬНО присутствующие в persisted
analogs} {оценочные источники (avito_imv/yandex_valuation/cian_valuation),
которые действительно использованы}. Детерминированно отсортирован.
Вызывается идентично на POST (estimate_quality) и GET-rehydrate
(api.v1.trade_in.get_estimate) из ТЕХ ЖЕ persisted analogs, поэтому один
estimate_id → идентичный sources_used на POST/GET/reload. Гарантирует
инвариант source_counts.keys() ⊆ sources_used: и counts, и листинговая часть
sources_used считаются из одного и того же набора analog-источников.
valuation_flags фильтруется до канонических оценочных источников — можно
безопасно передать сырой persisted-массив sources_used (листинговые сорта и
служебные метки вроде quarter_index отбрасываются, т.к. листинговая часть
берётся из analogs).
"""
listing = {s for s in analog_sources if s}
valuation = {s for s in valuation_flags if s in _CANONICAL_VALUATION_SOURCES}
return sorted(listing | valuation)
@dataclass
class PricingResult:
"""Return type of _price_from_inputs — все переменные, нужные estimate_quality после блока."""
median_ppm2: float
median_price: int
range_low: int
range_high: int
n_analogs: int
confidence: str
explanation: str
anchor_tier: str | None
anchor_comps_used: list[dict]
avito_imv_summary: AvitoImvSummary | None
dkp_corridor: DkpCorridor | None
expected_sold_per_m2: int | None
expected_sold_price: int | None
expected_sold_range_low: int | None
expected_sold_range_high: int | None
asking_to_sold_ratio: float | None
ratio_basis: str | None
sources_used_pre: list[str]
listings_clean: list[dict]
# #2043 (BE-1): коэффициент вариации ₽/м² по выборке, на которой построен
# headline. Anchor-путь → CV комплов (anchor["cv"]); radius-путь → CV
# радиусной ₽/м²-выборки. None если <2 цен (недостаточно данных).
cv: float | None = None
# #oblast-E/#oblast-F: >0 когда n листингов было найдено но ниже
# HEADLINE_LISTINGS_MIN_N. С #oblast-F (2026-08-10) больше НЕ обнуляет
# headline/listings_clean — median/n_analogs остаются реальными, поле лишь
# маркирует «низкая надёжность» (confidence='low' + честный explanation,
# см. gate comment ниже). 0 = либо выборка была достаточной, либо аналогов
# вообще не нашлось.
listings_headline_thin_n: int = 0
# #oblast-E (restored priority, product correction 2026-08-10): True когда
# headline построен из #oblast-D deals-corridor ИМЕННО потому, что базовая
# выборка листингов была тонкой (0 < n < HEADLINE_LISTINGS_MIN_N) И доступен
# достаточно надёжный ДКП-коридор (см. deals-headline-fallback блок ниже).
# Caller (estimate_quality) читает это чтобы (a) добавить relaxation-подпись
# «оценка по сделкам — мало объявлений рядом», (b) закэпить reliability на
# 'low' (не выше). False во всех остальных случаях, включая genuinely-zero
# listings deals-fallback (тот же блок, но без тонкой выборки позади).
deals_headline_due_to_thin_listings: bool = False
def _analog_word_dative(n: int) -> str:
"""Дательный падеж существительного «аналог» для confidence_explanation
тонкой (#oblast-E) выборки — «построена по N аналогу/аналогам»."""
return "аналогу" if n == 1 else "аналогам"
def _price_from_inputs(
*,
listings: list[dict],
area_m2: float,
rooms: int | None,
repair_state: str | None,
floor: int | None,
total_floors: int | None,
target_year: int | None,
analog_tier: str,
fallback_used: bool,
area_widened: bool,
anchor_comps: list[dict],
anchor_tier_fetched: str | None,
dkp_raw: dict | None,
imv_anchor: dict | None,
imv_eval: IMVEvaluation | None,
yandex_val_present: bool,
cian_val_present: bool,
ratio_resolver: Callable[[float | None], tuple[float | None, str | None]],
quarter_index_lookup: Callable[[str], tuple[float, int] | None],
quarter_indexes_lookup: Callable[[list[str]], dict[str, float]],
# Кадастр ЦЕЛИ (DaData) ИЛИ уже снятый по её координатам квартал — используется
# ровно в одном месте, блок #764, и только ради 3-го компонента (квартала).
# Кадастр аналога сюда подставлять нельзя: это квартал соседа, а не цели.
target_house_cadnum: str | None,
dadata_coarse: bool,
geo: GeocodeResult,
dadata_qc_geo: int | None,
# #2632: фактический радиус подбора (после каскада #oblast-F) — чтобы
# объяснение называло тот радиус, по которому реально искали. None →
# прежнее поведение (FALLBACK_RADIUS_M) для оффлайн-вызывающих (бэктест).
search_radius_m: int | None = None,
) -> PricingResult:
"""Deterministic pricing orchestration — pure, synchronous, zero I/O.
Extracted from estimate_quality (#1966) to enable offline backtesting and
direct unit testing. All DB lookups are injected via callables or pre-fetched
values; behavior is byte-identical to the original block.
"""
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
listings = _dedup_cross_source(listings)
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
# НИКОГДА не проходил через дедуп — только радиусные `listings` дедупились.
# Когда anchor сработал, anchor_comps_used (и n_analogs=anchor["n"]) брались
# из недедупленного пула → тот же физлот с разных source (пустой cadnum,
# street-композит совпал) считался/показывался дважды. Дедупим ЗДЕСЬ, ДО
# _compute_same_building_anchor — она сама дальше MAD-клипит anchor_comps и
# возвращает финальный переживший пул как anchor["comps"] (display-consistency
# fix, см. anchor_comps_used=anchor["comps"] ниже) — n_analogs и показанные
# top-N analogs остаются согласованы автоматически по построению.
anchor_comps = _dedup_cross_source(anchor_comps)
# 3. Outlier filter
listings_clean = _filter_outliers(listings)
# 4. Aggregation
if listings_clean:
prices_ppm2 = sorted(lot["price_per_m2"] for lot in listings_clean if lot["price_per_m2"])
median_ppm2 = _percentile(prices_ppm2, 0.5)
q1_ppm2 = _percentile(prices_ppm2, 0.25)
q3_ppm2 = _percentile(prices_ppm2, 0.75)
median_price = int(median_ppm2 * area_m2)
range_low = int(q1_ppm2 * area_m2)
range_high = int(q3_ppm2 * area_m2)
# #2: n_analogs считается по prices_ppm2, а не len(listings_clean).
n_analogs = len(prices_ppm2)
# #2043 (BE-1): CV радиусной выборки ₽/м² (переопределяется anchor CV ниже,
# если якорь сработал). None при <2 ценах.
cv = _cv_from_ppm2(list(prices_ppm2))
else:
median_ppm2 = 0.0
q1_ppm2 = 0.0
q3_ppm2 = 0.0
median_price = 0
range_low = 0
range_high = 0
n_analogs = 0
cv = None
# 4a. #oblast-E sufficiency gate (see HEADLINE_LISTINGS_MIN_N docstring above)
# — priority RESTORED 2026-08-10 (product correction on top of #oblast-F):
# 1..HEADLINE_LISTINGS_MIN_N-1 listings are a real find, but not trustworthy
# enough to headline on their OWN — a more reliable source should win when
# one exists. Two sub-cases:
# (i) a usable ДКП deals corridor is available (same threshold the
# #oblast-D deals-headline-fallback block below itself requires,
# DEALS_HEADLINE_FALLBACK_MIN_N deals with a positive median) → the
# listings AGGREGATE is suppressed to zero here so that block takes
# over the headline, EXACTLY like original #oblast-E. This is the
# Серов repro this gate exists for: n=3 listings must not outrank a
# 54-deal corridor. `listings_clean` stays intact (never cleared) —
# both for the anchor ghost-anchor guard (#1871) AND so
# estimate_quality() still surfaces these listings as display
# `analogs` cards even though they no longer drive n_analogs/median.
# (ii) no usable corridor → #oblast-F (never-block, 2026-08-10): keep the
# real thin median rather than refusing outright. By the time
# control reaches this function, estimate_quality() has already run
# the #oblast-F relaxation cascade (room-adjacency / freshness /
# novostroyki / radius) trying to grow the sample past the
# threshold — `listings` here is whatever that cascade could find.
# NOTE: `gate_ceded_to_deals` (local, this function only) is DIFFERENT from
# the `deals_headline_due_to_thin_listings` PricingResult field set later —
# this one fires as soon as the gate DECIDES to cede (used below to skip
# the repair-coefficient/explanation blocks safely, regardless of whether
# anchor later overrides); the field fires only once the #oblast-D
# deals-headline-fallback block ACTUALLY builds the headline from deals
# (anchor may still override in between — see that block).
listings_headline_thin_n = 0
gate_ceded_to_deals = False
# Outward PricingResult field — set True below, ONLY inside the actual
# #oblast-D deals-headline-fallback block, once it fires for THIS reason.
deals_headline_due_to_thin_listings = False
if 0 < n_analogs < HEADLINE_LISTINGS_MIN_N:
listings_headline_thin_n = n_analogs
dkp_corridor_usable = (
dkp_raw is not None
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
and dkp_raw.get("median_ppm2", 0) > 0
)
if dkp_corridor_usable:
gate_ceded_to_deals = True
logger.info(
"headline sufficiency gate #oblast-E: n=%d < %d listings, usable ДКП "
"corridor (n=%s) available — suppressing listings-derived median, "
"ceding headline to deals/anchor chain",
n_analogs,
HEADLINE_LISTINGS_MIN_N,
dkp_raw.get("count", 0) if dkp_raw else None,
)
median_ppm2 = 0.0
q1_ppm2 = 0.0
q3_ppm2 = 0.0
median_price = 0
range_low = 0
range_high = 0
n_analogs = 0
cv = None
else:
logger.info(
"headline sufficiency note #oblast-E: n=%d < %d listings, no usable "
"ДКП corridor — keeping real median, flagged low-reliability "
"(#oblast-F: never suppressed to zero without a fallback source)",
n_analogs,
HEADLINE_LISTINGS_MIN_N,
)
# 4b. Repair coefficient — applies to any real (non-zero) median, INCLUDING
# thin-but-kept (#oblast-F case ii) samples — a repair-state adjustment is
# meaningful there. Skipped when the gate ceded the headline to deals
# (gate_ceded_to_deals — median_price is already 0 above; applying a
# coefficient would leave it 0 but still emit a misleading "adjusted for
# repair state" note, same reasoning original #oblast-E used).
repair_coef = _repair_coefficient(repair_state)
repair_note = ""
if listings_clean and not gate_ceded_to_deals and repair_coef != 1.0:
median_price = int(median_price * repair_coef)
range_low = int(range_low * repair_coef)
range_high = int(range_high * repair_coef)
median_ppm2 = median_ppm2 * repair_coef
pct = round((repair_coef - 1.0) * 100)
# LOW audit #1: _REPAIR_COEF (см. предупреждение выше, строки ~262-266) —
# tunable market-эвристика с покрытием данных ~2%, НЕ data-derived величина.
# Формулировка ниже честно маркирует поправку как ориентировочную/эвристическую,
# а не как вычисленную из данных корректировку.
repair_note = (
f" Оценка ориентировочно скорректирована на состояние ремонта "
f"(эвристика: {_REPAIR_LABEL.get(repair_state, '')} {pct:+d}%)."
)
# Build sources_used_pre from listings + external sources
sources_used_pre = sorted({lot.get("source") for lot in listings_clean if lot.get("source")})
if imv_eval is not None:
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
if yandex_val_present:
sources_used_pre = sorted(set(sources_used_pre) | {"yandex_valuation"})
if cian_val_present:
sources_used_pre = sorted(set(sources_used_pre) | {"cian_valuation"})
# 4c. asking→sold variables — initialized; actual computation is after all mutations.
asking_to_sold_ratio: float | None = None
ratio_basis: str | None = None
expected_sold_per_m2: int | None = None
expected_sold_price: int | None = None
expected_sold_range_low: int | None = None
expected_sold_range_high: int | None = None
confidence, explanation = _compute_confidence(
n_analogs,
median_ppm2,
q1_ppm2 if listings_clean else 0,
q3_ppm2 if listings_clean else 0,
fallback_used,
area_widened,
listings=listings_clean,
radius_used_m=search_radius_m,
)
# #oblast-E/#oblast-F: honest low-reliability note — ONLY for case (ii) of
# the gate above (real thin median kept, no usable deals corridor to cede
# to). Case (i) (gate_ceded_to_deals) must NOT set this text — the
# deals-headline-fallback block below writes its OWN "built from
# Rosreestr deals" explanation; setting this first would leave a
# contradictory "Оценка построена по N аналогам" sentence stapled in front
# of it. Stays the final explanation unless a later block (anchor /
# #oblast-D deals-fallback) overwrites it with its OWN honest reasoning.
if listings_headline_thin_n and not gate_ceded_to_deals:
confidence = "low"
explanation = (
f"Оценка построена по {listings_headline_thin_n} "
f"{_analog_word_dative(listings_headline_thin_n)} — выборка мала, "
"точность снижена."
)
# Tier note — информируем пользователя о качестве house-match
tier_note = ""
if analog_tier == "S":
tier_note = " (аналоги из того же дома)"
elif analog_tier == "H":
tf_str = f"{total_floors}-эт." if total_floors is not None else ""
yr_str = f"{target_year}±15 г." if target_year else ""
parts_str = ", ".join(p for p in [yr_str, tf_str] if p)
tier_note = f" (аналоги из домов того же класса: {parts_str})" if parts_str else ""
else:
tier_note = " (нет аналогов в том же доме/классе — расширили поиск)"
explanation = (explanation or "") + tier_note + repair_note
# ── #651/#652 v2: same-building anchor ───────────────────────────────────
anchor_tier: str | None = anchor_tier_fetched
anchor_comps_used: list[dict] = []
anchor = _compute_same_building_anchor(
anchor_comps,
area_target=area_m2,
rooms_target=rooms,
tier=anchor_tier or "",
sigma=settings.estimate_sb_area_sigma,
rooms_boost=settings.estimate_sb_rooms_match_boost,
floor_target=floor,
total_floors_target=total_floors,
floor_sigma=settings.estimate_sb_floor_sigma,
min_comps=settings.estimate_sb_min_comps,
mad_k=settings.estimate_sb_mad_k,
)
# #1795 шаг 3: гейт Tier C.
if (
anchor is not None
and anchor_tier == "C"
and settings.estimate_anchor_tier_c_corridor_mult > 0
):
if dkp_raw is not None and dkp_raw.get("high_ppm2", 0) > 0:
corridor_high_for_gate = float(dkp_raw["high_ppm2"])
else:
corridor_high_for_gate = (median_ppm2 / repair_coef) * 1.3 if repair_coef else 0.0
gate_threshold = corridor_high_for_gate * settings.estimate_anchor_tier_c_corridor_mult
if gate_threshold > 0 and anchor["anchor_ppm2"] > gate_threshold:
logger.info(
"sb_anchor Tier C gate #1795: anchor_ppm2=%d > corridor_high×%.1f=%d"
" → keep radius median (anchor suppressed)",
int(anchor["anchor_ppm2"]),
settings.estimate_anchor_tier_c_corridor_mult,
int(gate_threshold),
)
anchor = None
# #audit-1: low-confidence gate.
if anchor is not None:
gate_low = anchor["confidence"] == "low"
gate_thin = (
anchor["n"] < settings.estimate_sb_gate_min_n
and anchor["fsd"] > settings.estimate_sb_gate_max_fsd
)
if gate_low or gate_thin:
logger.info(
"sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s"
" → suppressed (gate_low=%s gate_thin=%s) → radius fallback",
anchor_tier,
anchor["n"],
anchor["fsd"],
anchor["confidence"],
gate_low,
gate_thin,
)
anchor = None
# #2661: якорь не построен — сбрасываем tier-флаг ЯВНО. Причин три:
# _compute_same_building_anchor вернула None (комплов меньше min_comps, в т.ч.
# после MAD-клипа, см. #oblast-E выше), гейт Tier C #1795 или low-conf гейт
# #audit-1 выше. Раньше сброс делал только последний из трёх, и в остальных
# случаях anchor_tier залипал равным anchor_tier_fetched ("C"/"A") при радиусном
# headline. Флаг читают IMV-blend (`anchor_tier is None`, ниже), quarter-index
# #764 Guard-1a, radius-floor от ДКП-коридора, corridor-clamp (Tier A exempt) и
# api_analog_tier — залипший флаг молча глушил их все, будто headline построил
# якорь. Замер: 154 из 996 сделок теряют tier-флаг этой правкой, но ценообразование
# у них не двигается (расхождение ровно в 0.000%) — чинится именно флаг.
if anchor is None:
anchor_tier = None
if anchor is not None:
# #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы.
# display-consistency fix: anchor["comps"] — ФИНАЛЬНЫЙ пост-MAD-clip пул
# (len == anchor["n"] == n_analogs ниже), НЕ исходный anchor_comps —
# иначе clip-отсечённый ценовой выброс (напр. кросс-пост ×4.5 дороже
# остальных) оставался бы среди показанных карточек, а count карточек
# расходился бы с заявленным n_analogs.
# .get() fallback — defensive: тестовые/внешние доубли _compute_same_
# building_anchor могут не нести "comps" (старый контракт); деградируем
# к пред-clip списку, а не падаем KeyError.
anchor_comps_used = anchor.get("comps") or anchor_comps
est_ppm2 = anchor["anchor_ppm2"]
# PREMIUM GUARDRAIL (hard).
floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - settings.estimate_sb_guardrail_tol)
if est_ppm2 < floor_ppm2:
est_ppm2 = floor_ppm2
new_ppm2 = est_ppm2 * repair_coef
point = int(new_ppm2 * area_m2)
# FSD-диапазон.
half = settings.estimate_fsd_k * anchor["fsd"]
new_range_low = int(point * max(0.0, 1.0 - half))
new_range_high = int(point * (1.0 + half))
# Спред комплов.
spread_low = int(anchor["comp_min_ppm2"] * repair_coef * area_m2)
spread_high = int(anchor["comp_max_ppm2"] * repair_coef * area_m2)
new_range_low = min(new_range_low, spread_low, point)
new_range_high = max(new_range_high, spread_high, point)
logger.info(
"sb_anchor: tier=%s n=%d radius_median_ppm2=%d → anchor_asking_ppm2=%d"
" (uplift=%s haircut=%.2f) point %d%d",
anchor_tier,
anchor["n"],
int(median_ppm2),
int(est_ppm2),
anchor["used_uplift"],
anchor["haircut"],
median_price,
point,
)
median_ppm2 = new_ppm2
median_price = point
range_low = new_range_low
range_high = new_range_high
confidence = anchor["confidence"]
tier_label = "того же дома" if anchor_tier == "A" else "ближайшего окружения (≤500 м)"
# #695: explanation описывает ИМЕННО якорные комплы.
explanation = (
f"Оценка построена по {anchor['n']} аналогам из {tier_label}"
f"{' (топ-уровень в доме)' if anchor['used_uplift'] else ''}."
) + repair_note
# #695 (QA fixup): n_analogs по anchor-популяции.
n_analogs = anchor["n"]
# #2043 (BE-1): headline построен на комплах якоря → CV тоже по ним.
cv = anchor["cv"]
# #1871 P1: ghost-anchor guard.
if not listings_clean and confidence != "low":
logger.warning(
"estimator #1871 ghost-anchor guard: confidence %s→low "
"(anchor_n=%s, radius_analogs=0)",
confidence,
anchor["n"],
)
confidence = "low"
explanation += (
" Оценка опирается только на аналоги из того же дома — "
"сопоставимых предложений поблизости не найдено, поэтому "
"точность ориентировочная."
)
# #1871 P2: split-дома wide-corridor disclosure.
if anchor_tier == "A" and median_price > 0:
corridor_pct = (range_high - range_low) / median_price
if corridor_pct > settings.estimate_wide_corridor_threshold:
confidence = _downgrade_confidence(confidence)
explanation = (explanation or "") + (
" Очень широкий ценовой диапазон по дому (вероятно, дом "
"разбит на секции разной этажности или разнородный фонд) — "
"оценка ориентировочная, уточните по конкретной секции."
)
# ── #651: IMV / Yandex blend (Tier D only, anchor_tier is None) ──────────
imv_anchor_present: bool = False
avito_imv_summary: AvitoImvSummary | None = None
if anchor_tier is None and listings_clean and median_price > 0:
anchor_total: int | None = None
anchor_higher: int | None = None
anchor_label: str | None = None
anchor_repair_note: str = ""
if imv_anchor is not None and imv_anchor.get("recommended_price"):
anchor_total = int(imv_anchor["recommended_price"])
anchor_higher = (
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
)
# Публичная копия: клиент видит эту строку в confidence_explanation
# (HeroSummary), название площадки туда не должно утекать —
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
anchor_label = "оценочной модели площадки"
# `is not None`: market_count=0 — самый тонкий рынок, а не «неизвестно».
_imv_mc = (
int(imv_anchor["market_count"])
if imv_anchor.get("market_count") is not None
else None
)
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(
int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None
),
higher_price=anchor_higher,
market_count=_imv_mc,
thin_market=(
_imv_mc is not None and _imv_mc < settings.avito_imv_thin_market_threshold
),
)
# #2677: карточка выше показывает СЫРУЮ оценку Avito (это чужое число, мы его
# не правим), а в blend уходит якорь, приведённый к базису ремонта target'а
# тому же, в котором median_price уже посчитан выше (repair_coef).
_rep_k = _anchor_repair_factor(imv_anchor.get("renovation_type"), repair_state)
if _rep_k != 1.0:
anchor_total = round(anchor_total * _rep_k)
anchor_higher = round(anchor_higher * _rep_k) if anchor_higher else None
anchor_repair_note = ", приведённой к состоянию ремонта квартиры"
logger.info(
"imv_anchor repair-basis #2677: renovation=%s target_repair=%s k=%.3f"
" → anchor %d",
imv_anchor.get("renovation_type"),
repair_state,
_rep_k,
anchor_total,
)
elif imv_eval is not None and imv_eval.recommended_price:
anchor_total = int(imv_eval.recommended_price)
anchor_higher = int(imv_eval.higher_price) if imv_eval.higher_price else None
# Публичная копия: клиент видит эту строку в confidence_explanation
# (HeroSummary), название площадки туда не должно утекать —
# та же норма, что publicLabel в source-registry.ts (решение 31.08.2026).
anchor_label = "оценочной модели площадки"
avito_imv_summary = AvitoImvSummary(
recommended_price=anchor_total,
lower_price=(int(imv_eval.lower_price) if imv_eval.lower_price else None),
higher_price=anchor_higher,
market_count=imv_eval.market_count,
thin_market=(
imv_eval.market_count is not None
and imv_eval.market_count < settings.avito_imv_thin_market_threshold
),
)
# #audit-5b / #3323: thin-market warning. Раньше порог рождал ТОЛЬКО warning,
# а IMV всё равно двигал headline и растягивал range_high — теперь гейт в
# `_apply_imv_blend` отбрасывает якорь, и текст говорит именно это.
if avito_imv_summary is not None and avito_imv_summary.thin_market:
logger.warning(
"avito_imv thin_market #3323: market_count=%s"
" (< avito_imv_thin_market_threshold=%d) — IMV ОТБРОШЕН из денежного"
" пути (ни blend медианы, ни расширение range_high); остаётся"
" display-only в карточке avito_imv",
avito_imv_summary.market_count,
settings.avito_imv_thin_market_threshold,
)
if anchor_total is not None:
# #3323: на тонком рынке якорь отброшен гейтом ниже, значит и Guard-1b
# (#764, quarter-index) не должен глушить поправку «потому что якорь есть» —
# иначе headline не получит НИ ОДНОЙ поправки, и отброшенный IMV подвинет
# деньги вычитанием. avito_imv_summary здесь уже собран обеими ветками.
imv_anchor_present = (
not avito_imv_summary.thin_market if avito_imv_summary is not None else True
)
new_median, new_range_high, new_ppm2, blended, anchor_used = _apply_imv_blend(
median_price=median_price,
range_high=range_high,
median_ppm2=median_ppm2,
area=area_m2,
anchor_total=anchor_total,
anchor_higher=anchor_higher,
weight=settings.estimate_imv_blend_weight,
threshold=settings.estimate_imv_blend_threshold,
market_count=(
avito_imv_summary.market_count if avito_imv_summary is not None else None
),
thin_market_threshold=settings.avito_imv_thin_market_threshold,
)
if blended:
logger.info(
"imv_blend: median %d%d (anchor=%d w=%.2f) range_high %d%d",
median_price,
new_median,
anchor_used,
settings.estimate_imv_blend_weight,
range_high,
new_range_high,
)
median_price = new_median
median_ppm2 = new_ppm2
explanation = (explanation or "") + (
f" Оценка скорректирована по {anchor_label}"
f"{anchor_repair_note} ({anchor_used / 1_000_000:.1f} млн ₽)."
)
sources_used_pre = sorted(set(sources_used_pre) | {"avito_imv"})
# Диапазон расширяем даже если медиану не двигали.
range_high = new_range_high
# Display-only IMV summary — когда карточку не заполнил blend выше.
# #2661: условие было `anchor_tier is not None and ...`. После сброса залипшего
# флага оставалась щель: комплы якоря добыты (тир был "C"), якорь не построен, а
# headline подавлен/нулевой → blend не срабатывает (ему нужны listings_clean и
# median_price > 0), старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ
# карточку IMV, которую видел раньше. Гейт по tier снят: `avito_imv_summary is
# None` уже гарантирует, что двойного заполнения не будет. Display-only —
# median/expected_sold/ranges блок не трогает.
if avito_imv_summary is None:
if imv_anchor is not None and imv_anchor.get("recommended_price"):
_disp_mc = (
int(imv_anchor["market_count"])
if imv_anchor.get("market_count") is not None
else None
)
avito_imv_summary = AvitoImvSummary(
recommended_price=int(imv_anchor["recommended_price"]),
lower_price=(
int(imv_anchor["lower_price"]) if imv_anchor.get("lower_price") else None
),
higher_price=(
int(imv_anchor["higher_price"]) if imv_anchor.get("higher_price") else None
),
market_count=_disp_mc,
thin_market=(
_disp_mc is not None and _disp_mc < settings.avito_imv_thin_market_threshold
),
)
# ── #764: per-cadastral-quarter price index gap-correction ───────────────
if (
anchor_tier is None # Guard-1a
and not imv_anchor_present # Guard-1b
and median_price > 0
and area_m2
):
# target_house_cadnum: свой кадастр цели (DaData) ИЛИ квартал, снятый по
# координатам цели (_lookup_target_quarter_by_coords, вызывающий код).
# Прежде здесь был фолбэк «взять кадастр первого попавшегося аналога» —
# снят: dadata.house_cadnum пуст в 15 из 15 прод-применений, то есть квартал
# цели ВСЕГДА приходил от соседа (замер 2026-08-12: аналог-донор в 201-4087 м
# от цели; индексы кварталов на такой дистанции расходятся в 1.15-1.23 раза
# по медиане и в 1.6-1.8 по p90 — то есть множитель на витрине был чужой).
# Нет своего квартала → поправки нет; факт «квартал цели неизвестен» нельзя
# заменить чужим кварталом, он и есть предмет поправки.
target_quarter: str | None = _quarter_from_cadastre(target_house_cadnum)
if target_quarter is not None:
qindex_result = quarter_index_lookup(target_quarter)
if qindex_result is not None:
target_qi, target_n_deals = qindex_result
# Bimodal/nominal guard (Guard-4).
if (
target_qi > settings.estimate_quarter_index_max_for_small_n
and target_n_deals < settings.estimate_quarter_index_small_n_threshold
):
logger.info(
"quarter_index: bimodal guard triggered "
"(index=%.3f n=%d < %d) for %s — no-op",
target_qi,
target_n_deals,
settings.estimate_quarter_index_small_n_threshold,
target_quarter,
)
else:
lot_quarters_for_guard2: list[str] = []
analog_quarters: list[tuple[str, float]] = []
for lot in listings_clean:
lq = _quarter_from_cadastre(lot.get("building_cadastral_number"))
if lq is None:
continue
lot_quarters_for_guard2.append(lq)
lp = lot.get("price_per_m2")
if lp:
analog_quarters.append((lq, float(lp)))
# Guard-2: same-quarter ratio.
same_quarter_count = sum(
1 for lq in lot_quarters_for_guard2 if lq == target_quarter
)
# #1385: знаменатель — только классифицируемые аналоги.
same_quarter_ratio = (
same_quarter_count / len(lot_quarters_for_guard2)
if lot_quarters_for_guard2
else 0.0
)
if same_quarter_ratio > settings.estimate_quarter_match_skip_ratio:
logger.info(
"quarter_index: Guard-2 skip (same-quarter ratio=%.2f > %.2f) for %s",
same_quarter_ratio,
settings.estimate_quarter_match_skip_ratio,
target_quarter,
)
else:
distinct_analog_quarters = list(
dict.fromkeys(lq for lq, _lp in analog_quarters)
)
analog_index_map = quarter_indexes_lookup(distinct_analog_quarters)
weighted_sum = 0.0
weight_total = 0.0
for lq, lp in analog_quarters:
lot_qi = analog_index_map.get(lq)
if lot_qi is None:
continue
weighted_sum += lp * lot_qi
weight_total += lp
# Guard-5 (C3, #2583): без единого проиндексированного аналога
# уровень аналогов НЕИЗВЕСТЕН. Прежний фолбэк avg=1.0 подставлял
# сюда не «нейтраль», а МЕДИАНУ ЕКАТЕРИНБУРГА: price_index
# нормирован на неё (99a_quarter_price_index.sql), так что 1.0
# читается как «аналоги стоят как ЕКБ». Для цели вне ЕКБ это
# превращало gap-коррекцию в безусловную скидку до уровня ЕКБ
# (индексы области 0.280.82 → factor 0.6 после клампа, 40%).
# Нет данных → нет поправки: factor=1.0 эквивалентен
# avg := target_qi, а это на проде и есть лучшая оценка
# неизвестного avg (медиана |ошибки| 0.116 против 0.161 у 1.0,
# p90 0.337 против 0.517; 400 лотов, замер 2026-08-12).
if weight_total <= 0:
logger.info(
"quarter_index: Guard-5 skip — ни один аналог не имеет"
" квартального индекса (target=%s target_qi=%.3f"
" analogs_with_quarter=%d)",
target_quarter,
target_qi,
len(analog_quarters),
)
else:
avg_analog_index = weighted_sum / weight_total
(
median_ppm2,
median_price,
range_low,
range_high,
qi_factor,
) = _apply_quarter_index(
base_median_ppm2=median_ppm2,
base_median_price=median_price,
base_range_low=range_low,
base_range_high=range_high,
target_index=target_qi,
avg_analog_index=avg_analog_index,
min_factor=settings.estimate_quarter_index_factor_min,
max_factor=settings.estimate_quarter_index_factor_max,
)
analogs_with_qi = sum(
1 for lq, _lp in analog_quarters if lq in analog_index_map
)
logger.info(
"quarter_index: applied target=%s target_qi=%.3f"
" avg_analog_qi=%.3f factor=%.3f"
" (same_quarter_ratio=%.2f analogs_with_qi=%d)",
target_quarter,
target_qi,
avg_analog_index,
qi_factor,
same_quarter_ratio,
analogs_with_qi,
)
explanation = (explanation or "") + (
f" Учтена локация квартала (индекс цен квартала ×{qi_factor:.2f})."
)
sources_used_pre = sorted(set(sources_used_pre) | {"quarter_index"})
# ── #1795 шаг 1: soft-кламп headline к коридору ДКП-сделок ──────────────
slack = settings.estimate_corridor_clamp_slack
if dkp_raw is not None and median_ppm2 > 0:
old_ppm2 = median_ppm2
median_ppm2, median_price, range_low, range_high, clamped = _apply_corridor_clamp(
median_ppm2=median_ppm2,
median_price=median_price,
range_low=range_low,
range_high=range_high,
corridor_high_ppm2=dkp_raw["high_ppm2"],
corridor_count=dkp_raw["count"],
anchor_tier=anchor_tier,
slack=slack,
min_n=settings.estimate_corridor_clamp_min_n,
)
if clamped:
logger.info(
"corridor clamp #1795: headline %d%d ₽/м² (corridor_high=%d ×(1+%.2f),"
" count=%d, tier=%s)",
int(old_ppm2),
int(median_ppm2),
dkp_raw["high_ppm2"],
slack,
dkp_raw["count"],
anchor_tier,
)
explanation = (explanation or "") + (
" Оценка ограничена коридором реальных сделок Росреестра по улице."
)
# ── Radius-path нижний floor от DKP-коридора ─────────────────────────────
if (
anchor_tier is None
and dkp_raw is not None
and dkp_raw.get("low_ppm2", 0) > 0
and median_ppm2 > 0
and dkp_raw.get("count", 0) >= settings.estimate_corridor_clamp_min_n
):
radius_floor_ppm2 = float(dkp_raw["low_ppm2"]) * settings.estimate_radius_floor_factor
if median_ppm2 < radius_floor_ppm2:
floor_factor = radius_floor_ppm2 / median_ppm2
logger.info(
"radius_floor: median_ppm2=%d < dkp_low=%d × factor=%.2f = floor=%d"
" → lifting (factor=%.3f)",
int(median_ppm2),
dkp_raw["low_ppm2"],
settings.estimate_radius_floor_factor,
int(radius_floor_ppm2),
floor_factor,
)
median_ppm2 = radius_floor_ppm2
median_price = round(median_price * floor_factor)
range_low = round(range_low * floor_factor)
range_high = round(range_high * floor_factor)
explanation = (explanation or "") + (
" Оценка поднята до нижней границы коридора реальных сделок Росреестра."
)
# 4c (cont.). expected_sold AFTER all headline mutations.
if median_ppm2 > 0:
asking_to_sold_ratio, ratio_basis = ratio_resolver(median_ppm2)
if asking_to_sold_ratio is None:
ratio_basis = None
if asking_to_sold_ratio is not None and median_price > 0:
effective_ratio = asking_to_sold_ratio
if settings.estimate_expected_sold_le_asking and effective_ratio > 1.0:
logger.info(
"expected_sold ratio clamped %.3f->1.0 (rooms=%s)",
effective_ratio,
rooms,
)
effective_ratio = 1.0
expected_sold_per_m2 = round(median_ppm2 * effective_ratio)
expected_sold_price = round(median_price * effective_ratio)
# #2002: hedonic year+area correction на ТОЧКУ expected_sold. Fit
# log(actual_sold/expected_sold) ~ year + ln(area) по 2366 прод-сделкам:
# factor = exp(b0 + b_year*(year-2000)/20 + b_larea*ln(area)), clamp [min,max].
# Применяется ДО калиброванного PI-блока ниже, чтобы диапазон считался
# вокруг скорректированной точки. OFF ⇒ точно старое expected_sold.
if (
settings.estimate_hedonic_correction_enabled
and expected_sold_price
and area_m2
and area_m2 > 0
):
_yr = ((target_year - 2000) / 20.0) if target_year else 0.0
# #2002: ground-floor (floor==1) term — engine overestimates 1st-floor
# units ~12%; floor is int|None, so floor==1 leaves None/other floors
# byte-identical to the prior 2-term factor.
_first = settings.estimate_hedonic_first_floor_coef if floor == 1 else 0.0
_factor = math.exp(
settings.estimate_hedonic_b0
+ settings.estimate_hedonic_year_coef * _yr
+ settings.estimate_hedonic_larea_coef * math.log(area_m2)
+ _first
)
_factor = max(
settings.estimate_hedonic_factor_min,
min(settings.estimate_hedonic_factor_max, _factor),
)
if expected_sold_per_m2:
expected_sold_per_m2 = round(expected_sold_per_m2 * _factor)
expected_sold_price = round(expected_sold_price * _factor)
# #2002: re-assert the le_asking invariant — the hedonic factor (≤1.30) can
# push expected_sold above the asking headline (median) for new/small lots,
# which is an overpay risk for trade-in. Re-clamp the corrected point back to
# the headline so the calibrated PI range below wraps the clamped point.
# round(median_ppm2) keeps expected_sold_per_m2 an int (median_ppm2 is float).
if settings.estimate_expected_sold_le_asking and expected_sold_price:
expected_sold_price = min(expected_sold_price, median_price)
if expected_sold_per_m2:
expected_sold_per_m2 = min(expected_sold_per_m2, round(median_ppm2))
if expected_sold_price:
# #1966: калиброванный ~80% prediction interval вокруг ТОЧКИ expected_sold.
# Эмпирически отношение actual_sold_ppm2 / expected_sold_per_m2 по 2366
# прод-сделкам: p10=0.649, p90=1.392 → band [×low_mult, ×high_mult] даёт
# ~80% honest coverage. Старый IQR-производный band (asking-IQR × ratio)
# покрывал лишь ~55% реальных продаж при заявленном «диапазоне оценки».
# Множители low<1<high (дефолты) гарантируют low ≤ point ≤ high.
expected_sold_range_low = round(expected_sold_price * settings.estimate_pi_low_mult)
expected_sold_range_high = round(expected_sold_price * settings.estimate_pi_high_mult)
else:
# legacy: диапазон производный от IQR аналогов (asking-IQR × ratio).
expected_sold_range_low = round(range_low * effective_ratio)
expected_sold_range_high = round(range_high * effective_ratio)
# #2141: честный asking_to_sold_ratio (дескриптор бейджа «N% к рынку»).
# ratio_resolver отдаёт СЫРОЙ per-rooms/tier дисконт, но ФАКТИЧЕСКИЙ
# expected_sold сдвинут относительно median×ratio: hedonic-коррекция (#2002,
# год+площадь, factor ∈ [0.75, 1.30]), le_asking-clamp, corridor-clamp. Тогда
# сохранённый сырой ratio ≠ expected_sold/headline, и web/PDF-бейдж
# round((1ratio)×100) систематически врёт (напр. stored 0.771 при факте 0.945
# → «23%» вместо честных «5%»). Пересчитываем ratio как реальное
# expected_sold_price/median_price — дескриптор становится честным, а сам
# expected_sold (выкуп) НЕ трогаем: hedonic-uplift остаётся прибит к sale-модели.
# Нет сдвига (expected == median×ratio, hedonic OFF) → табличный ratio байт-в-байт.
#
# #2087 M2 (донат-виджет «N% к рынку» расходился с показанными ценами):
# тот же корень, что #2141 выше — закрывается ЭТИМ recompute'ом, т.к. он
# безусловный (выполняется всегда, когда expected_sold_price задан) и стоит
# ПОСЛЕ anchor/IMV-blend/quarter-index/corridor-clamp/radius-floor (все они
# мутируют median_price ДО этой точки, см. "AFTER all headline mutations"
# выше). #2255 segment-multiplier ниже — единственная мутация ПОСЛЕ этого
# recompute'а — умножает median_price И expected_sold_price ОДНИМ и тем же
# factor, так что их отношение (= сохранённый ratio) инвариантно с точностью
# до независимого round() на каждой стороне (см.
# test_honest_ratio_invariant_under_segment_multiplier).
if expected_sold_price and expected_sold_price > 0:
honest_ratio = expected_sold_price / median_price
if abs(honest_ratio - asking_to_sold_ratio) > _RATIO_DESCRIPTOR_EPS:
asking_to_sold_ratio = honest_ratio
# LOW audit #2: once honest_ratio overwrites the raw per_rooms/
# global_fallback ratio, `ratio_basis` (still the pre-shift
# ratio_resolver provenance) mislabels the value's origin. Relabel
# to an honest, self-describing basis so consumers (API/PDF) don't
# attribute expected_over_median math to a stale per-rooms lookup.
ratio_basis = "expected_over_median"
# ── #oblast-D: deals-headline-fallback ───────────────────────────────────
# When the radius/anchor analog pipeline found NOTHING usable (median_ppm2
# is still 0 here — happens for non-EKB towns where scraped `listings`
# coverage is ~0; see the Part-1 geo-bound fix in the Tier S fallback
# query above) but we DO have a ДКП deal corridor (dkp_raw — city+street
# scoped via _resolve_target_city/_fetch_dkp_corridor, widened to
# city-wide when the street sample is too thin, see _fetch_dkp_corridor),
# build the headline FROM THE DEAL SIGNAL instead of surfacing an empty
# ("n/a") estimate — or, pre-Part-1, a distant EKB-leaked listing median.
#
# Placed AFTER the expected_sold block above (guarded by the SAME
# `median_ppm2 <= 0` state it ran in) so ratio_resolver/hedonic/le_asking
# never touch this value: dkp_raw's ppm² are ALREADY sold prices — running
# them through the asking→sold ratio would double-discount. expected_sold_*
# stays None (an existing, already-supported state — see the Prediction
# docstring in scripts/backtest_estimator.py: "may be None when the spine
# produced a headline but no asking→sold ratio resolved").
#
# n_analogs stays 0 (true — zero scraped-listing analogs), so
# _enforce_zero_analog_low (estimate_quality, downstream) forces
# confidence 'low' regardless — we set it explicitly here too so the
# explanation text stays coherent with the actual reason (deals-only, not
# generic ghost-anchor). No repair_state adjustment: the deal corridor
# mixes conditions across sold units — unlike the listings comp pool,
# there is no per-unit signal to correct against.
#
# #oblast-E: guards on `anchor is None` (the actual computed anchor dict),
# NOT `anchor_tier is None`. Found via live backtest-fixture regen: when
# `_compute_same_building_anchor` rejects a candidate outright (e.g. its
# own MAD-clip drops comps below estimate_sb_min_comps), it returns None
# WITHOUT the caller resetting `anchor_tier` back to None (it stays
# whatever `anchor_tier_fetched` was, e.g. "C") — the anchor never fired,
# but the stale tier flag falsely reads as "anchor claimed the headline"
# and blocked this fallback even with a large, valid ДКП corridor
# available (observed: 677 deals for one fixture case). `anchor is None`
# is the ground truth of whether the anchor actually produced a headline.
# #2661 update: тот залипший флаг теперь сбрасывается у источника (см. `if
# anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия
# стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это
# по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг.
if (
median_ppm2 <= 0
and anchor is None
and dkp_raw is not None
and dkp_raw.get("count", 0) >= DEALS_HEADLINE_FALLBACK_MIN_N
and dkp_raw.get("median_ppm2", 0) > 0
):
median_ppm2 = float(dkp_raw["median_ppm2"])
median_price = int(median_ppm2 * area_m2)
range_low = int(dkp_raw["low_ppm2"] * area_m2)
range_high = int(dkp_raw["high_ppm2"] * area_m2)
n_analogs = 0
confidence = "low"
cv = None
# #oblast-E (priority restored 2026-08-10): differentiate "genuinely
# zero listings" from "found some but below HEADLINE_LISTINGS_MIN_N,
# ceded to the deals corridor" (gate above, case i) — the latter must
# NOT claim "рядом нет объявлений" (false — some WERE found, just not
# trusted as headline on their own). `deals_headline_due_to_thin_
# listings` (returned on PricingResult) tells estimate_quality() this
# was the thin-cession path specifically, so it can (a) append the
# "оценка по сделкам — мало объявлений рядом" relaxation label, (b)
# cap reliability at 'low' — a real deals corridor is a real signal,
# just not a listings-comp one.
no_listings_clause = (
f" Из {listings_headline_thin_n} найденных объявлений недостаточно для "
"надёжной медианы —"
if listings_headline_thin_n
else " Рядом нет актуальных объявлений —"
)
explanation = (explanation or "") + (
f"{no_listings_clause} оценка построена по реальным "
f"сделкам Росреестра ({dkp_raw['count']} шт. за {dkp_raw['period_months']} мес.),"
" точность ориентировочная."
)
if listings_headline_thin_n:
deals_headline_due_to_thin_listings = True
logger.info(
"deals_headline_fallback #oblast-D: dkp median=%d (n=%d) → headline"
" (listings=0 [thin_ceded=%d], anchor=None)",
int(median_ppm2),
dkp_raw["count"],
listings_headline_thin_n,
)
# ── #652: ДКП-коридор реальных сделок (advisory) ─────────────────────────
dkp_corridor: DkpCorridor | None = None
if dkp_raw is not None:
dkp_corridor = DkpCorridor(**dkp_raw)
if median_ppm2 and dkp_raw["count"] >= 3:
if median_ppm2 > dkp_raw["high_ppm2"] * (1.0 + slack):
explanation = (explanation or "") + (
" Оценка выше коридора реальных сделок Росреестра по улице."
)
elif median_ppm2 < dkp_raw["low_ppm2"] * (1.0 - slack):
explanation = (explanation or "") + (
" Оценка ниже коридора реальных сделок Росреестра по улице."
)
# ── #693: coarse-geo downgrade ───────────────────────────────────────────
geo_coarse = _geocode_is_coarse(geo)
if (dadata_coarse or geo_coarse) and median_price > 0:
logger.info(
"coarse-geo gate #693: dadata_coarse=%s geo_coarse=%s anchor_tier=%s "
"median=%s geo.provider=%s geo.confidence=%s geo.full_address=%r geo=(%.5f,%.5f)",
dadata_coarse,
geo_coarse,
anchor_tier,
median_price,
geo.provider,
geo.confidence,
geo.full_address,
geo.lat,
geo.lon,
)
if (dadata_coarse or geo_coarse) and anchor_tier != "A" and median_price > 0:
if dadata_coarse:
_coarse_label = {2: "населённого пункта", 3: "города", 4: "региона"}.get(
dadata_qc_geo, "населённого пункта"
)
else:
_coarse_label = "населённого пункта"
confidence = "low"
explanation = (explanation or "") + (
f" Адрес определён лишь до уровня {_coarse_label} — точные координаты "
"дома найти не удалось, поэтому оценка ориентировочная (аналоги взяты "
"по широкой окрестности)."
)
# ── #2255: сегментная поправка по ценовому бэнду — СРАЗУ ПЕРЕД min-width
# floor, ПОСЛЕ IMV-blend / corridor-clamp / quarter-index / hedonic / PI
# (все ценовые мутации точки уже отработали → бэнд считается по финальному
# median_ppm2). Множитель к median_price/median_ppm2 и пропорционально к
# range_low/range_high. Флаг OFF ⇒ no-op (путь байт-в-байт идентичен).
#
# Тот же factor применяется ДВУМЯ точками — к asking-headline (здесь) И к
# expected_sold (выкуп) ниже. Причина: expected_sold дерайвится выше по
# цепочке (median×ratio + hedonic/le_asking/PI, ~стр. 2595-2651), т.е. ДО
# этой поправки → он уже заморожен и не «подхватит» умноженный median сам.
# Acceptance #2255 требует сжатия per-segment bias именно в бэктесте, а
# бэктест скорит expected_sold — значит поправка ОБЯЗАНА доехать до выкупа,
# иначе оффер клиенту в бизнес/элит остаётся заниженным. Один общий factor
# держит asking↔выкуп геометрически консистентными: honest_ratio
# (expected/median) инвариантен к общему множителю (бейдж «N%» не врёт), и
# инвариант «выкуп ≤ headline» сохраняется (обе стороны ×один factor).
if settings.estimate_segment_multiplier_enabled:
(
median_ppm2,
median_price,
range_low,
range_high,
_seg_band,
_seg_factor,
) = _apply_segment_multiplier(
median_price=median_price,
median_ppm2=median_ppm2,
range_low=range_low,
range_high=range_high,
enabled=settings.estimate_segment_multiplier_enabled,
multipliers=settings.estimate_segment_multipliers,
)
if _seg_band is not None:
sources_used_pre = sorted(set(sources_used_pre) | {"segment_multiplier"})
# Догоняем той же пропорцией уже-дерайвнутый expected_sold (выкуп).
if expected_sold_per_m2 is not None:
expected_sold_per_m2 = round(expected_sold_per_m2 * _seg_factor)
if expected_sold_price is not None:
expected_sold_price = round(expected_sold_price * _seg_factor)
if expected_sold_range_low is not None:
expected_sold_range_low = round(expected_sold_range_low * _seg_factor)
if expected_sold_range_high is not None:
expected_sold_range_high = round(expected_sold_range_high * _seg_factor)
logger.info(
"segment_mult: expected_sold band=%s ×%.3f price→%s per_m2→%s",
_seg_band,
_seg_factor,
expected_sold_price,
expected_sold_per_m2,
)
# ── #2209: min-width floor — ПОСЛЕДНИМ, после всех мутаций (anchor / IMV blend
# / quarter-index / corridor-clamp / radius-floor / hedonic PI / segment-mult),
# чтобы никакая последующая мутация не сузила диапазон обратно. Floor только
# расширяет и не трогает точку (median_price / expected_sold_price). Вырожденный
# n=1 asking-диапазон (Q1==Q3) перестаёт быть точкой с ложной точностью.
range_low, range_high = _apply_range_floor(range_low, range_high, median_price)
if expected_sold_range_low is not None and expected_sold_range_high is not None:
expected_sold_range_low, expected_sold_range_high = _apply_range_floor(
expected_sold_range_low,
expected_sold_range_high,
expected_sold_price if expected_sold_price is not None else 0,
)
return PricingResult(
median_ppm2=median_ppm2,
median_price=median_price,
range_low=range_low,
range_high=range_high,
n_analogs=n_analogs,
confidence=confidence,
explanation=explanation,
anchor_tier=anchor_tier,
anchor_comps_used=anchor_comps_used,
avito_imv_summary=avito_imv_summary,
dkp_corridor=dkp_corridor,
expected_sold_per_m2=expected_sold_per_m2,
expected_sold_price=expected_sold_price,
expected_sold_range_low=expected_sold_range_low,
expected_sold_range_high=expected_sold_range_high,
asking_to_sold_ratio=asking_to_sold_ratio,
ratio_basis=ratio_basis,
sources_used_pre=sources_used_pre,
listings_clean=listings_clean,
cv=cv,
listings_headline_thin_n=listings_headline_thin_n,
deals_headline_due_to_thin_listings=deals_headline_due_to_thin_listings,
)
# ── Public ───────────────────────────────────────────────────────────────────
async def estimate_quality(
payload: TradeInEstimateInput,
db: Session,
created_by: str | None = None,
client_ip: str | None = None,
*,
require_consent: bool = False,
) -> AggregatedEstimate:
"""Главная функция — оценка квартиры по реальным данным.
PR M / #564 Phase 3: rosreestr_deals **included** в actual_deals output.
Stale NOTE 2026-05-24 (про ДДУ contamination) устарел — importer
`import-rosreestr.sh` после PR-A 2026-05-24 фильтрует doc_type='ДКП',
ДДУ первички исключены. Deals идут в `actual_deals` JSONB поле
AggregatedEstimate с tier classification (T0_per_house / T1_per_street)
— frontend может разделять confidence в UI.
Args:
require_consent: ЭТАП 4 B2C consent-before-save gate (keyword-only,
дефолт False — см. _ESTIMATE_CONSENT_* комментарий выше про то,
почему это НЕ выводится из created_by is None). Единственный
реальный вызывающий — app/api/v1/trade_in.py::estimate() — передаёт
True, когда нет X-Authenticated-User (анонимный запрос).
Returns:
AggregatedEstimate с estimate_id, медианой, диапазоном, аналогами.
Raises:
HTTPException(422): require_consent=True без payload.consent=True.
Проверка стоит ПЕРВОЙ строкой тела функции, ДО geocode() и ДО
обоих мест, где адрес попадает в trade_in_estimates (главный
INSERT ниже И _empty_estimate fallback) — адрес физлица не должен
попасть в БД раньше согласия ни при каком исходе оценки.
"""
# ЭТАП 4 B2C launch (152-ФЗ) — consent-before-save. require_consent=False
# (дефолт, весь B2B-путь и все внутренние/тестовые вызовы) — эта проверка
# их не касается. require_consent=True (реально анонимный HTTP-запрос,
# см. Args выше) обязан нести явное согласие ПРЕЖДЕ, чем estimate_id будет
# сгенерирован и адрес уйдёт в БД.
if require_consent and not payload.consent:
raise HTTPException(
status_code=422,
detail="consent required for anonymous estimate request",
)
# 1. Geocode (#654: time-budgeted — Nominatim retry chain can stack
# multiple network round-trips + 1s Nominatim rate-limit sleeps).
geo: GeocodeResult | None = None
# Variant A: trust client-provided coords (resolved by autocomplete/map) when present
# and inside the oblast bbox — skips the geocode() chain that fails on DaData-format
# addresses (#2593: Yandex Geocoder, the previous fallback for those, removed).
# Out-of-bbox / partial → ignore, geocode normally.
# (oblast C2): was tight EKB-only bbox (60.40-60.85 / 56.65-56.95) — widened to
# geocoder.is_within_oblast66_bbox (region 66) so client-coords from oblast towns also
# get this perf fast-path instead of always paying the geocode() round-trip. Perf-only,
# not a correctness gate — narrower fast-path just meant more oblast requests fell
# through to geocode() (still correct, just slower).
if (
payload.lat is not None
and payload.lon is not None
and is_within_oblast66_bbox(payload.lat, payload.lon)
):
geo = GeocodeResult(
lat=payload.lat,
lon=payload.lon,
full_address=payload.address,
provider="cache",
confidence="exact",
)
logger.info(
"estimate: using client coords (%.5f, %.5f) — skipping geocode",
payload.lat,
payload.lon,
)
if geo is None and payload.address:
# #2576: city_hint прокидывается из payload — БЕЗ него geocode() больше не
# подставляет "Екатеринбург" молча (см. app.services.geocoder). Опционально:
# фронт пока (до отдельного изменения UI) его не шлёт, geo.city_ambiguous
# честно сигнализирует об этом ниже.
geo = await _with_budget(
geocode(payload.address, db, city_hint=payload.city_hint),
settings.estimate_geocode_budget_s,
label="geocode",
)
if geo is None:
# Без координат не можем искать через PostGIS. Возвращаем low confidence.
logger.warning("geocode failed for %s — returning low-confidence estimate", payload.address)
return await asyncio.to_thread(
_empty_estimate,
payload,
db,
reason="address_not_geocoded",
created_by=created_by,
client_ip=client_ip,
require_consent=require_consent,
)
# 1b. DaData enrichment (PR Q1) — on-demand cleanup для target адреса.
# Best-effort: graceful None при отсутствии credentials / quota / fail.
# Дополняет geocode результатом kadastr_num + canonical form + nearest metro.
dadata: DadataAddressResult | None = None
try:
dadata = await dadata_clean_address(payload.address)
except Exception as exc: # pragma: no cover — defensive
logger.warning("dadata: unexpected error (graceful): %s", exc)
# 1c. #6 House-match: резолвим target в КАНОНИЧЕСКИЙ house_id (read-only, без
# создания записи). Это даёт детерминированный Tier S «тот же дом» через
# listings.house_id_fk (99% покрытие), точнее хрупкого address-string match.
# cadastr от DaData → cadastr_exact tier заработает по мере backfill houses.
# Best-effort: None при любой ошибке, estimator продолжает на гео-tier'ах.
target_house_id: int | None = None
# ФИАС как first-class ключ: приоритет клиентскому target_fias_id (разрешён
# автокомплитом suggest), иначе fias из DaData /clean. Прокидываем в Tier 0.5
# fias_exact матчера — самый стабильный ключ дома, точнее адрес-строки.
dadata_fias = dadata.house_fias_id if dadata else None
match_fias = payload.target_fias_id or dadata_fias
try:
match = await asyncio.to_thread(
match_house_readonly,
db,
address=(dadata.canonical_address if dadata else None) or geo.full_address,
lat=geo.lat,
lon=geo.lon,
cadastral_number=(dadata.house_cadnum if dadata else None),
house_fias_id=match_fias,
)
if match is not None:
target_house_id = match[0]
logger.info(
"estimate target → house_id=%s via %s (conf=%.2f)", match[0], match[2], match[1]
)
# Fill-null persist: если DaData вернул fias, а у канонического дома его
# ещё нет — дописываем (+ qc_geo / enriched_at, сохраняя существующие
# значения через COALESCE). Семантика scripts/backfill_houses_dadata.py.
# Guard `house_fias_id IS NULL` — никогда не перетираем curated id.
# SAVEPOINT изолирует сбой UPDATE от внешней estimate-транзакции.
if dadata_fias and dadata is not None:
def _backfill_house_fias() -> None:
with db.begin_nested():
db.execute(
text(
"UPDATE houses "
" SET house_fias_id = CAST(:fias AS text), "
" dadata_qc_geo = COALESCE(dadata_qc_geo, "
" CAST(:qc_geo AS integer)), "
" dadata_enriched_at = COALESCE(dadata_enriched_at, NOW()) "
" WHERE id = CAST(:hid AS bigint) "
" AND house_fias_id IS NULL"
),
{
"fias": dadata_fias,
"qc_geo": dadata.qc_geo,
"hid": target_house_id,
},
)
try:
await asyncio.to_thread(_backfill_house_fias)
except Exception as exc: # pragma: no cover — defensive
logger.warning("estimate fias back-fill failed (graceful): %s", exc)
except Exception as exc: # pragma: no cover — defensive
logger.warning("target house match failed (graceful): %s", exc)
# 2. #392: обогащаем год / тип дома из картографии (OSM Overpass), если
# пользователь их не указал — это улучшает house-match аналогов (#6).
# Best-effort: при недоступности OSM target_* остаются None.
# #654: time-budgeted — Overpass httpx timeout 15s сам по себе близок к
# gateway-таймауту; деградируем в None при превышении budget.
target_year = payload.year_built
target_house_type = payload.house_type
target_total_floors = payload.total_floors
# 2a. #3234: houses — наш справочник, приоритет ПЕРЕД OSM Overpass (year
# 86.8% / floors 98.3% покрытия на проде против 68 строк в
# house_metadata-кэше). payload остаётся главнее в любом случае — houses
# заполняет только то, что пользователь не указал.
if target_year is None or target_house_type is None or target_total_floors is None:
house_facts = await asyncio.to_thread(
_lookup_house_facts,
db,
target_house_id=target_house_id,
lat=geo.lat,
lon=geo.lon,
)
if house_facts is not None:
filled: list[str] = []
if target_year is None and house_facts.year_built is not None:
target_year = house_facts.year_built
filled.append(f"year_built={target_year}")
if target_house_type is None and house_facts.house_type is not None:
target_house_type = house_facts.house_type
filled.append(f"house_type={target_house_type}")
if target_total_floors is None and house_facts.total_floors is not None:
target_total_floors = house_facts.total_floors
filled.append(f"total_floors={target_total_floors}")
if filled:
logger.info(
"estimate: house facts fallback house_id=%s%s",
house_facts.house_id,
", ".join(filled),
)
if target_year is None or target_house_type is None or target_total_floors is None:
house_meta = await _with_budget(
get_house_metadata(geo.lat, geo.lon, db),
settings.estimate_house_meta_timeout_s,
label="house_metadata(overpass)",
)
if house_meta is not None:
if target_year is None:
target_year = house_meta.year_built
if target_house_type is None:
target_house_type = house_meta.house_type
if target_total_floors is None:
target_total_floors = house_meta.total_floors
# 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме,
# либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на
# «неизвестен» ДО того как target_year уйдёт в cohort-фильтр (ниже),
# _fetch_analogs house-match scoring и хедонический фактор
# (_price_from_inputs, #2002). Единая точка входа — все три места ниже
# используют этот же target_year.
target_year = _sanitize_build_year(
target_year, house_id=target_house_id, address=payload.address
)
# 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter):
# 0) 1km + ±15% area + cohort match (year_built — если задан)
# a) 1km + ±15% area (без cohort — drop fallback)
# b) 2km + ±15% area (fallback_used = True)
# c) 2km + ±25% area (fallback_used = True, area_widened = True)
#
# #2044: опциональный override радиуса анализа (контрол РАДИУС). None →
# точное текущее поведение (DEFAULT 1000 м первичный, FALLBACK 2000 м
# расширение). Задан → и первичный, и fallback-поиск используют ровно этот
# радиус (он же — максимум, без авто-расширения за пределы выбранного).
base_radius_m = payload.radius_m or DEFAULT_RADIUS_M
fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M
# #2632: фактический радиус, по которому реально отобраны listings-аналоги
# (в отличие от payload.radius_m — выбор пользователя в дропдауне). Стартует
# с base_radius_m, переключается на fallback_radius_m в тех же ветках, что
# выставляют fallback_used ниже (см. _compute_confidence "расширили радиус"
# note) — держим оба сигнала консистентными по построению. Прокидывается в
# AggregatedEstimate.search_radius_m для карты (ParamsPanel circle, #2632).
search_radius_m = base_radius_m
cohort_range = _target_cohort_range(target_year)
if cohort_range is not None:
cy_min, cy_max = cohort_range
listings_tier0, _, analog_tier = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=base_radius_m,
full_address=geo.full_address,
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=target_total_floors,
cohort_year_min=cy_min,
cohort_year_max=cy_max,
)
else:
listings_tier0 = []
analog_tier = "W"
if len(listings_tier0) >= MIN_ANALOGS_TIER_0:
listings = listings_tier0
fallback_used = False
else:
# Tier 0 пуст/мал — graceful fallback на Tier A без cohort
listings, fallback_used, analog_tier = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=base_radius_m,
full_address=geo.full_address,
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=target_total_floors,
)
# #oblast-F: True only when there WAS a cohort (year_built) filter to drop —
# surfaced later as the "снят фильтр по году постройки" relaxation label.
cohort_dropped = cohort_range is not None and len(listings_tier0) < MIN_ANALOGS_TIER_0
area_widened = False
if len(listings) < 5:
listings_wide, _, analog_tier_wide = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=fallback_radius_m,
full_address=geo.full_address,
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=target_total_floors,
)
if len(listings_wide) > len(listings):
listings = listings_wide
fallback_used = True
analog_tier = analog_tier_wide
search_radius_m = fallback_radius_m
# Tier C: если даже на 2км мало — расширяем area tolerance до ±25%
# (актуально для отдалённых районов / новостроек с нестандартной планировкой)
if len(listings) < 3:
listings_widearea, _, analog_tier_wa = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=fallback_radius_m,
area_tolerance=0.25,
full_address=geo.full_address,
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=target_total_floors,
)
if len(listings_widearea) > len(listings):
listings = listings_widearea
fallback_used = True
area_widened = True
analog_tier = analog_tier_wa
search_radius_m = fallback_radius_m
# ── #oblast-F: relaxation cascade (never-block estimate, product decision
# 2026-08-10) ──────────────────────────────────────────────────────────
# Product requirement: NEVER refuse an estimate outright. If the 4-tier
# cascade above still leaves the sample thinner than HEADLINE_LISTINGS_MIN_N,
# keep loosening search criteria — least → most distorting — until either
# the sample clears the threshold or we run out of steps. Every step that
# ACTUALLY grew the sample is recorded in `relaxations` (RU labels, surfaced
# via AggregatedEstimate.relaxations + appended to confidence_explanation
# below) so a low-reliability estimate honestly explains why it stretched
# the search. Each step carries FORWARD the relaxations already applied by
# earlier steps (cumulative widening), not just its own single criterion.
relaxations: list[str] = []
if cohort_dropped:
relaxations.append("снят фильтр по году постройки")
cur_rooms_min: int | None = None
cur_rooms_max: int | None = None
cur_fresh_days = LISTINGS_FRESH_DAYS
cur_allow_novostroyki = False
cur_area_tolerance = 0.25 if area_widened else AREA_TOLERANCE
async def _try_relax(
*,
rooms_min: int | None,
rooms_max: int | None,
fresh_days: int,
allow_novostroyki: bool,
radius_m: int,
area_tolerance: float,
) -> tuple[list[dict[str, Any]], str] | None:
"""Один шаг каскада #oblast-F. Возвращает (listings, tier) только если
кандидат СТРОГО больше текущей выборки — иначе релаксация не засчитана
(ничего реально не выиграла) и вызывающий её не применяет."""
candidate, _, tier = await asyncio.to_thread(
_fetch_analogs,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
rooms_min=rooms_min,
rooms_max=rooms_max,
area=payload.area_m2,
radius_m=radius_m,
area_tolerance=area_tolerance,
fresh_days=fresh_days,
allow_novostroyki=allow_novostroyki,
full_address=geo.full_address,
target_house_id=target_house_id,
year_built=target_year,
house_type=target_house_type,
total_floors=target_total_floors,
)
if len(candidate) > len(listings):
return candidate, tier
return None
# (a) room-count adjacency — самое дешёвое искажение: студия↔1-комн для
# rooms<=1 (live repro: Академика Парина 46/5, rooms=1 давал n=4, rooms=0
# тем же адресом — n=34), иначе комнатность ±RELAX_ROOMS_ADJACENT_DELTA.
if len(listings) < HEADLINE_LISTINGS_MIN_N:
if payload.rooms <= 1:
try_rooms_min, try_rooms_max, rooms_label = 0, 1, "учтены студии"
else:
try_rooms_min = payload.rooms - RELAX_ROOMS_ADJACENT_DELTA
try_rooms_max = payload.rooms + RELAX_ROOMS_ADJACENT_DELTA
rooms_label = "комнатность ±1"
rooms_result = await _try_relax(
rooms_min=try_rooms_min,
rooms_max=try_rooms_max,
fresh_days=cur_fresh_days,
allow_novostroyki=cur_allow_novostroyki,
radius_m=search_radius_m,
area_tolerance=cur_area_tolerance,
)
if rooms_result is not None:
listings, analog_tier = rooms_result
cur_rooms_min, cur_rooms_max = try_rooms_min, try_rooms_max
relaxations.append(rooms_label)
# (b) свежесть объявлений: LISTINGS_FRESH_DAYS (14) → LISTINGS_FRESH_DAYS_RELAXED (60).
if len(listings) < HEADLINE_LISTINGS_MIN_N:
fresh_result = await _try_relax(
rooms_min=cur_rooms_min,
rooms_max=cur_rooms_max,
fresh_days=LISTINGS_FRESH_DAYS_RELAXED,
allow_novostroyki=cur_allow_novostroyki,
radius_m=search_radius_m,
area_tolerance=cur_area_tolerance,
)
if fresh_result is not None:
listings, analog_tier = fresh_result
cur_fresh_days = LISTINGS_FRESH_DAYS_RELAXED
relaxations.append("объявления за 60 дней")
# (c) снять guard listing_segment — допустить новостройки в comp-пул.
if len(listings) < HEADLINE_LISTINGS_MIN_N:
novo_result = await _try_relax(
rooms_min=cur_rooms_min,
rooms_max=cur_rooms_max,
fresh_days=cur_fresh_days,
allow_novostroyki=True,
radius_m=search_radius_m,
area_tolerance=cur_area_tolerance,
)
if novo_result is not None:
listings, analog_tier = novo_result
cur_allow_novostroyki = True
relaxations.append("учтены новостройки")
# (d) радиус → max(текущий, RELAX_RADIUS_STEP1_M), затем → RELAX_RADIUS_STEP2_M.
# Пропускается, когда пользователь явно зафиксировал radius_m — тот же
# контракт, что и у существующего radius-fallback выше (#2044: сервер не
# авто-расширяет поиск за пределы выбранного пользователем радиуса).
if len(listings) < HEADLINE_LISTINGS_MIN_N and payload.radius_m is None:
for relax_radius in (max(search_radius_m, RELAX_RADIUS_STEP1_M), RELAX_RADIUS_STEP2_M):
if relax_radius <= search_radius_m:
continue
radius_result = await _try_relax(
rooms_min=cur_rooms_min,
rooms_max=cur_rooms_max,
fresh_days=cur_fresh_days,
allow_novostroyki=cur_allow_novostroyki,
radius_m=relax_radius,
area_tolerance=cur_area_tolerance,
)
if radius_result is not None:
listings, analog_tier = radius_result
search_radius_m = relax_radius
fallback_used = True
if len(listings) >= HEADLINE_LISTINGS_MIN_N:
break
# Area/radius relaxations derived from FINAL state (covers both the
# pre-existing Tier B/C radius/area widening above AND step (d) here) —
# a single check avoids double-labelling the same underlying widening.
if area_widened:
relaxations.append("площадь ±25%")
if search_radius_m > base_radius_m:
relaxations.append(radius_relaxation_label(search_radius_m))
# ── PRE-FETCH: dkp_raw (hoisted before _price_from_inputs) ──────────────
# #1795: ДКП-коридор фетчим ДО вызова _price_from_inputs, чтобы
# corridor_high был доступен для Tier C-гейта и soft-клампа headline.
# (oblast C2): city-scope корридора — та же precedence, что и для
# match_house_readonly выше (dadata.canonical_address приоритетнее сырого
# geo.full_address, если DaData отработал).
# payload.address (пользовательский ввод) — САМЫЙ надёжный источник города:
# geocoder.full_address/canonical часто роняют город для не-ЕКБ («Ленина, 1»
# вместо «Нижний Тагил, Ленина, 1») → без этого city-scope коридора (C2) был
# бы no-op для всей области (verified прод: tc=None → corridor=None). Fallback
# на canonical/geo для bare-адресов без города.
target_city = _resolve_target_city(payload.address) or _resolve_target_city(
(dadata.canonical_address if dadata else None) or geo.full_address
)
dkp_raw = await asyncio.to_thread(
_fetch_dkp_corridor,
db,
address=payload.address,
rooms=payload.rooms,
area=payload.area_m2,
city=target_city,
)
# ── Stage 3: Avito IMV evaluation as 5-th source (on-demand cached) ──
imv_eval: IMVEvaluation | None = None
imv_house_type = _IMV_HOUSE_TYPE_MAP.get(target_house_type)
imv_renovation = _IMV_REPAIR_MAP.get(payload.repair_state)
if (
geo is not None
and geo.full_address
and payload.rooms is not None
and payload.area_m2
and payload.floor is not None
and payload.total_floors is not None
and imv_house_type is not None
and imv_renovation is not None
):
# #654/#b2c-antiabuse-2: единственный внешний вызов, ранее БЕЗ time-budget
# guard — evaluate_via_imv делает до 3 последовательных HTTP-запросов
# (warm-up + geocode + evaluate), каждый со своим таймаутом 25s
# (_HTTP_TIMEOUT_SEC в scraper_kit.providers.avito.imv), плюс возможен ОДИН
# internal retry с "очищенным" адресом на IMVAddressNotFoundError (см.
# _get_or_fetch_imv_cached) — необёрнутый worst-case доходил до ~150s.
# Оборачиваем так же, как соседние ungated-вызовы (geocode/house_meta/
# yandex_valuation/cian_valuation).
imv_eval = await _with_budget(
_get_or_fetch_imv_cached(
db,
address=geo.full_address,
rooms=payload.rooms,
area_m2=payload.area_m2,
floor=payload.floor,
floor_at_home=payload.total_floors,
house_type=imv_house_type,
renovation_type=imv_renovation,
has_balcony=bool(payload.has_balcony),
has_loggia=False,
),
settings.estimate_avito_imv_timeout_s,
label="avito_imv",
)
# ── Stage 8: Yandex Valuation as on-demand source (anonymous, cached 24h) ──
yandex_val: YandexValuationResult | None = None
if geo is not None and geo.full_address:
_y_addr, _y_house = geo.full_address, target_house_id
_bg = settings.estimate_external_sources_background
yandex_val = await _with_budget(
_get_or_fetch_yandex_valuation_cached(
db, address=_y_addr, house_id=_y_house, fetch_on_miss=not _bg
),
settings.estimate_yandex_valuation_timeout_s,
label="yandex_valuation",
)
if _bg and yandex_val is None:
# Промах кэша: ответ не ждёт ~6 с внешнего HTTP, догрузка уходит в фон
# и наполняет кэш к следующему обращению по этому адресу.
_defer_external_refresh(
"yandex_valuation",
lambda bg_db: _get_or_fetch_yandex_valuation_cached(
bg_db, address=_y_addr, house_id=_y_house
),
)
if yandex_val is not None:
saved_hist = await asyncio.to_thread(_save_yandex_history_items, db, yandex_val)
logger.info(
"yandex_valuation: history items processed=%d saved=%d (ext_val house_id=%s)",
len(yandex_val.history_items),
saved_hist,
target_house_id,
)
# ── Stage 9: Cian Valuation as 7th source (on-demand, 24h cached) ──────
cian_val: CianValuationResult | None = None
if (
geo is not None
and geo.full_address
and payload.rooms is not None
and payload.area_m2
and payload.floor is not None
and payload.total_floors is not None
):
_c_bg = settings.estimate_external_sources_background
_c_kwargs: dict[str, Any] = {
"config": RealScraperConfig(),
"address": geo.full_address,
"total_area": payload.area_m2,
"rooms_count": payload.rooms,
"floor": payload.floor,
"total_floors": payload.total_floors,
"repair_type": "cosmetic",
"deal_type": "sale",
"use_cache": True,
"house_id": target_house_id,
# #3398: без provider'а curl_proxy_url считает use_pool=False (флаг AND
# provider is not None) → env-прокси CIAN_PROXY_URL/SCRAPER_PROXY_URL, мёртвый
# узел (#2613) → «Cian valuation fetch failed: … 407». Lease живёт внутри
# curl_proxy_url: acquire до запроса, release в finally.
#
# Весь этот dict переиспользуется отложенной фоновой догрузкой ниже
# (`_defer_external_refresh` захватывает `_c_kwargs` замыканием), то есть
# `config` и `proxy_provider` — ОДИН инстанс на два вызова, которые могут идти
# одновременно (фон стартует после ответа, но живёт своей задачей). Корректно
# это ровно пока оба stateless: `RealScraperConfig` — read-only снимок настроек,
# `RealProxyProvider` не хранит полей вообще и открывает короткую сессию БД на
# каждую операцию (acquire/release/mark_health), поэтому lease'ы двух вызовов не
# пересекаются. Появится у любого из них per-вызов состояние (кэш lease'а,
# счётчик, открытая сессия) — фоновой задаче нужен СВОЙ инстанс, а не общий.
"proxy_provider": RealProxyProvider(),
}
try:
cian_val = await _with_budget(
estimate_via_cian_valuation(db, fetch_on_miss=not _c_bg, **_c_kwargs),
settings.estimate_cian_valuation_timeout_s,
label="cian_valuation",
)
if _c_bg and cian_val is None:
_defer_external_refresh(
"cian_valuation",
lambda bg_db: estimate_via_cian_valuation(bg_db, **_c_kwargs),
)
if cian_val is not None and cian_val.sale_price_rub:
logger.info(
"cian_valuation: price=%s accuracy=%s house_id=%s",
cian_val.sale_price_rub,
cian_val.sale_accuracy,
cian_val.external_house_id,
)
except Exception as e:
if caused_by_no_proxy(e):
# #3398: пустой пул поднимает NoProxyAvailableError ДО HTTP (curl_proxy_url),
# то есть это не сбой Циана. WARNING, не ERROR: в GlitchTip такое событие —
# шум. Оценка отдаётся без cian-источника, /estimate не 5xx.
logger.warning("cian_valuation: пул прокси пуст — продолжаем без Cian: %s", e)
else:
# logger.exception (не .warning) — намеренно: GlitchTip LoggingIntegration
# (main.py/scheduler_main.py) слушает event_level=logging.ERROR. WARNING,
# даже с exc_info=True, остаётся ниже порога и НЕ создаёт событие в GlitchTip
# (только breadcrumb) — config-wiring регрессия здесь была бы не видна
# мониторингу. .exception() логирует на ERROR + traceback (#2337).
logger.exception("cian_valuation: lookup failed (graceful)")
# ── Pre-fetch: same-building anchor comps ─────────────────────────────────
# Guard mirrors the original in-block guard exactly; when false → ([], None).
_anchor_comps_pre: list[dict]
_anchor_tier_pre: str | None
if payload.area_m2 and (payload.address or (geo is not None and geo.full_address)):
_anchor_comps_pre, _anchor_tier_pre = await asyncio.to_thread(
_fetch_anchor_comps,
db,
address=payload.address or geo.full_address,
target_house_id=target_house_id,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
)
else:
_anchor_comps_pre, _anchor_tier_pre = [], None
# ── Pre-fetch: house IMV anchor ONCE (used for both blend and display) ───
imv_anchor_data = await asyncio.to_thread(
_fetch_house_imv_anchor,
db,
target_house_id=target_house_id,
rooms=payload.rooms,
area=payload.area_m2,
)
# ── Coarse-geo signals ────────────────────────────────────────────────────
dadata_coarse = dadata is not None and dadata.qc_geo is not None and dadata.qc_geo >= 2
# ── #764: квартал ЦЕЛИ для квартального индекса ──────────────────────────
# Свой кадастр (DaData) — приоритет; пусто (15 из 15 применений на проде) →
# квартал по СОБСТВЕННЫМ координатам цели, ЕСЛИ включён флаг. Кадастр аналога
# сюда больше не попадает вообще (см. блок #764 в _price_from_inputs).
# Флаг dormant: точность резолва измерена (92% на 25 м), эффект поправки на
# точность ЦЕНЫ — нет (бэктест-фикстура его не проходит). См. config.
target_quarter_cadnum: str | None = dadata.house_cadnum if dadata else None
if (
settings.estimate_quarter_from_coords_enabled
and not target_quarter_cadnum
and geo is not None
):
target_quarter_cadnum = await asyncio.to_thread(
_lookup_target_quarter_by_coords, db, geo.lat, geo.lon
)
# ── DB-callable wrappers injected into pure pricing ───────────────────────
def _ratio_resolver(
appm2: float | None,
) -> tuple[float | None, str | None]:
# #2620-2: area-bucket key (payload.rooms — фолбэк только без площади).
return _get_asking_sold_ratio(db, payload.rooms, payload.area_m2, anchor_ppm2=appm2)
def _qi_lookup(q: str) -> tuple[float, int] | None:
return _lookup_quarter_index(
db,
quarter_cad_number=q,
min_n_deals=settings.estimate_quarter_index_min_n_deals,
)
def _qis_lookup(qs: list[str]) -> dict[str, float]:
return _lookup_quarter_indexes(
db,
quarter_cad_numbers=qs,
min_n_deals=settings.estimate_quarter_index_min_n_deals,
)
# ── Deterministic pricing orchestration ──────────────────────────────────
pr = await asyncio.to_thread(
_price_from_inputs,
listings=listings,
area_m2=payload.area_m2,
rooms=payload.rooms,
repair_state=payload.repair_state,
floor=payload.floor,
total_floors=payload.total_floors,
target_year=target_year,
analog_tier=analog_tier,
fallback_used=fallback_used,
area_widened=area_widened,
anchor_comps=_anchor_comps_pre,
anchor_tier_fetched=_anchor_tier_pre,
dkp_raw=dkp_raw,
imv_anchor=imv_anchor_data,
imv_eval=imv_eval,
yandex_val_present=yandex_val is not None,
cian_val_present=cian_val is not None and bool(cian_val.sale_price_rub),
ratio_resolver=_ratio_resolver,
quarter_index_lookup=_qi_lookup,
quarter_indexes_lookup=_qis_lookup,
target_house_cadnum=target_quarter_cadnum,
dadata_coarse=dadata_coarse,
geo=geo,
dadata_qc_geo=dadata.qc_geo if dadata else None,
search_radius_m=search_radius_m,
)
# Unpack pricing result
median_price = pr.median_price
median_ppm2 = pr.median_ppm2
range_low = pr.range_low
range_high = pr.range_high
n_analogs = pr.n_analogs
confidence = pr.confidence
explanation = pr.explanation
anchor_tier = pr.anchor_tier
anchor_comps_used = pr.anchor_comps_used
avito_imv_summary = pr.avito_imv_summary
dkp_corridor = pr.dkp_corridor
expected_sold_per_m2 = pr.expected_sold_per_m2
expected_sold_price = pr.expected_sold_price
expected_sold_range_low = pr.expected_sold_range_low
expected_sold_range_high = pr.expected_sold_range_high
asking_to_sold_ratio = pr.asking_to_sold_ratio
ratio_basis = pr.ratio_basis
listings_clean = pr.listings_clean
cv = pr.cv
# #oblast-E (priority restored 2026-08-10): headline ceded to the ДКП deals
# corridor because the base listings sample was thin — a real signal (real
# Rosreestr deals), just not a listings-comp one. Recorded as its own
# relaxation label (distinct from the #oblast-F cascade labels above, which
# describe attempts to grow the LISTINGS sample — this describes switching
# sources entirely).
if pr.deals_headline_due_to_thin_listings:
relaxations.append("оценка по сделкам — мало объявлений рядом")
# #oblast-F: reliability tier derived from the FINAL n_analogs (post anchor/
# deals-fallback override above) — independent of `confidence` (older
# unique-address/IQR metric, see AggregatedEstimate docstring). If the
# #oblast-F cascade had to relax anything to get here, an otherwise-"ok"
# sample is downgraded to "low" — the raw count looks fine, but it only
# exists because we widened the search past the user's exact criteria.
if n_analogs >= 8:
reliability: Literal["ok", "low", "very_low"] = "ok"
elif n_analogs >= 3:
reliability = "low"
else:
reliability = "very_low"
if pr.deals_headline_due_to_thin_listings:
# #oblast-E: n_analogs is 0 here (listings-comp count, honestly zero —
# the headline came from deals instead), which would otherwise bucket
# to 'very_low'. Pin to 'low' instead: a 54-deal Rosreestr corridor is
# a real, meaningful signal — "не выше low" (product spec), not
# "почти нет сигнала" (what 'very_low' would imply here).
reliability = "low"
elif relaxations and reliability == "ok":
reliability = "low"
# #conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11):
# `confidence` (unique-address/IQR metric, _compute_confidence above) and
# `reliability` (n_analogs-bucket + #oblast-F relaxation-cascade metric,
# just computed) used to be fully independent — the cascade's room-
# adjacency/freshness/novostroyki steps never set `fallback_used`, so
# _compute_confidence stayed blind to them and kept scoring a wide,
# tight-IQR (post-relaxation) sample as "high", while `reliability`
# honestly read "low" from the SAME relaxations. Live prod repro: n=39,
# confidence='high', reliability='low', 3 relaxations applied — client
# saw a "высокая уверенность" badge next to a "точность снижена" banner
# simultaneously. Fix applied at ONE point, after BOTH values are final
# (NOT spread across the cascade steps above) — see
# _cap_confidence_by_reliability docstring for the exact rule.
original_confidence = confidence
confidence = _cap_confidence_by_reliability(confidence, reliability)
confidence_downgraded_by_reliability = confidence != original_confidence
if relaxations:
relaxations_detail = "Применены послабления подбора: " + ", ".join(relaxations) + "."
if confidence_downgraded_by_reliability:
# #conf-cap: explanation must LEAD with the accuracy-reduced summary
# when confidence was actually pulled down by the cap above — the
# old ordering opened with "Найдено 39 аналогов ... разброс ±6%"
# (reads as a confident, unqualified headline) and only tacked the
# relaxations sentence on at the very end, which itself contradicted
# the (now-capped) confidence badge just as much as the raw scores did.
explanation = (
"Оценка построена с расширенными параметрами подбора — точность "
"снижена. " + (explanation or "") + " " + relaxations_detail
)
else:
# No visible confidence change (e.g. it was already 'low'/'medium'
# pre-cap, or reliability=='ok' downgraded nothing) — keep the
# existing tail-append shape, unaffected by #conf-cap.
explanation = (explanation or "") + " " + relaxations_detail
# 5. Deals — ДКП-only sales (вторичка) из rosreestr_deals.
# Importer фильтрует doc_type='ДКП' (PR-A 2026-05-24), ДДУ застройщиков
# исключены — больше не скёюят median вторички ~110-120 К/м².
deals = await asyncio.to_thread(
_fetch_deals,
db,
lat=geo.lat,
lon=geo.lon,
rooms=payload.rooms,
area=payload.area_m2,
radius_m=base_radius_m,
)
# 6. Сохраняем в trade_in_estimates
estimate_id = uuid4()
now = datetime.now(tz=UTC)
expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours)
# #694: когда same-building якорь сработал, headline построен на комплах того
# же дома (anchor_comps_used) — показываем ИХ, а не радиусные listings_clean
# (другие/дешевле/пусто → premium headline «не подтверждён» аналогами).
# display-consistency fix: anchor_comps_used уже = anchor["comps"] (финальный
# пост-MAD-clip пул, len == n_analogs) — count согласован по построению.
# _dedup_display_lots ловит остаточные кросс-source дубли (ценовой дрейф между
# площадками), которые price_bucket-строгий статистический _dedup_cross_source
# мог пропустить — display-only, n_analogs/median/cv не трогает.
# #2899: позиция объекта внутри ТОЙ ЖЕ когорты, что дала headline, и по УЖЕ
# финальной median_ppm2 (все восемь мутаторов цены — repair_coef, anchor override,
# IMV-blend, quarter index, corridor clamp, radius floor, deals fallback, segment
# multiplier — лежат внутри _price_from_inputs и отработали выше). Считать внутри
# _price_from_inputs нельзя: там цена ещё не финальная, а n_analogs после точки
# расчёта перезаписывается трижды — перцентиль разошёлся бы с показанной ценой у
# двух третей оценок, которые идут якорным путём.
#
# Пул берётся ДО _dedup_display_lots: дедуп режет выборку под показ, а позиция
# должна считаться по популяции, из которой взята цена.
if anchor_tier is not None and anchor_comps_used:
market_percentile = _market_percentile(median_ppm2, anchor_comps_used)
display_pool = _dedup_display_lots(anchor_comps_used)
analogs_lots = [_anchor_comp_to_analog(c) for c in display_pool[:10]]
# #1519: при сработавшем якоре метаданные (freshness/last_scraped_at/
# days_on_market) считаем по ПОКАЗАННЫМ комплам, а не по радиусной выборке —
# иначе «обновлено N мин назад»/дата парсинга/срок продажи относятся к другому
# набору (или = None при пустом listings_clean, хотя у комплов данные есть).
metadata_lots = display_pool
else:
# display-consistency fix: только ЦЕНОВЫЕ листинги — та же популяция, что
# дала n_analogs = len(prices_ppm2) в radius-ветке _price_from_inputs.
# listings_clean может содержать записи с price_per_m2=None (outlier-фильтр
# их сохраняет — «нечего судить»), которые раздували карточки сверх
# заявленного N. _dedup_display_lots — см. ветку anchor выше.
priced_clean = [lot for lot in listings_clean if lot.get("price_per_m2")]
market_percentile = _market_percentile(median_ppm2, priced_clean)
display_pool = _dedup_display_lots(priced_clean)
analogs_lots = [_listing_to_analog(lot) for lot in display_pool[:10]]
metadata_lots = display_pool
deals_lots = [_deal_to_analog(d) for d in deals[:10]]
# #2087 (M1): единый канонический sources_used + source_counts. ОБА считаем по
# persisted analogs_lots (top-N, ровно то, что уходит в колонку analogs и
# рехайдрейтится на GET) — не по полной metadata-выборке. Иначе POST-ответ
# (полная выборка) расходился бы с GET-reload (persisted top-N): источник мог
# оказаться в source_counts, но не в sources_used → счётчик «X/7» прыгал.
# Инвариант: source_counts.keys() ⊆ sources_used (общий набор analog-источников).
valuation_flags: set[str] = set()
if imv_eval is not None:
valuation_flags.add("avito_imv")
if yandex_val is not None:
valuation_flags.add("yandex_valuation")
if cian_val is not None and cian_val.sale_price_rub:
valuation_flags.add("cian_valuation")
sources_used = _canonical_sources((lot.source for lot in analogs_lots), valuation_flags)
source_counts = _source_counts([lot.source for lot in analogs_lots])
freshness_pre = _compute_freshness_minutes(metadata_lots)
# DaData enrichment (PR Q1) — заполняется только если service отработал.
# При DaData = None все колонки идут в DB как NULL (graceful).
dadata_metro_json = (
json.dumps(dadata.metro, ensure_ascii=False)
if dadata is not None and dadata.metro
else None
)
# #2207: INSERT+commit сгруппированы и вынесены с event loop (to_thread).
def _persist_estimate_and_commit() -> None:
db.execute(
text(
"""
INSERT INTO trade_in_estimates (
id, address, lat, lon,
area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
ownership_type, has_mortgage,
median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
market_percentile,
analogs, actual_deals,
sources_used, data_freshness_minutes,
canonical_address, house_cadnum, house_fias_id,
dadata_qc_geo, dadata_qc_house, dadata_metro,
expected_sold_price, expected_sold_range_low,
expected_sold_range_high, expected_sold_per_m2,
asking_to_sold_ratio, ratio_basis,
relaxations, reliability,
created_by,
expires_at,
consent, client_ip, consent_policy_version, consent_text_snapshot
) VALUES (
CAST(:id AS uuid),
:address, :lat, :lon,
:area, :rooms, :floor, :total_floors,
:year_built, :house_type, :repair_state, :has_balcony,
:ownership_type, :has_mortgage,
:median_price, :range_low, :range_high, :median_ppm2,
:confidence, :explanation, :n_analogs,
:market_percentile,
CAST(:analogs_json AS jsonb),
CAST(:deals_json AS jsonb),
CAST(:sources_json AS jsonb),
:freshness,
:canonical_address, :house_cadnum, :house_fias_id,
:dadata_qc_geo, :dadata_qc_house,
CAST(:dadata_metro_json AS jsonb),
:expected_sold_price, :expected_sold_range_low,
:expected_sold_range_high, :expected_sold_per_m2,
:asking_to_sold_ratio, :ratio_basis,
CAST(:relaxations_json AS jsonb), :reliability,
:created_by,
:expires_at,
:consent, CAST(:client_ip AS inet), :consent_policy_version,
:consent_text_snapshot
)
"""
),
{
"id": str(estimate_id),
"address": geo.full_address,
"lat": geo.lat,
"lon": geo.lon,
"area": payload.area_m2,
"rooms": payload.rooms,
"floor": payload.floor,
"total_floors": payload.total_floors,
"year_built": target_year,
"house_type": target_house_type,
"repair_state": payload.repair_state,
"has_balcony": payload.has_balcony,
"ownership_type": payload.ownership_type,
"has_mortgage": payload.has_mortgage,
"median_price": median_price,
"range_low": range_low,
"range_high": range_high,
"median_ppm2": int(median_ppm2),
"confidence": confidence,
"explanation": explanation,
"n_analogs": n_analogs,
"market_percentile": market_percentile,
"analogs_json": json.dumps(
[a.model_dump(mode="json") for a in analogs_lots], ensure_ascii=False
),
"deals_json": json.dumps(
[a.model_dump(mode="json") for a in deals_lots], ensure_ascii=False
),
# #2087 (M1): персистим КАНОНИЧЕСКИЙ sources_used (листинговые из
# analogs оценочные флаги) — тот же массив, что в POST-ответе. GET
# рехайдрейтит его же (валид. флаги читаются из этой колонки), history
# и PDF получают консистентный «X/7» без quarter_index/радиусного шума.
"sources_json": json.dumps(sources_used, ensure_ascii=False),
"freshness": freshness_pre,
"canonical_address": dadata.canonical_address if dadata else None,
"house_cadnum": dadata.house_cadnum if dadata else None,
"house_fias_id": dadata.house_fias_id if dadata else None,
"dadata_qc_geo": dadata.qc_geo if dadata else None,
"dadata_qc_house": dadata.qc_house if dadata else None,
"dadata_metro_json": dadata_metro_json,
"expected_sold_price": expected_sold_price,
"expected_sold_range_low": expected_sold_range_low,
"expected_sold_range_high": expected_sold_range_high,
"expected_sold_per_m2": expected_sold_per_m2,
"asking_to_sold_ratio": asking_to_sold_ratio,
"ratio_basis": ratio_basis,
# #oblast-F / GET-rehydrate (PR #2823 open follow-up): персистим
# relaxations/reliability вместе с median — раньше эти поля жили
# только в POST-ответе, и красный баннер «точность снижена»
# пропадал при открытии оценки по сохранённой ссылке (?id=).
"relaxations_json": json.dumps(relaxations, ensure_ascii=False),
"reliability": reliability,
"created_by": created_by,
"expires_at": expires_at,
**_estimate_consent_persist_fields(require_consent, client_ip),
},
)
# Link saved IMV evaluation к этому estimate_id атомарно с основным INSERT
# (closes finding #4 from 2026-05-24 audit — prior code committed estimate first,
# then UPDATEd IMV in a separate tx, racing against concurrent estimators
# sharing the same cache_key).
if imv_eval is not None:
db.execute(
text(
"""
UPDATE avito_imv_evaluations
SET estimate_id = CAST(:estimate_id AS uuid)
WHERE cache_key = :cache_key
AND (estimate_id IS NULL OR estimate_id = CAST(:estimate_id AS uuid))
"""
),
{"estimate_id": str(estimate_id), "cache_key": imv_eval.cache_key},
)
db.commit()
await asyncio.to_thread(_persist_estimate_and_commit)
logger.info(
"estimate: id=%s addr=%s rooms=%d area=%.1f → median=%d (n=%d, conf=%s)%s%s",
estimate_id,
geo.full_address[:60],
payload.rooms,
payload.area_m2,
median_price,
n_analogs,
confidence,
f" imv={imv_eval.recommended_price}" if imv_eval else "",
f" cian={cian_val.sale_price_rub}" if cian_val and cian_val.sale_price_rub else "",
)
# #2087 (M1): sources_used / source_counts уже посчитаны выше (канонически, из
# persisted analogs_lots) — не пересчитываем, чтобы POST-ответ был байт-в-байт
# тем, что персистится и рехайдрейтится на GET.
freshness_min = _compute_freshness_minutes(metadata_lots)
last_scraped_at = _compute_last_scraped_at(metadata_lots)
# Месячный ₽/м² тренд целевого дома (web TREND chart) — best-effort, None если нет данных.
# #audit-3: передаём freshness_months из настроек — исключаем устаревшие items.
price_trend_raw = await asyncio.to_thread(
_fetch_price_trend,
db,
target_house_id=target_house_id,
freshness_months=settings.estimate_price_trend_max_age_months,
)
price_trend = (
[PriceTrendPoint(month=p["month"], ppm2=p["ppm2"]) for p in price_trend_raw]
if price_trend_raw
else None
)
# #2002: премиальный-дом флаг + класс для target_house_id. Источник — curated
# overlay (data/sql/142) с fallback на MV `premium_houses` (data/sql/139).
# МЕТАДАННЫЕ (manual-review routing #4), НЕ ценовой сигнал — не трогает
# median/expected_sold/ranges. Best-effort: degrade в (False, None, None) при
# отсутствии таблиц. premium_building_class — класс из curated (None для
# невыверенных MV-домов).
(
premium_building,
premium_building_median_ppm2,
premium_building_class,
) = await asyncio.to_thread(_is_premium_building, db, target_house_id)
# #audit-2: структурный analog_tier — стабильный enum для фронта.
# anchor-путь: anchor_tier "A" → "same_building", "C" → "micro_radius".
# radius-путь: analog_tier "W" → "city", остальные → "district".
# None только если нет аналогов (median=0, insufficient_data=True).
if median_price > 0:
if anchor_tier == "A":
api_analog_tier: str | None = "same_building"
elif anchor_tier == "C":
api_analog_tier = "micro_radius"
elif analog_tier == "W":
api_analog_tier = "city"
else:
api_analog_tier = "district"
else:
api_analog_tier = None
# #1871 P1.2 — defensive invariant guard перед сборкой ответа: n_analogs == 0
# не может сосуществовать с confidence != 'low' (ghost-anchor). Mainline уже
# честен — это belt-and-suspenders на будущие external-valuation/rehydrate пути.
confidence, explanation = _enforce_zero_analog_low(
confidence,
n_analogs,
explanation,
median_price=median_price,
sources_used=sources_used,
)
# #2002 #4: manual-review routing — производный ФЛАГ на основе уже
# вычисленных premium_building / confidence / range (asking-IQR). НЕ влияет
# на median/expected_sold/ranges — только метаданные для UI/маршрутизации.
manual_review_recommended, manual_review_reasons = _manual_review(
premium_building,
expected_sold_price,
confidence,
range_low,
range_high,
settings,
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
asking_ppm2=int(median_ppm2),
)
return AggregatedEstimate(
estimate_id=estimate_id,
median_price_rub=median_price,
range_low_rub=range_low,
range_high_rub=range_high,
median_price_per_m2=int(median_ppm2),
confidence=confidence,
confidence_explanation=explanation,
n_analogs=n_analogs,
market_percentile=market_percentile,
period_months=DEALS_PERIOD_MONTHS,
analogs=analogs_lots,
actual_deals=deals_lots,
expires_at=expires_at,
target_address=geo.full_address,
target_lat=geo.lat,
target_lon=geo.lon,
target_city_ambiguous=geo.city_ambiguous,
target_address_refined=geo.address_refined,
sources_used=sources_used,
data_freshness_minutes=freshness_min,
last_scraped_at=last_scraped_at,
price_trend=price_trend,
est_days_on_market=_estimate_days_on_market(metadata_lots, deals),
cian_valuation=(
CianValuationSummary(
sale_price_rub=int(cian_val.sale_price_rub) if cian_val.sale_price_rub else None,
rent_price_rub=int(cian_val.rent_price_rub) if cian_val.rent_price_rub else None,
chart=[
{
"date": p.get("month_date") or p.get("date") or "",
"price": p["price"],
}
for p in (cian_val.chart or [])
if p.get("price") is not None
],
chart_change_pct=cian_val.chart_change_pct,
chart_change_direction=(
cian_val.chart_change_direction
if cian_val.chart_change_direction in {"increase", "decrease", "neutral"}
else None
),
)
if cian_val is not None
else None
),
avito_imv=avito_imv_summary,
dkp_corridor=dkp_corridor,
expected_sold_price_rub=expected_sold_price,
expected_sold_range_low_rub=expected_sold_range_low,
expected_sold_range_high_rub=expected_sold_range_high,
expected_sold_per_m2=expected_sold_per_m2,
asking_to_sold_ratio=asking_to_sold_ratio,
ratio_basis=ratio_basis,
area_m2=payload.area_m2,
rooms=payload.rooms,
floor=payload.floor,
total_floors=payload.total_floors,
year_built=target_year,
house_type=target_house_type,
repair_state=payload.repair_state,
has_balcony=payload.has_balcony,
canonical_address=dadata.canonical_address if dadata else None,
house_cadnum=dadata.house_cadnum if dadata else None,
house_fias_id=dadata.house_fias_id if dadata else None,
metro_nearest=(dadata.metro if dadata and dadata.metro else []),
address_precision=_qc_geo_to_precision(dadata.qc_geo if dadata else None),
analog_tier=api_analog_tier, # type: ignore[arg-type]
# #2632: обе величины рядом — с чего поиск начали (выбор пользователя или
# DEFAULT_RADIUS_M) и на чём закончили. Расходятся, когда сервер сам
# расширил поиск; подменять одно другим нельзя — это ровно тот дефект
# метки, что чинили в #2811. None у _empty_estimate (поиск не выполнялся).
requested_radius_m=base_radius_m,
search_radius_m=search_radius_m,
premium_building=premium_building,
premium_building_median_ppm2=premium_building_median_ppm2,
premium_building_class=premium_building_class,
manual_review_recommended=manual_review_recommended,
manual_review_reasons=manual_review_reasons,
# #2043 (BE-1): достоверность выборки — CV, счётчики источников, дата.
cv=cv,
source_counts=source_counts,
created_at=now,
# #oblast-F (never-block relaxation cascade) — применённые ослабления
# подбора + производная надёжность выборки (см. reliability computation
# above, независимо от `confidence`).
relaxations=relaxations,
reliability=reliability,
)
def _qc_geo_to_precision(qc_geo: int | None) -> str | None:
# DaData qc_geo: 0=exact(house), 1=street, 2=settlement, 3=city, 4=region, 5=unknown
if qc_geo is None:
return None
if qc_geo == 0:
return "house"
if qc_geo == 1:
return "street"
return "approximate"
def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int | None, str | None]:
"""#2002: премиальный-дом флаг + класс для target_house_id.
Источник истины — двухслойный overlay:
1. `premium_buildings_curated` (data/sql/142) — AI/human-выверенный слой:
per-house class tier (премиум/элит/бизнес/комфорт/эконом/неизвестно) +
is_premium с false-positive demotions. Если дом есть здесь — он ПЕРЕБИВАЕТ MV.
2. `premium_houses` MV (data/sql/139) — ~298 EKB-домов по концентрации дорогих
листингов; даёт med_ppm2 (₽/м² медиана дорогих листингов) + fallback-членство
для НЕвыверенных домов.
Возвращает (is_premium, med_ppm2, building_class):
- curated-дом → (curated.is_premium, MV.med_ppm2 или None, curated.tier);
- невыверенный дом в MV → (True, MV.med_ppm2, None) — класс неизвестен;
- дом нигде не найден / house_id None → (False, None, None).
Это МЕТАДАННЫЕ для manual-review routing (#4), НЕ ценовой сигнал: не трогает
median/expected_sold/ranges.
Best-effort: любая из таблиц может ещё не существовать в части окружений — любая
ошибка (нет таблицы / SQL) деградирует в (False, None, None) и НИКОГДА не валит
оценку.
LOW audit #4 — EKB-bias (doc-only, no behavior change): и `premium_buildings_curated`
(data/sql/142, см. header-комментарий там), и её MV-фолбэк `premium_houses`
(data/sql/139) построены и выверены по ЕКБ-методологии. Кураторская таблица
активно ДЕПРОМОУТИТ non-EKB описания как шум/mislabel (см. пример house_id=372944
в 142_premium_buildings_curated.sql — «это НЕ ЕКБ» → is_premium=false). Значит для
областных (oblast) элитных домов эта функция систематически вернёт (False, None,
None) — premium_building флаг не выставится, даже если дом объективно элитный по
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости —
`estimate_manual_review_high_value_rub` (expected_sold_price ≥ порога) и
`estimate_manual_review_elite_ppm2` (asking ₽/м² ≥ порога) — независимо от
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
почти всегда подхватывается value-based триггером той же функции.
"""
if house_id is None:
return (False, None, None)
try:
cur = db.execute(
text(
"SELECT is_premium, tier FROM premium_buildings_curated "
"WHERE house_id = CAST(:hid AS bigint)"
),
{"hid": house_id},
).first()
mv = db.execute(
text("SELECT med_ppm2 FROM premium_houses WHERE house_id = CAST(:hid AS bigint)"),
{"hid": house_id},
).first()
except Exception as exc: # pragma: no cover — defensive (таблица может ещё не существовать)
logger.warning("premium-building lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception:
pass
return (False, None, None)
med = int(mv[0]) if mv is not None and mv[0] is not None else None
if cur is not None: # curated overlay перебивает MV (класс + corrected флаг)
return (bool(cur[0]), med, str(cur[1]))
return (mv is not None, med, None) # невыверенный → членство в MV, класс неизвестен
def _manual_review(
premium_building: bool,
expected_sold_price: int | None,
confidence: str,
range_low: int | None,
range_high: int | None,
settings: Settings,
asking_ppm2: int | None = None,
) -> tuple[bool, list[str]]:
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
Помечает оценки, которые НЕ стоит авто-оффэрить — нужна оценка человеком.
Research: элит/премиум-премия unit-level и под-доверена (зависит от
отделки/вида/этажа, чего нет в данных сделок). Собирает человекочитаемые
RU-причины. Чистая функция — НЕ трогает median/expected_sold/ranges
(regression-gate остаётся byte-identical).
Триггеры:
- premium_building — премиальный дом;
- expected_sold_price ≥ high_value_rub — элит-зона по абсолютной стоимости;
- confidence == "low" — низкая уверенность оценки;
- range_high / range_low ≥ wide_range_ratio — слишком широкий диапазон;
- asking_ppm2 ≥ elite_ppm2 — дорогой сегмент по asking ₽/м².
`asking_ppm2` — headline/«по объявлениям» медиана ₽/м² (НЕ expected_sold_per_m2):
бэктест показал систематическую недооценку бизнес/элита (bias ≈ 29%), потому что
expected_sold затягивает элит-юнит в бизнес-полосу и не опознаёт его. Asking-сторона
не занижена и чисто маркирует дорогой сегмент.
Возвращает (recommended, reasons), где recommended == bool(reasons).
Деление на ноль исключено: проверка
`range_low and range_high` отсекает None/0 до деления.
"""
reasons: list[str] = []
if premium_building:
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
high_value = settings.estimate_manual_review_high_value_rub
if expected_sold_price and expected_sold_price >= high_value:
reasons.append("высокая стоимость (≥20 млн ₽)")
if confidence == "low":
reasons.append("низкая уверенность оценки")
wide_ratio = settings.estimate_manual_review_wide_range_ratio
if range_low and range_high and range_high / range_low >= wide_ratio:
reasons.append("широкий диапазон цены")
if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2:
reasons.append(
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
)
return (bool(reasons), reasons)
def _estimate_days_on_market(
listings: list[dict[str, Any]], deals: list[dict[str, Any]]
) -> int | None:
"""Прогноз срока продажи — медиана days_on_market по аналогам/сделкам.
Возвращает None если ни у одного аналога нет данных о сроке экспозиции
(наши парсеры не всегда его отдают — честно показываем «нет данных»).
"""
values = [
int(lot["days_on_market"])
for lot in (*listings, *deals)
if lot.get("days_on_market") and int(lot["days_on_market"]) > 0
]
if len(values) < 3:
return None
values.sort()
return values[len(values) // 2]
def _compute_freshness_minutes(lots: list[dict[str, Any]]) -> int | None:
"""Минут с последнего парсинга — для UI «обновлено N мин назад»."""
if not lots:
return None
from datetime import datetime as _dt
now = _dt.now(tz=UTC)
scraped = [lot.get("scraped_at") or lot.get("listing_date") for lot in lots]
scraped_dt: list[datetime] = []
for s in scraped:
if s is None:
continue
# listings rows из mappings — scraped_at это datetime, не date
if hasattr(s, "tzinfo"):
scraped_dt.append(s if s.tzinfo else s.replace(tzinfo=UTC))
if not scraped_dt:
return None
return int((now - max(scraped_dt)).total_seconds() / 60)
def _compute_last_scraped_at(lots: list[dict[str, Any]]) -> datetime | None:
"""Абсолютный timestamp самого свежего парсинга среди аналогов (для UI).
Дополняет _compute_freshness_minutes (относительные минуты): отдаёт точную
дату/время, чтобы фронт мог отрендерить «обновлено DD.MM HH:MM». None если
ни у одного лота нет scraped_at/listing_date с tzinfo (graceful)."""
if not lots:
return None
scraped = [lot.get("scraped_at") or lot.get("listing_date") for lot in lots]
scraped_dt: list[datetime] = []
for s in scraped:
if s is None:
continue
if hasattr(s, "tzinfo"):
scraped_dt.append(s if s.tzinfo else s.replace(tzinfo=UTC))
return max(scraped_dt) if scraped_dt else None
def _fetch_price_trend(
db: Session,
*,
target_house_id: int | None,
months: int = 24,
min_points: int = 3,
freshness_months: int | None = None,
) -> list[dict[str, Any]] | None:
"""Месячный ₽/м² тренд для целевого дома (web TREND chart) — best-effort.
Предпочитает `houses_price_dynamics` (house_id, month_date, price_per_sqm) —
готовая помесячная серия. В prod эта таблица пока ПУСТА, поэтому fallback —
агрегация `house_placement_history` по месяцам (медиана COALESCE(last_price,
start_price)/area_m2, дата = COALESCE(last_price_date, start_price_date)).
Возвращает список [{month: 'YYYY-MM', ppm2: int}, ...] (≤ `months` точек,
ASC по месяцу) или None если house_id не задан / точек < `min_points` /
любая ошибка (graceful — фронт скрывает chart, без регрессий).
"""
if target_house_id is None:
return None
# ── Source 1 (preferred): houses_price_dynamics ──────────────────────────
try:
rows = (
db.execute(
text(
"""
SELECT to_char(month_date, 'YYYY-MM') AS month,
round(
percentile_cont(0.5) WITHIN GROUP (ORDER BY price_per_sqm)
)::int AS ppm2
FROM houses_price_dynamics
WHERE house_id = CAST(:hid AS bigint)
AND price_per_sqm > 0
AND month_date > (CURRENT_DATE
- (CAST(:months AS integer) || ' months')::interval)
GROUP BY month_date
ORDER BY month_date ASC
"""
),
{"hid": target_house_id, "months": months},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("price_trend houses_price_dynamics lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception:
pass
rows = []
trend = [{"month": r["month"], "ppm2": int(r["ppm2"])} for r in rows if r["ppm2"]]
if len(trend) >= min_points:
logger.info(
"price_trend house_id=%s source=houses_price_dynamics → %d points",
target_house_id,
len(trend),
)
return trend
# ── Source 2 (fallback): aggregate house_placement_history by month ──────
# #audit-3: freshness_months — фильтр по scraped_at чтобы исключить стale items
# (yandex_valuation из 2024 и т.д.). Применяется ко ВСЕМ source в этом запросе
# (avito_imv + yandex_valuation), не только к yandex_valuation. Дефолт из config.
# Mera-audit fix-3: cross-source dedup near-дублей: один объект на avito_imv +
# yandex_valuation c разными ext_item_id → double-count в медиане. DISTINCT ON по
# ключу (round(area_m2,0), floor, price, price_date) с приоритетом avito_imv
# (source='avito_imv' сортируется раньше через CASE).
_fresh_months = freshness_months if freshness_months is not None else months
try:
trend_sql = """
WITH deduped AS (
SELECT DISTINCT ON (
ROUND(area_m2),
floor,
COALESCE(last_price, start_price),
COALESCE(last_price_date, start_price_date)
)
area_m2,
floor,
COALESCE(last_price, start_price) AS price,
COALESCE(last_price_date, start_price_date) AS price_date
FROM house_placement_history
WHERE house_id = CAST(:hid AS bigint)
AND area_m2 > 0
AND COALESCE(last_price, start_price) > 0
AND COALESCE(last_price_date, start_price_date) IS NOT NULL
AND COALESCE(last_price_date, start_price_date) > (CURRENT_DATE
- (CAST(:months AS integer) || ' months')::interval)
AND scraped_at > (now()
- CAST(:fresh_months AS integer)
* CAST('1 month' AS interval))
ORDER BY
ROUND(area_m2),
floor,
COALESCE(last_price, start_price),
COALESCE(last_price_date, start_price_date),
CASE source WHEN 'avito_imv' THEN 0 ELSE 1 END ASC,
scraped_at DESC
)
SELECT to_char(date_trunc('month', price_date), 'YYYY-MM') AS month,
round(
percentile_cont(0.5) WITHIN GROUP (
ORDER BY price / NULLIF(area_m2, 0)
)
)::int AS ppm2
FROM deduped
GROUP BY 1
ORDER BY 1 ASC
"""
rows = (
db.execute(
text(trend_sql),
{"hid": target_house_id, "months": months, "fresh_months": _fresh_months},
)
.mappings()
.all()
)
except Exception as exc: # pragma: no cover — defensive
logger.warning("price_trend house_placement_history lookup failed (graceful): %s", exc)
try:
db.rollback()
except Exception:
pass
rows = []
trend = [{"month": r["month"], "ppm2": int(r["ppm2"])} for r in rows if r["ppm2"]]
if len(trend) >= min_points:
logger.info(
"price_trend house_id=%s source=house_placement_history → %d points",
target_house_id,
len(trend),
)
return trend
logger.info(
"price_trend house_id=%s → only %d points (<%d) → None",
target_house_id,
len(trend),
min_points,
)
return None
# ── Internals ────────────────────────────────────────────────────────────────
# Compiled regexes for _extract_short_addr — module-level for performance.
# Strips leading admin prefixes: «Россия», «Свердловская область», «г. Екатеринбург» etc.
_ADMIN_PREFIX_RE = re.compile(
r"^(?:"
r"\s*(?:Россия|РФ|Российская\s+Федерация)\s*,?\s*|"
r"\s*[А-Яа-яёЁ][А-Яа-яёЁ\s-]+(?:\s+(?:обл(?:асть)?|р-н|район|округ|край|республика))\.?\s*,?\s*|"
r"\s*(?:г(?:ород)?|гор)\.?\s*[А-Яа-яёЁ][А-Яа-яёЁ\s-]+\s*,?\s*|"
r"\s*[А-Я][а-яё-]+(?:\s+[А-Я][а-яё-]+)?\s*,?\s*"
r")+",
flags=re.UNICODE,
)
# Recognizes start of a street keyword.
_STREET_START_RE = re.compile(
r"(?:ул\.|улица|пр\.|пр-т|проспект|пер\.|переулок|"
r"б-р|бульвар|ш\.|шоссе|наб\.|набережная|проезд|тракт|пл\.|площадь|"
r"мкр\.?|микрорайон)\s+",
flags=re.IGNORECASE | re.UNICODE,
)
# Drops trailing apartment / office / corpus noise from the end.
_TRAILING_NOISE_RE = re.compile(
r"\s*,\s*(?:кв\.?\s*\d+|корп\.?\s*\w+|оф\.?\s*\d+|пом\.?\s*\d+|подъезд\s*\d+).*$",
flags=re.IGNORECASE | re.UNICODE,
)
def _extract_short_addr(full_address: str | None) -> str | None:
"""Извлекает «улица + номер дома» из полного адреса для поиска в том же доме.
NOTE (audit fix): `_fetch_analogs` Tier S-fallback больше НЕ вызывает эту
функцию — заменена на `_normalize_building_key` + `_house_boundary_regex`
(numeric-boundary matching, тот же принцип что и anchor Tier A), т.к. голый
строковый prefix без границы номера дома схлопывал «Ленина, 5» с «Ленина,
50/51/500». Оставлена как standalone-утилита (own tests в
test_extract_short_addr.py) — не удалена на случай будущего переиспользования
простого street+house извлечения без полной normalize_building_key семантики.
Примеры:
"Свердловская область, г. Екатеринбург, ул. Заводская, д. 44-а""ул. Заводская, д. 44-а"
"Россия, Екатеринбург, ул. Малышева, 1""ул. Малышева, 1"
"РФ, Свердловская обл., Екатеринбург, ул. Ленина, 5, кв. 12""ул. Ленина, 5"
"г. Екатеринбург, проспект Ленина, 50""проспект Ленина, 50"
"Екатеринбург, ул. Крауля, 48/2""ул. Крауля, 48/2"
Алгоритм:
1. Отрезаем trailing кв./корп./оф. noise.
2. Ищем первый street-keyword токен (ул./пр./пер. и т.д.) — возвращаем с него.
3. Fallback: агрессивно strip admin-prefix regex, вернуть остаток.
4. None если строка пустая или нечего возвращать.
"""
if not full_address:
return None
s = full_address.strip()
s = _TRAILING_NOISE_RE.sub("", s)
# Find first street-keyword position and return from there.
m = _STREET_START_RE.search(s)
if m:
return s[m.start() :].strip(" ,.")
# Fallback: strip known admin prefixes, return whatever remains.
s = _ADMIN_PREFIX_RE.sub("", s)
return s.strip(" ,.") or None
# Ищет keyword типа улицы (ул./улица/пр./проспект/...) в адресе.
# Работает для FORWARD и REVERSE форматов Nominatim.
# #pdf-honesty/#oblast-E-follow-up (live-prod fix 2026-08-10): точка после
# сокращений (ул., пр., пер., ш., наб., пл., мкр.) сделана ОПЦИОНАЛЬНОЙ
# (`\.?`) — DaData (основной источник адресов, дом-уровень геокодинга) отдаёт
# формат БЕЗ точки: "ул Академика Парина", а не "ул. Академика Парина". Старый
# regex требовал точку строго → keyword не матчился НИ НА ОДНОМ DaData-адресе
# → street-deals/sales-vs-listings блоки молчали (WARNING "could not extract
# street") на КАЖДОМ запросе с DaData-геокодингом, не только на репро-адресе.
# Порядок альтернатив принципиален: `ул\.?` идёт ПЕРЕД полным словом `улица` —
# но это безопасно за счёт backtracking Python `re` (NFA, не POSIX longest-
# match): если `ул\.?` матчит только "ул" из "улица" и последующий `\s+`
# после этого не находит пробел (следующий символ — "и"), движок
# откатывается и пробует СЛЕДУЮЩУЮ альтернативу — "улица" — которая матчит
# полностью. Проверено на "ул. X" / "ул X" / "улица X" — все три дают
# идентичный результат (см. test_street_deals_endpoint.py). Бывшая отдельная
# bare-альтернатива "мкр" убрана как ставшая избыточной — "мкр\.?" уже
# покрывает оба варианта (с точкой и без).
_STREET_KW_RE = re.compile(
r"(?<![А-Яа-яёЁa-zA-Z])"
r"(?:ул\.?|улица|пр\.?|пр-т|проспект|пер\.?|переулок|"
r"б-р|бульвар|ш\.?|шоссе|наб\.?|набережная|проезд|тракт|"
r"пл\.?|площадь|мкр\.?|микрорайон)"
r"\s+",
flags=re.IGNORECASE | re.UNICODE,
)
# После keyword: 1-3 слова имени улицы со стопом на запятую или номер дома.
# Поддерживает "8 Марта" (цифра + слово) и "Большая Конюшенная" (несколько слов).
_STREET_NAME_RE = re.compile(
r"^([0-9]+\s+[А-Яа-яёЁ][А-Яа-яёЁ-]+"
r"|[А-Яа-яёЁ][А-Яа-яёЁ-]+(?:\s+[А-Яа-яёЁ][А-Яа-яёЁ-]+){0,2})"
r"(?=,|\s+(?:д\.?\s*)?\d|\s*$)",
flags=re.UNICODE,
)
def extract_street_name(full_address: str | None) -> str | None:
"""Извлекает чистое имя улицы из адреса в FORWARD или REVERSE формате.
Примеры:
"Екатеринбург, ул. Космонавтов, 50""Космонавтов"
"80, улица 8 Марта, Артек, ..., Россия""8 Марта"
"проспект Ленина 50""Ленина"
"Россия, Екатеринбург, ул. Малышева, 1""Малышева"
"ул. Большая Конюшенная, 25""Большая Конюшенная"
"" → None
Алгоритм:
1. Ищем street-keyword (ул/улица/пр/проспект/...) — case-insensitive.
2. После keyword берём 1-3 слова до запятой или номера дома.
3. Если keyword не нашёлся — пытаемся первый capitalized токен с
поиском до запятой или номера (fallback для адресов без keyword'а).
Returns None если ничего не извлеклось.
"""
if not full_address or not full_address.strip():
return None
s = full_address.strip()
# 1. Keyword-based extraction (работает для обоих форматов: forward и reverse)
m = _STREET_KW_RE.search(s)
if m:
rest = s[m.end() :].lstrip()
nm = _STREET_NAME_RE.match(rest)
if nm:
return nm.group(1).strip()
# 2. Fallback: нет keyword — пробуем первый capitalized токен
# Используется для "Большая Конюшенная, 25" без "ул."
nm = _STREET_NAME_RE.match(s)
if nm:
candidate = nm.group(1).strip()
# Отсеиваем очевидные административные слова
bad = {"Россия", "Москва", "Санкт-Петербург", "область", "район", "округ", "край"}
if not any(b in candidate for b in bad):
return candidate
return None
def _stratify_candidates(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Стратифицированная выборка Approach B — гарантирует MIN_ANALOGS_PER_SOURCE слотов.
Candidates должны быть уже отсортированы по relevance_score (ASC).
"""
guaranteed: list[dict[str, Any]] = []
guaranteed_ids: set[int] = set()
by_source: dict[str, list[dict[str, Any]]] = {}
for row in candidates:
src = row.get("source") or "unknown"
by_source.setdefault(src, []).append(row)
for _src, src_rows in by_source.items():
quota = min(len(src_rows), MIN_ANALOGS_PER_SOURCE)
for row in src_rows[:quota]:
if id(row) not in guaranteed_ids:
guaranteed.append(row)
guaranteed_ids.add(id(row))
remaining_slots = 50 - len(guaranteed)
remainder: list[dict[str, Any]] = []
if remaining_slots > 0:
for row in candidates:
if id(row) not in guaranteed_ids:
remainder.append(row)
if len(remainder) >= remaining_slots:
break
result = guaranteed + remainder
result.sort(key=lambda r: r.get("relevance_score") or 0.0)
return result[:50]
def _adjust_relevance_by_pool_deviation(
candidates: list[dict[str, Any]],
*,
key: str,
scale: float,
max_penalty: float,
min_n: int,
) -> None:
"""#2012 comp-scoring: penalize deviation from the CANDIDATE POOL's own median.
kitchen_area_m2 / ceiling_height_m have NO target value to compare against —
unlike house_type/year_built (compared to target_house_type/target_year,
sourced from payload or OSM house_metadata fallback), neither
``TradeInEstimateInput`` (user form) nor ``deals`` (backtest ground truth,
rosreestr ДКП) carry a kitchen/ceiling value for the property being priced.
So instead of a target-diff soft-penalty, this penalizes a candidate's
deviation from the MEDIAN of the same pool of candidates being scored right
now (self-referential) — no external "typical" constant is assumed.
Mutates each candidate's ``relevance_score`` IN PLACE (same convention as the
SQL house_type/year_built CASE terms: higher = less relevant). NULL-safe:
a candidate missing ``key`` is neither penalized nor counted toward the pool
median. Sparse-safe: if fewer than ``min_n`` candidates carry a non-NULL
value, the signal is skipped for the ENTIRE pool — too few examples to trust
a "typical" median (see #2012 sparse-coverage risk: kitchen 4-99%, ceiling
~10% coverage across sources).
"""
values = [float(c[key]) for c in candidates if c.get(key) is not None]
if len(values) < min_n:
return
pool_median = statistics.median(values)
for c in candidates:
v = c.get(key)
if v is None:
continue
penalty = min(abs(float(v) - pool_median) / scale, max_penalty)
c["relevance_score"] = (c.get("relevance_score") or 0.0) + penalty
def _apply_unknown_attr_penalty(
candidates: list[dict[str, Any]],
*,
target_year: int | None,
target_house_type: str | None,
min_n: int,
) -> None:
"""#2936: неизвестный year_built / house_type получает МЕДИАННЫЙ по пулу штраф.
SQL-формула relevance_score даёт кандидату без year_built штраф 0 — столько
же, сколько точному попаданию в год, и лучше, чем известному году с разницей
24 (2.0). Отсутствие данных выигрывает у знания и возвышает источник с худшей
полнотой (см. комментарий к estimate_unknown_attr_penalty_enabled в config).
Штраф за NULL = медиана штрафов того же признака среди кандидатов пула, у
которых он известен: не наказание и не награда. Считается ТОЛЬКО по тем
членам пула, где SQL реально начислил штраф, — то есть воспроизводит ровно
SQL-термы abs(year_built - target)/12.0 и 1.5-за-несовпадение, без своего
понимания «насколько далеко». Если target неизвестен, SQL штраф не начисляет
никому — и здесь тоже (симметрия: CASE WHEN :target_... IS NOT NULL).
Sparse-safe: известных значений меньше ``min_n`` → сигнал пропущен целиком.
Мутирует relevance_score IN PLACE — та же конвенция, что у kitchen/ceiling.
"""
if target_year is not None:
known = [
abs(int(c["year_built"]) - int(target_year)) / 12.0
for c in candidates
if c.get("year_built") is not None
]
if len(known) >= min_n:
median_pen = statistics.median(known)
for c in candidates:
if c.get("year_built") is None:
c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen
if target_house_type is not None:
known_ht = [
1.5 if str(c["house_type"]) != str(target_house_type) else 0.0
for c in candidates
if c.get("house_type") is not None
]
if len(known_ht) >= min_n:
median_pen = statistics.median(known_ht)
for c in candidates:
if c.get("house_type") is None:
c["relevance_score"] = (c.get("relevance_score") or 0.0) + median_pen
def _apply_kitchen_ceiling_signal(candidates: list[dict[str, Any]]) -> None:
"""#2012: apply the kitchen_area_m2 / ceiling_height_m comp-scoring signals.
Each is an INDEPENDENT feature flag (default OFF — see config.py). No-op
when both flags are off (byte-identical to pre-#2012 behaviour). Called only
from the Tier H / Tier W paths of ``_fetch_analogs`` — Tier S (same
building) is intentionally excluded, symmetric with house_type/year_built,
which also don't participate in Tier S relevance (fixed 0.0 there).
"""
if settings.estimate_kitchen_area_signal_enabled:
_adjust_relevance_by_pool_deviation(
candidates,
key="kitchen_area_m2",
scale=settings.estimate_kitchen_area_scale,
max_penalty=settings.estimate_kitchen_ceiling_signal_max_penalty,
min_n=settings.estimate_kitchen_ceiling_signal_min_n,
)
if settings.estimate_ceiling_height_signal_enabled:
_adjust_relevance_by_pool_deviation(
candidates,
key="ceiling_height_m",
scale=settings.estimate_ceiling_height_scale,
max_penalty=settings.estimate_kitchen_ceiling_signal_max_penalty,
min_n=settings.estimate_kitchen_ceiling_signal_min_n,
)
_ANALOG_SELECT_COLS = """
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at,
building_cadastral_number,
-- #2012: comp-scoring сигналы (kitchen_area_m2/ceiling_height_m). Только
-- Tier H/W применяют их (см. _apply_kitchen_ceiling_signal) — Tier S
-- (same building) не трогают, симметрично house_type/year_built, которые
-- тоже не участвуют в Tier S relevance (там фиксированный 0.0).
kitchen_area_m2, ceiling_height_m,
-- #2936: year_built / house_type ВЫБИРАЮТСЯ, а не только участвуют в SQL-CASE.
-- До этого SQL считал по ним relevance_score, но в словарь кандидата колонки не
-- попадали: Python-слой видел None у ВСЕХ и штрафовать «неизвестное» не мог по
-- построению — прогон A/B с флагом дал 0 из 5 915 сменённых слотов при 55.7 %
-- avito в топ-50. Замечено probe-логом в оверлее: pool=30 null_year=30
-- null_type=30. Для путей, которые эти ключи не читают, добавление байт-
-- идентично; фикстура бэктеста реплеится с замороженными listings и гейта не
-- трогает.
year_built, house_type
"""
_COMMON_WHERE = """
-- #oblast-F (a): rooms_min/rooms_max — обычно оба = целевой rooms (exact-match
-- byte-identical поведение). estimate_quality() расширяет диапазон (студия↔1-
-- комн / ±1 комната) ТОЛЬКО когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N.
AND rooms BETWEEN :rooms_min AND :rooms_max
AND area_m2 BETWEEN :area_min AND :area_max
AND is_active = true
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND price_rub > 0
-- Когортный фильтр (PR 10): применяется к Tier S и Tier H через _COMMON_WHERE.
-- Tier W имеет свою копию этого блока в inline SQL. Если cohort_year_min IS NULL —
-- фильтр прозрачен. CAST обязателен — psycopg3 prepared statement не выводит
-- тип $N при IS NULL в predicate (см. PR #518 fix).
AND (
CAST(:cohort_year_min AS integer) IS NULL
OR year_built IS NULL
OR year_built BETWEEN CAST(:cohort_year_min AS integer)
AND CAST(:cohort_year_max AS integer)
)
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
-- Исключаем новостройки из comp-пула вторички: девелоперский прайс искажает
-- медиану ₽/м². NULL сегмент пропускаем (rosreestr/avito/yandex без сегмента —
-- это вторичка или неклассифицированный объект). #oblast-F (c): allow_novostroyki
-- пробрасывается как последняя-по-очереди relaxation-ступень (estimate_quality) —
-- дефолт False сохраняет канон-guard byte-identical.
AND (
CAST(:allow_novostroyki AS boolean) IS TRUE
OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
)
-- #2012 is_apartments hard-filter (флаг estimate_is_apartments_filter_enabled,
-- default OFF pending backtest). Флаг выключен ⇒ CAST(... ) IS NOT TRUE ⇒
-- условие прозрачно (byte-identical старому поведению). Включён ⇒ исключает
-- ТОЛЬКО явные is_apartments=true (апартаменты — иной ценовой/юридический
-- сегмент). NULL (подавляющее большинство строк, sparse coverage) проходит
-- без штрафа — не наказываем отсутствие данных.
AND (
CAST(:is_apartments_filter AS boolean) IS NOT TRUE
OR is_apartments IS NULL
OR is_apartments = false
)
"""
# Note: Tier W has its own inline copy of the cohort clause (PR #519 line
# ~1280). Не удалять — Tier W не использует _COMMON_WHERE из-за inline
# relevance_score CASE expressions. Both code paths must stay in sync.
# ── #1871 P2: radius-tier (source, source_id) dedup ──────────────────────────
# Окно rn_dup в base-CTE каждого radius-тира: (source, source_id)-дубли делят
# один address и переживают per-address rn_addr cap → раздувают n_analogs.
# Anchor-путь дедупит по (source, source_id) (:1556); radius — нет. Ключ:
# source + (id:source_id | url:source_url | ctid:ctid) namespaced-тегом (защита
# от value-collision между id и url, симметрично anchor dedup). freshest по
# scraped_at. ВСЕ тиры читают FROM listings (физическая таблица, без JOIN) →
# source_id / source_url / ctid доступны в base-CTE даже без явного SELECT.
# psycopg3: source_id::text / ctid::text — это COLUMN::type (не bind-параметр),
# работает; запрещён только :name::type на bind-параметрах (их тут нет).
_RN_DUP_WINDOW = """
row_number() OVER (
PARTITION BY source,
CASE
WHEN source_id IS NOT NULL THEN 'id:' || source_id::text
WHEN source_url IS NOT NULL THEN 'url:' || source_url
ELSE 'ctid:' || ctid::text
END
ORDER BY scraped_at DESC
) AS rn_dup"""
def _fetch_analogs(
db: Session,
*,
lat: float,
lon: float,
rooms: int,
area: float,
radius_m: int,
full_address: str | None = None,
area_tolerance: float = AREA_TOLERANCE,
year_built: int | None = None,
house_type: str | None = None,
total_floors: int | None = None,
cohort_year_min: int | None = None, # NEW: lower bound year_built inclusive
cohort_year_max: int | None = None, # NEW: upper bound year_built inclusive
target_house_id: int | None = None, # #6: canonical house for same-building Tier S
# #oblast-F (never-block relaxation cascade) — все три опциональны, дефолты
# byte-identical старому поведению (exact rooms match / 14 дней / без
# новостроек). estimate_quality() передаёт неполные (widened) значения ТОЛЬКО
# когда базовая выборка тоньше HEADLINE_LISTINGS_MIN_N — см. module docstring.
rooms_min: int | None = None, # #oblast-F (a): None → эффективно = rooms
rooms_max: int | None = None, # #oblast-F (a): None → эффективно = rooms
fresh_days: int = LISTINGS_FRESH_DAYS, # #oblast-F (b): relaxed = LISTINGS_FRESH_DAYS_RELAXED
allow_novostroyki: bool = False, # #oblast-F (c)
) -> tuple[list[dict[str, Any]], bool, str]:
"""SELECT аналогов — трёхуровневый house-match (S → H → W).
**Tier S (same building):** сначала канонический match по house_id_fk
(если задан target_house_id) — детерминированно «тот же дом»; fallback —
normalized street + numeric-boundary house-number regex (см.
_normalize_building_key + _house_boundary_regex, тот же принцип что и anchor
Tier A) + ST_DWithin(radius_m). Если ≥3 результатов → tier='S'.
**Tier H (same class):** PostGIS + rooms + area + year ±15 + total_floors ±30%.
Если ≥5 результатов → возвращаем; tier='H'.
Пропускается если year_built или total_floors неизвестны.
**Tier W (wide / current):** текущая логика без year/floors WHERE фильтра.
tier='W'.
House-match relevance_score используется для сортировки в Tier H и W.
Стратифицированная выборка (Approach B):
1. SQL вытягивает до 300 кандидатов с per-address row_number (cap MAX_ANALOGS_PER_ADDRESS).
2. Python гарантирует MIN_ANALOGS_PER_SOURCE слотов каждому live source.
3. Оставшиеся слоты заполняются из остальных кандидатов по relevance.
4. Итоговый список отсортирован по relevance, LIMIT 50.
Когортный фильтр (PR 9): если переданы cohort_year_min/max — добавляется
hard-filter WHERE year_built BETWEEN min AND max OR year_built IS NULL.
NULL допускается чтобы не отсеивать листинги с неизвестным годом
(типично для Avito anonymous-address объявлений).
#2012 comp-scoring (все флаги default OFF, см. config.py):
- is_apartments hard-filter в WHERE (все тиры, симметрично novostroyki-guard).
- kitchen_area_m2 / ceiling_height_m soft self-referential pool-median
penalty в Tier H/W (не Tier S) — см. _apply_kitchen_ceiling_signal.
Returns:
(list_of_listings_as_dicts, fallback_radius_used_flag, tier)
tier: 'S' | 'H' | 'W'
"""
area_min = area * (1 - area_tolerance)
area_max = area * (1 + area_tolerance)
# #oblast-F (a): None → эффективно exact-match (rooms_min=rooms_max=rooms),
# byte-identical старому `rooms = :rooms`. Caller (estimate_quality) passes a
# widened range only past HEADLINE_LISTINGS_MIN_N thinness.
eff_rooms_min = rooms if rooms_min is None else rooms_min
eff_rooms_max = rooms if rooms_max is None else rooms_max
# #1871 P2: (source, source_id) dedup в radius-тирах. rn_dup-окно всегда в SQL
# (безвредно без фильтра); статический фрагмент управляет только применением
# `AND rn_dup = 1` в outer WHERE. Это SQL-литерал (static), НЕ data — psycopg3
# bind-параметры не задействованы, инъекции нет.
dup_filter = "AND rn_dup = 1"
base_params: dict[str, Any] = {
"rooms_min": eff_rooms_min,
"rooms_max": eff_rooms_max,
"area_min": area_min,
"area_max": area_max,
"fresh_days": fresh_days,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"cohort_year_min": cohort_year_min,
"cohort_year_max": cohort_year_max,
# #2012: is_apartments hard-filter — see _COMMON_WHERE comment above.
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled,
# #oblast-F (c): allow_novostroyki — see _COMMON_WHERE comment above.
"allow_novostroyki": allow_novostroyki,
}
# ── Tier S (canonical): same building via house_id_fk ─────────────────────
# #6: детерминированный «тот же дом» через канонический house-граф. 99%
# listings слинкованы (listings.house_id_fk → houses.id), что несравнимо
# надёжнее address-string match'а между разнородными источниками
# (Avito/Cian/Yandex форматируют адрес по-разному). Если target_house_id
# неизвестен или результатов <3 — падаем на address-prefix Tier S ниже.
if target_house_id is not None:
tier_sc_params = {**base_params, "target_house_id": target_house_id}
tier_sc_rows = (
db.execute(
text(
f"""
WITH base AS (
SELECT
{_ANALOG_SELECT_COLS},
0.0 AS distance_m,
0.0 AS relevance_score,
row_number() OVER (
PARTITION BY address ORDER BY scraped_at DESC
) AS rn_addr,
{_RN_DUP_WINDOW}
FROM listings
WHERE house_id_fk = CAST(:target_house_id AS bigint)
{_COMMON_WHERE}
)
SELECT
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at, distance_m, relevance_score,
building_cadastral_number
FROM base
WHERE rn_addr <= :max_per_addr
{dup_filter}
ORDER BY scraped_at DESC
LIMIT 300
"""
),
tier_sc_params,
)
.mappings()
.all()
)
tier_sc = [dict(r) for r in tier_sc_rows]
if len(tier_sc) >= 3:
logger.info(
"analogs tier=S(canonical) house_id=%s%d results",
target_house_id,
len(tier_sc),
)
return _stratify_candidates(tier_sc), radius_m > DEFAULT_RADIUS_M, "S"
# ── Tier S (fallback): same building via normalized street + house-number ──
# #oblast-D geo-bound fix: a bare address-prefix match can collide with an
# IDENTICALLY-NAMED street+house-number in a COMPLETELY DIFFERENT city (e.g.
# "улица Ленина, 100" exists verbatim in Берёзовский — ~40 191 of ~40 200
# active listings are EKB-region, so any such collision silently resolves
# to a distant EKB listing). A Нижний Тагил / Серов / Каменск subject then
# got "same building" comps from a listing ~100+ km away. Tier S's OWN
# semantics ("same building") already imply the match must be near the
# subject, so we require ST_DWithin on the SAME radius_m the caller passed
# in (mirrors Tier H/W below) — this can only DROP false-positive
# cross-city matches, never legitimate same-building EKB hits (>99.9% of
# active listings carry lat/lon).
#
# Numeric-boundary fix (audit finding): the OLD implementation matched
# `address ILIKE short_addr + '%'` — a bare prefix with NO boundary after
# the house number, so "ул. Ленина, 5%" collided with "..., 50", "..., 51",
# "..., 500", "..., 5а" (a DIFFERENT building one street-address away).
# ONE such stray building could push len(tier_s) >= 3 and silently poison
# the same-building median. Reuse the anchor Tier A machinery instead
# (_normalize_building_key + _house_boundary_regex, estimator.py ~1741) —
# already proven/tested there — so the fallback requires the SAME
# numeric-boundary regex match, not just a naive string prefix.
street, base_no, letter = _normalize_building_key(full_address)
if street and base_no is not None:
house_re = _house_boundary_regex(base_no, letter)
tier_s_params = {
**base_params,
"street_like": "%" + street + "%",
"house_re": house_re,
"lat": lat,
"lon": lon,
"radius": radius_m,
}
tier_s_rows = (
db.execute(
text(
f"""
WITH base AS (
SELECT
{_ANALOG_SELECT_COLS},
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
AS distance_m,
0.0 AS relevance_score,
row_number() OVER (
PARTITION BY address ORDER BY scraped_at DESC
) AS rn_addr,
{_RN_DUP_WINDOW}
FROM listings
WHERE lower(translate(address, 'ёЁ', 'ее')) LIKE :street_like
AND lower(translate(address, 'ёЁ', 'ее')) ~ :house_re
AND ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
{_COMMON_WHERE}
)
SELECT
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at, distance_m, relevance_score,
building_cadastral_number
FROM base
WHERE rn_addr <= :max_per_addr
{dup_filter}
ORDER BY scraped_at DESC
LIMIT 300
"""
),
tier_s_params,
)
.mappings()
.all()
)
tier_s = [dict(r) for r in tier_s_rows]
if len(tier_s) >= 3:
logger.info(
"analogs tier=S street=%r base=%s letter=%s%d results",
street,
base_no,
letter,
len(tier_s),
)
return _stratify_candidates(tier_s), radius_m > DEFAULT_RADIUS_M, "S"
# ── Tier H: same class (year ±15, total_floors ±30%) ─────────────────────
if year_built is not None and total_floors is not None:
year_min = year_built - 15
year_max = year_built + 15
tf_min = math.floor(total_floors * 0.7)
tf_max = math.ceil(total_floors * 1.3)
tier_h_rows = (
db.execute(
text(
f"""
WITH base AS (
SELECT
{_ANALOG_SELECT_COLS},
id,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
AS distance_m,
(
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
/ 1000.0
+ CASE
WHEN year_built IS NOT NULL
THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
ELSE 0
END
+ CASE
WHEN CAST(:target_house_type AS text) IS NOT NULL
AND house_type IS NOT NULL
AND house_type <> CAST(:target_house_type AS text)
THEN 1.5
ELSE 0
END
) AS relevance_score,
row_number() OVER (
PARTITION BY address
ORDER BY (
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
/ 1000.0
+ CASE
WHEN year_built IS NOT NULL
THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
ELSE 0
END
+ CASE
WHEN CAST(:target_house_type AS text) IS NOT NULL
AND house_type IS NOT NULL
AND house_type <> CAST(:target_house_type AS text)
THEN 1.5
ELSE 0
END
)
) AS rn_addr,
{_RN_DUP_WINDOW}
FROM listings
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
AND (geo_precision IS DISTINCT FROM 'city')
{_COMMON_WHERE}
AND total_floors BETWEEN CAST(:tf_min AS integer)
AND CAST(:tf_max AS integer)
AND year_built BETWEEN CAST(:year_min AS integer)
AND CAST(:year_max AS integer)
)
SELECT
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at, distance_m, relevance_score,
building_cadastral_number,
kitchen_area_m2, ceiling_height_m,
-- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует
-- внутренний, без этого Python-слой не видит год и тип дома.
year_built, house_type
FROM base
WHERE rn_addr <= :max_per_addr
{dup_filter}
-- Детерминированный тай-брейкер: при равном relevance_score (один
-- distance-bucket + year delta + house_type → идентичный score)
-- Postgres возвращал бы строки в произвольном порядке, и тот же
-- /analyze давал бы разные комп-сэты между запусками. distance_m
-- ASC → scraped_at DESC (свежее) → id ASC (PK, гарантирует total
-- order) делают выборку воспроизводимой.
ORDER BY relevance_score ASC, distance_m ASC, scraped_at DESC NULLS LAST, id ASC
LIMIT 300
"""
),
{
**base_params,
"lat": lat,
"lon": lon,
"radius": radius_m,
"target_year": year_built,
"target_house_type": house_type,
"tf_min": tf_min,
"tf_max": tf_max,
"year_min": year_min,
"year_max": year_max,
},
)
.mappings()
.all()
)
tier_h = [dict(r) for r in tier_h_rows]
_apply_kitchen_ceiling_signal(tier_h)
if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF
_apply_unknown_attr_penalty(
tier_h,
target_year=year_built,
target_house_type=house_type,
min_n=settings.estimate_unknown_attr_penalty_min_n,
)
tier_h.sort(key=lambda r: r.get("relevance_score") or 0.0)
if len(tier_h) >= 5:
logger.info(
"analogs tier=H year=%d±15 tf=%d-%d%d results",
year_built,
tf_min,
tf_max,
len(tier_h),
)
return _stratify_candidates(tier_h), radius_m > DEFAULT_RADIUS_M, "H"
logger.info(
"analogs tier=H year=%d±15 tf=%d-%d → only %d (fallthrough to W)",
year_built,
tf_min,
tf_max,
len(tier_h),
)
# ── Tier W: wide (current logic, year/floors only in relevance sort) ──────
tier_w_rows = (
db.execute(
text(
f"""
WITH base AS (
SELECT
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at,
building_cadastral_number,
kitchen_area_m2, ceiling_height_m,
-- #2936: Tier W строит base явным списком (не _ANALOG_SELECT_COLS),
-- поэтому год и тип дома добавляются здесь отдельно.
year_built, house_type,
id,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
AS distance_m,
(
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
/ 1000.0
-- CAST обязателен: target_year / target_house_type приходят NULL
-- без типа → PostgreSQL "could not determine data type of parameter"
-- (AmbiguousParameter). Явный тип снимает неоднозначность.
+ CASE
WHEN CAST(:target_year AS integer) IS NOT NULL
AND year_built IS NOT NULL
THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
ELSE 0
END
+ CASE
WHEN CAST(:target_house_type AS text) IS NOT NULL
AND house_type IS NOT NULL
AND house_type <> CAST(:target_house_type AS text)
THEN 1.5
ELSE 0
END
) AS relevance_score,
row_number() OVER (
PARTITION BY address
ORDER BY (
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography)
/ 1000.0
+ CASE
WHEN CAST(:target_year AS integer) IS NOT NULL
AND year_built IS NOT NULL
THEN abs(year_built - CAST(:target_year AS integer)) / 12.0
ELSE 0
END
+ CASE
WHEN CAST(:target_house_type AS text) IS NOT NULL
AND house_type IS NOT NULL
AND house_type <> CAST(:target_house_type AS text)
THEN 1.5
ELSE 0
END
)
) AS rn_addr,
{_RN_DUP_WINDOW}
FROM listings
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
AND (geo_precision IS DISTINCT FROM 'city')
-- #oblast-F (a): sync с _COMMON_WHERE — см. комментарий там же.
AND rooms BETWEEN :rooms_min AND :rooms_max
AND area_m2 BETWEEN :area_min AND :area_max
AND is_active = true
AND scraped_at > NOW() - (:fresh_days || ' days')::interval
AND price_rub > 0
-- Когортный фильтр (PR 9): отсеивает разные эпохи застройки
-- (хрущёвка vs новостройка). Если cohort_year_min IS NULL —
-- фильтр прозрачен. CAST обязателен — psycopg3 prepared statement
-- не выводит тип $N при IS NULL в predicate (см. PR #518 fix).
AND (
CAST(:cohort_year_min AS integer) IS NULL
OR year_built IS NULL
OR year_built BETWEEN CAST(:cohort_year_min AS integer)
AND CAST(:cohort_year_max AS integer)
)
-- novostroyki guard (#1186): NULL = legacy вторичка до м.011
-- Tier W: исключаем новостройки из comp-пула (sync с _COMMON_WHERE).
-- #oblast-F (c): allow_novostroyki relaxation, sync с _COMMON_WHERE.
AND (
CAST(:allow_novostroyki AS boolean) IS TRUE
OR (listing_segment IS NULL OR listing_segment = 'vtorichka')
)
-- #2012 is_apartments hard-filter, sync с _COMMON_WHERE (см. комментарий
-- там же). Флаг выключен ⇒ прозрачно (byte-identical старому поведению).
AND (
CAST(:is_apartments_filter AS boolean) IS NOT TRUE
OR is_apartments IS NULL
OR is_apartments = false
)
-- 2026-05-23: Avito coords теперь real (PR #487 убрал jitter после
-- C-5 audit). Listings с NULL coords отфильтруются через ST_DWithin
-- (geom IS NULL → не matches). geocode-missing-listings backfill
-- подтягивает координаты для address-only Avito листингов.
-- #769 Part E: geo_precision='city' исключает city-centroid листинги
-- из radius-аналогов (centroid загрязнял comp set при ST_DWithin).
-- IS DISTINCT FROM 'city' пропускает NULL (неизвестная точность —
-- консервативно: листинг участвует в аналогах, не удаляем без причины).
)
SELECT
source, source_url, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2,
listing_date, days_on_market, photo_urls,
scraped_at,
building_cadastral_number,
kitchen_area_m2, ceiling_height_m,
-- #2936: see _ANALOG_SELECT_COLS — внешний список дублирует
-- внутренний, без этого Python-слой не видит год и тип дома.
year_built, house_type,
distance_m,
relevance_score
FROM base
WHERE rn_addr <= :max_per_addr
{dup_filter}
-- Детерминированный тай-брейкер: симметрично Tier H. При равном
-- relevance_score Postgres возвращал бы строки в произвольном порядке
-- → недетерминированная оценка. distance_m ASC → scraped_at DESC
-- (свежее) → id ASC (PK, total order) делают выборку воспроизводимой.
ORDER BY relevance_score ASC, distance_m ASC, scraped_at DESC NULLS LAST, id ASC
LIMIT 300
"""
),
{
"lat": lat,
"lon": lon,
"radius": radius_m,
"rooms_min": eff_rooms_min,
"rooms_max": eff_rooms_max,
"area_min": area_min,
"area_max": area_max,
"fresh_days": fresh_days,
"target_year": year_built,
"target_house_type": house_type,
"max_per_addr": MAX_ANALOGS_PER_ADDRESS,
"cohort_year_min": cohort_year_min, # NEW
"cohort_year_max": cohort_year_max, # NEW
"is_apartments_filter": settings.estimate_is_apartments_filter_enabled, # #2012
"allow_novostroyki": allow_novostroyki, # #oblast-F (c)
},
)
.mappings()
.all()
)
candidates: list[dict[str, Any]] = [dict(r) for r in tier_w_rows]
_apply_kitchen_ceiling_signal(candidates)
if settings.estimate_unknown_attr_penalty_enabled: # #2936, default OFF
_apply_unknown_attr_penalty(
candidates,
target_year=year_built,
target_house_type=house_type,
min_n=settings.estimate_unknown_attr_penalty_min_n,
)
candidates.sort(key=lambda r: r.get("relevance_score") or 0.0)
logger.info("analogs tier=W radius=%dm → %d candidates", radius_m, len(candidates))
return _stratify_candidates(candidates), radius_m > DEFAULT_RADIUS_M, "W"
def _is_plausible_deal(
price_per_m2: float | None,
floor: int | None,
total_floors: int | None,
area_m2: float | None = None,
price_rub: float | None = None,
city: str | None = None,
bands: dict[str, tuple[int, int]] | None = None,
) -> bool:
"""#699 + Mera-audit fix-2 + #2478: True если ДКП-сделка правдоподобна (не выброс).
Абсолютные guard-bands (см. DEAL_* константы). None-поля не судим (keep —
нечем сравнивать). Проверки:
- price_per_m2 вне [ppm_min, ppm_max] → drop, где (ppm_min, ppm_max) =
bands.get(city, (DEAL_MIN_PPM2, DEAL_MAX_PPM2)) — #2478: per-city band
(deal_city_price_bands, миграция 178) для не-ЕКБ городов области, где
глобальный пол 50k ₽/м² режет легитимно дешёвый рынок. city не в bands
(в т.ч. Екатеринбург — НАМЕРЕННО не в таблице) или bands=None →
fallback на глобальные DEAL_MIN_PPM2/DEAL_MAX_PPM2 (byte-identical
сегодняшнему поведению).
- floor < 1 или floor > DEAL_MAX_FLOOR → drop (битый парсер: floor=-5/999)
- floor > total_floors физически невозможен → drop
- area_m2 задана и <= 0 → drop (битый парсер)
- price_rub задана и <= 0 → drop (нерыночная/технческая сделка)
"""
ppm_min, ppm_max = (bands or {}).get(city, (DEAL_MIN_PPM2, DEAL_MAX_PPM2))
if price_per_m2 is not None and not (ppm_min <= price_per_m2 <= ppm_max):
return False
if floor is not None:
if floor < 1 or floor > DEAL_MAX_FLOOR:
return False
if total_floors is not None and total_floors > 0 and floor > total_floors:
return False
if area_m2 is not None and area_m2 <= 0:
return False
if price_rub is not None and price_rub <= 0:
return False
return True
def _fetch_deals(
db: Session, *, lat: float, lon: float, rooms: int, area: float, radius_m: int
) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT
source, address, lat, lon,
rooms, area_m2, floor, total_floors,
price_rub, price_per_m2, city,
deal_date, days_on_market,
cadastral_number,
ST_Distance(geom::geography, ST_MakePoint(:lon, :lat)::geography) AS distance_m
FROM deals
WHERE ST_DWithin(geom::geography, ST_MakePoint(:lon, :lat)::geography, :radius)
AND rooms = :rooms
AND area_m2 BETWEEN :area_min AND :area_max
AND deal_date > NOW() - (:months || ' months')::interval
AND price_rub > 0
ORDER BY deal_date DESC
LIMIT 30
"""
),
{
"lat": lat,
"lon": lon,
"radius": radius_m,
"rooms": rooms,
"area_min": area * (1 - AREA_TOLERANCE),
"area_max": area * (1 + AREA_TOLERANCE),
"months": DEALS_PERIOD_MONTHS,
},
)
.mappings()
.all()
)
# #699 + Mera-audit fix-2: отсекаем ДКП-выбросы (битый этаж / нерыночный ppm²
# / нулевая площадь / нулевая цена) до выдачи в actual_deals и expected_sold.
# #2478: per-city ppm² band (deal_city_price_bands) вместо глобального
# DEAL_MIN_PPM2/MAX_PPM2 — грузим один раз на вызов, graceful {} при ошибке.
bands = _load_city_price_bands(db)
deals = [dict(r) for r in rows]
clean = [
d
for d in deals
if _is_plausible_deal(
d.get("price_per_m2"),
d.get("floor"),
d.get("total_floors"),
d.get("area_m2"),
d.get("price_rub"),
city=d.get("city"),
bands=bands,
)
]
if len(clean) < len(deals):
logger.info("deals sanitize #699: %d%d (dropped outliers)", len(deals), len(clean))
return clean
def _apply_corridor_clamp(
*,
median_ppm2: float,
median_price: int,
range_low: int,
range_high: int,
corridor_high_ppm2: int,
corridor_count: int,
anchor_tier: str | None,
slack: float,
min_n: int,
) -> tuple[float, int, int, int, bool]:
"""#1795 шаг 1: soft-кламп headline к коридору реальных ДКП-сделок Росреестра.
Чистая (testable без БД) функция. Прижимает headline к
cap = corridor_high_ppm2 × (1 + slack), когда ВСЕ условия:
- median_ppm2 > cap (headline выше потолка коридора + slack),
- corridor_count >= min_n (достаточно сделок для доверия коридору),
- anchor_tier != "A" (Tier A = реальные комплы ТОГО ЖЕ дома → EXEMPT).
Тогда price/range_low/range_high пересчитываются ПРОПОРЦИОНАЛЬНО
(множитель = cap / median_ppm2), чтобы asking↔sold↔range остались
консистентны. Возвращает (median_ppm2, median_price, range_low,
range_high, clamped: bool). clamped=False → значения не изменены
(no-op: в коридоре, Tier A или мало сделок).
"""
if anchor_tier == "A":
# Tier A — реальные комплы того же дома: коридор по улице может быть
# систематически ниже (старые/малометражные сделки) → не клампим.
return median_ppm2, median_price, range_low, range_high, False
if corridor_count < min_n or corridor_high_ppm2 <= 0 or median_ppm2 <= 0:
return median_ppm2, median_price, range_low, range_high, False
cap = corridor_high_ppm2 * (1.0 + slack)
if median_ppm2 <= cap:
# В коридоре (с учётом slack) — no-op (эконом/комфорт сюда не попадают).
return median_ppm2, median_price, range_low, range_high, False
factor = cap / median_ppm2
new_ppm2 = cap
new_price = round(median_price * factor)
new_low = round(range_low * factor)
new_high = round(range_high * factor)
return new_ppm2, new_price, new_low, new_high, True
# ── #2087 H4 / #2265: кросс-source физический дедуп аналогов ──────────────────
# Radius-дедуп (_RN_DUP_WINDOW) ловит только повторы ВНУТРИ одного source
# (source, source_id). Один физический лот, кросс-запостенный на avito+cian+
# domklik, имеет РАЗНЫЕ source → переживает radius-дедуп → считается несколько
# раз → раздувает n_analogs И cv (→ шире коридор), может смещать медиану.
# _dedup_cross_source схлопывает такие дубли по физическому ключу ДО агрегации.
#
# #2265: адресный fallback-ключ ужесточён. Раньше building = lower + схлопнутая
# пунктуация ВСЕГО адреса → три source-формата одного дома дают три разных
# building-компонента и один физлот считается тремя. Прод-пример (оценка
# d5fc3841): один лот 33.6 м²/3 этаж/4.0М на ул. Сыромолотова показан как 3
# аналога, т.к. cadnum пуст у всех троих, а адреса форматированы по-разному:
# cian «р-н Кировский, мкр. ЖБИ, улица Сыромолотова, …»
# domklik «Россия, Екатеринбург, ЖБИ м-н, улица Сыромолотова…»
# yandex «Екатеринбург, улица Сыромолотова, 11»
# domklik building_cadastral_number ВСЕГДА пуст (0/6296), yandex/avito/cian —
# 17/29/33% → cadnum-ключ между источниками тоже не совпадает.
#
# Фикс: из адреса извлекаем нормализованный street_token (тип улицы + город +
# район + «Россия» выброшены) и строим композит (street_token, floor, area,
# price). house_no СОЗНАТЕЛЬНО не входит в equality-ключ — он присутствует
# непоследовательно (yandex несёт номер, cian/domklik часто нет), включение
# помешало бы слить именно тот кросс-пост, ради которого дедуп существует.
# Разные физлоты остаются разными через floor/area/price (требование «не
# over-merge»). Дедуп union-find по ЛЮБОМУ совпавшему ключу (cadnum-композит ИЛИ
# street-композит) → закрывает и «у одного cadnum есть, у другого нет, адрес
# совпадает». Два РАЗНЫХ кадастра (авторитетный сигнал разных зданий) НЕ
# сливаются, даже если street-композит совпал.
_DEDUP_PRICE_BUCKET_RUB = 100_000
# Типы улиц (для отсечения от названия). District-маркеры (мкр/м-н/квартал/
# район) СЮДА НЕ входят — иначе «мкр. ЖБИ, улица Сыромолотова» матчилось бы на
# «мкр ЖБИ» вместо реальной улицы; они убираются как шум в _DEDUP_ADDR_NOISE_WORDS.
# NB: имена с префиксом _DEDUP_ — чтобы НЕ конфликтовать с модульным
# _STREET_NAME_RE (extract_street_name, другой парсер выше).
_DEDUP_STREET_TYPE_WORDS = (
"улица",
"ул",
"переулок",
"пер",
"проспект",
"просп",
"пр-кт",
"пр-т",
"пр",
"бульвар",
"б-р",
"бул",
"проезд",
"шоссе",
"ш",
"набережная",
"наб",
"площадь",
"пл",
"аллея",
"тупик",
"тракт",
"линия",
)
# Длинные типы раньше коротких, чтобы «улица» матчилось раньше «ул».
_DEDUP_STREET_TYPE_ALT = "|".join(
re.escape(w) for w in sorted(_DEDUP_STREET_TYPE_WORDS, key=len, reverse=True)
)
# «<тип>[.] <название>»: название = опциональная ведущая цифро-группа + 1-3
# буквенных слова, до запятой/цифры. Ведущая цифра нужна для нумерованных улиц
# («8 Марта», «40 лет Октября», «22 Партсъезда»); она разрешена ТОЛЬКО перед
# буквенным словом (?:\d+\s+)?[а-яё]…, поэтому НОМЕР ДОМА (число ПОСЛЕ названия,
# не сопровождаемое буквой) в токен не попадает: «Белинского 86» → «белинского».
# #2269 (б): тип-суффикс может быть приклеен запятой/точкой без пробела
# («ул.,6к1»): после `\.?` разрешаем ЛИБО пробел (обычный формат), ЛИБО запятую
# (→ следом номер дома). Ветка «тип ПЕРЕД именем» — только для формата с пробелом.
_DEDUP_STREET_NAME_RE = re.compile(
rf"(?:^|[,\s])(?:{_DEDUP_STREET_TYPE_ALT})\.?\s+"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
flags=re.IGNORECASE,
)
# #2269 (а): «<название> <тип>» — тип-слово ПОСЛЕ имени (формат domklik:
# «Рассветная улица, 6 к1»; avito с глухим суффиксом: «Рассветная ул.,6к1»).
# Название — 1-3 буквенных слова (+ опц. ведущая цифра нумерованной улицы) на
# границе запятой/начала строки, СРАЗУ перед типом. Тип завершается концом
# слова / точкой / запятой / пробелом — так «Рассветная ул.,6к1» и «Рассветная
# улица, 6» дают тот же токен «рассветная», что ветка-перед-именем на «улица
# Рассветная». Тип НЕ входит в захват (group 1) — токен байт-в-байт совпадает.
_DEDUP_STREET_NAME_SUFFIX_RE = re.compile(
r"(?:^|[,\s])"
r"((?:\d+\s+)?[а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
rf"(?:{_DEDUP_STREET_TYPE_ALT})(?=\.|,|\s|$)",
flags=re.IGNORECASE,
)
# #2269 (в): fallback без уличного типа — «микрорайон/мкр/мкр-н/м-н <имя>» или
# «<имя> микрорайон». Имя = 1-3 буквенных слова. Токен получает префикс «mkr »,
# чтобы НЕ пересекаться с уличными токенами (микрорайон ≠ одноимённая улица).
_DEDUP_MKR_WORDS_ALT = "|".join(
re.escape(w) for w in sorted(("микрорайон", "мкр-н", "мкр", "м-н"), key=len, reverse=True)
)
_DEDUP_MKR_PREFIX_RE = re.compile(
rf"(?:^|[,\s])(?:{_DEDUP_MKR_WORDS_ALT})\.?\s+" r"([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})",
flags=re.IGNORECASE,
)
_DEDUP_MKR_SUFFIX_RE = re.compile(
r"(?:^|[,\s])"
r"([а-яё][а-яё\-]+(?:\s+[а-яё][а-яё\-]+){0,2})\s+"
rf"(?:{_DEDUP_MKR_WORDS_ALT})(?=\.|,|\s|$)",
flags=re.IGNORECASE,
)
# Номер дома = первое число ПОСЛЕ названия улицы (опц. «д.»/«дом»), с литерой/
# корпусом («46», «8а», «12/3», «12к3»). Ищется в остатке строки от конца
# street-матча — ведущая цифра нумерованной улицы («8 Марта») сюда не попадает.
# #2423: суффикс-буква («46г», «8а») засчитывается только если ЗА НЕЙ не идёт
# ЕЩЁ ОДИН word-символ (`(?!\w)` — буква ИЛИ цифра), т.е. только если это
# последний word-символ токена. Иначе это не литера дома, а первая буква
# приклеенного без пробела слова (название станции метро в avito:
# «2Чкаловская11-15 мин» → дом «2», а не «15» из хвоста дистанции).
# #2436 (фикс регрессии #2429): корпус-словоформа («к»/«корп»/«корпус», глухо
# ИЛИ с пробелом, ЗА КОТОРОЙ идёт цифра корпуса) — отдельная альтернатива,
# пробуется ДО bare-letter-альтернативы, чтобы захватить ВЕСЬ токен «65к4»/
# «65 к4» целиком (а не только «65», как раньше — #2429 отклонял букву через
# `(?!\w)`, но не восстанавливал отброшенную цифру корпуса). Раздельные
# source-нотации корпуса («65к4» avito/cian glued, «65/4» domklik slash)
# канонизируются ПОСЛЕ этого regex-а через `_normalize_house_no` (см. ниже) —
# сам regex лишь ГАРАНТИРУЕТ, что сырая корпус-подстрока захвачена целиком, не
# усечена; реформатирование в единый «N/M» — забота нормализатора.
# Trailing `\b` намеренно убран: он ложно "отменял" весь матч цифры, когда
# сразу за ней шло кириллическое слово без разделителя (Cyrillic letter и
# digit — оба `\w`, между ними нет word-boundary).
_DEDUP_HOUSE_NO_RE = re.compile(
r"\b(?:д(?:ом)?\.?\s*)?"
r"(\d+(?:\s*/\s*\d+"
r"|\s*,?\s*к(?:орп(?:ус)?)?\.?\s*\d+"
r"|[а-яё](?!\w))?)",
flags=re.IGNORECASE,
)
# Слова-маркеры дома, которые могли попасть в захват названия (напр. «ленина д 5»).
_DEDUP_HOUSE_MARKER_WORDS = frozenset(
{"дом", "д", "корпус", "корп", "к", "строение", "стр", "литера", "лит", "владение", "вл"}
)
# Шум-токены (город/страна/район/микрорайон) на случай, если попали в название.
# (oblast C2): "екатеринбург" был единственным city-словом здесь (hardcoded)
# — генерализовано на весь регион 66 через _OBLAST_CITY_TOKENS (derived из
# geocoder.SVERDLOVSK_OBLAST_CITIES, см. определение выше _CITY_TOKENS), плюс
# admin/generic-шум ниже. _parse_street_house уже делает s.replace("ё","е") ДО
# токенизации (см. выше) — _OBLAST_CITY_TOKENS тоже нормализован на «е».
_DEDUP_ADDR_NOISE_WORDS = _OBLAST_CITY_TOKENS | frozenset(
{
"россия",
"рф",
"г",
"город",
"область",
"обл",
"район",
"р",
"мкр",
"м-н",
"микрорайон",
"квартал",
}
)
# #2291 (класс 1): токен-множества для bare-street-ветки (см. ниже) — прямая
# проверка отдельных слов сегмента, без построения регэкспа.
_DEDUP_MKR_WORDS = frozenset({"микрорайон", "мкр-н", "мкр", "м-н"})
_DEDUP_STREET_TYPE_WORDS_SET = frozenset(_DEDUP_STREET_TYPE_WORDS)
def _bare_street_before_house_segment(s: str) -> tuple[str, str] | None:
"""Bare-street ветка (#2291, класс 1): «<мкр-сегмент>, <имя>, <номер>».
Root cause: `_DEDUP_MKR_SUFFIX_RE` матчит «<имя> м-н/мкр» ГДЕ УГОДНО в
строке. Для DomClick-формата «Заречный м-н, Готвальда, 24» — голая улица
БЕЗ типа-слова после района — ветки 1/2 (требуют тип-слово) молчат, и
mkr-fallback (ветка 3) неверно берёт ключ по РАЙОНУ («mkr заречный»)
вместо реальной улицы «готвальда» → лот никогда не сливается с avito/cian
того же дома, где «готвальда» распознаётся корректно.
Разбивает адрес по запятым; ищет ПЕРВЫЙ сегмент, ЦЕЛИКОМ совпадающий с
`_DEDUP_HOUSE_NO_RE` (номер дома). Сегмент НЕПОСРЕДСТВЕННО перед ним — 1-3
слова, без мкр-слова/уличного типа/шум-токена — кандидат в bare-улицу.
#2291 over-merge guard (deep-review, blocking): ветка активируется ТОЛЬКО
для 3+ сегментного district-chain паттерна — обязателен ЕЩЁ более ранний
сегмент (до кандидата-улицы), который сам содержит мкр/район-слово
(`_DEDUP_MKR_WORDS`). Без этого условия голые 2-сегментные адреса без
какого-либо mkr-контекста («ВИЗ, 24», «Академический, 24», «ЖК Некий,
24» — частый неформальный ЕКБ-шорткат в объявлениях) фабриковали бы
уличный токен из названия района/ЖК и рисковали ложным cross-merge двух
РАЗНЫХ физлотов. Такие адреса должны вернуть None (решают ветки 1/2/3;
итог — безопасный `("", "")`, как и до всей этой ветки).
Не найдено/не подходит → None (решают ветки 1/2/3, в частности mkr-fallback
для «мкр Пионерский, 5», где сегмент перед номером содержит мкр-слово).
"""
segments = s.split(",")
for idx in range(1, len(segments)):
house_seg = segments[idx].strip()
if not house_seg:
continue
hm = _DEDUP_HOUSE_NO_RE.fullmatch(house_seg)
if hm is None:
continue
street_idx = idx - 1
earlier_segments = segments[:street_idx]
if not earlier_segments or not any(
any(w in _DEDUP_MKR_WORDS for w in seg.strip().split()) for seg in earlier_segments
):
# Нет более раннего сегмента с mkr/район-словом → это не заявленный
# 3-сегментный district-chain, а голый 2-сегментный адрес — не
# фабрикуем уличный токен, отдаём другим веткам (итог — "", "").
continue
raw_words = segments[street_idx].strip().split()
if not raw_words or len(raw_words) > 3:
continue
if any(
w in _DEDUP_MKR_WORDS
or w in _DEDUP_ADDR_NOISE_WORDS
or w in _DEDUP_STREET_TYPE_WORDS_SET
for w in raw_words
):
continue # район/мкр/голое тип-слово — не улица, отдаём другим веткам
words = [w for w in raw_words if w not in _DEDUP_HOUSE_MARKER_WORDS]
if not words:
continue
return " ".join(words), hm.group(1)
return None
# #2436: канонизация house_no к единому кросс-source формату. Регекс выше лишь
# ГАРАНТИРУЕТ, что сырая корпус-подстрока захвачена целиком («65к4», «65 к4»,
# «65, корпус 4») — реформатирование в один канонический «N/M» делает функция
# ниже, а не сам regex (три разные лексические словоформы корпуса плюс слэш —
# читаемее и тестируемее как обычный Python, чем regex-replacement).
_CORPUS_SLASH_RE = re.compile(r"^(\d+)\s*/\s*(\d+)$")
_CORPUS_WORD_RE = re.compile(r"^(\d+)\s*,?\s*к(?:орп(?:ус)?)?\.?\s*(\d+)$", re.IGNORECASE)
_LITER_RE = re.compile(r"^(\d+)([а-яё])$", re.IGNORECASE)
def _normalize_house_no(raw: str) -> str:
"""Канонизирует извлечённый house_no к единому кросс-source формату.
Корпус — ЛЮБАЯ нотация («65к4», «65 к4», «65, корпус 4», «65/4») —
канонизируется к «65/4», чтобы guard_house's string equality (#2429
регрессия) видела один и тот же дом независимо от того, как его записал
источник (avito/cian — глухая/словесная нотация; domklik — слэш). Литер
(единственная буква БЕЗ цифры за ней, «24г») — другой физический суффикс
(«дом 24, литер Г», не «дом 24 корпус 4») и НЕ нормализуется — остаётся
«24г». Идемпотентна: вызов на уже-канонической строке — no-op. Не
полагается на то, что `raw` уже приведён к нижнему регистру вызывающей
стороной — регексы сами case-insensitive.
"""
if not raw:
return raw
s = raw.strip()
m = _CORPUS_SLASH_RE.match(s) or _CORPUS_WORD_RE.match(s)
if m:
return f"{m.group(1)}/{m.group(2)}"
m = _LITER_RE.match(s)
if m:
return f"{m.group(1)}{m.group(2).lower()}"
return s
def _parse_street_house(addr: str | None) -> tuple[str, str]:
"""(street_token, house_no) из адреса произвольного source-формата (#2265).
street_token — название улицы без типа (улица/пер/пр…), города, района и
«Россия»; нумерованные улицы («8 Марта») сохраняют ведущую цифру. house_no —
первое число ПОСЛЕ названия улицы (может отсутствовать: cian/domklik часто
без номера). Не распознано → ("", "").
"""
if not addr:
return "", ""
s = addr.strip().lower()
# #2291 (класс 2): «ё» → «е» — «Шевелёва»/«Шевелева» и «Миномётчиков»/
# «Минометчиков» должны схлопываться в один канонический токен.
s = s.replace("ё", "е")
# Ветка 1 (существующая, #2265): тип ПЕРЕД именем («улица Рассветная»).
# Пробуется ПЕРВОЙ — новые ветки только если она дала пусто. Логика не тронута.
m = _DEDUP_STREET_NAME_RE.search(s)
mkr = False
if m is None:
# Ветка 2 (#2269 а/б): имя ПЕРЕД типом, тип-суффикс с пробелом или
# приклеенный запятой/точкой («Рассветная улица», «Рассветная ул.,6к1»).
m = _DEDUP_STREET_NAME_SUFFIX_RE.search(s)
if m is None:
# Ветка bare-street (#2291, класс 1): «<имя>, <номер>» без типа/мкр-
# слова прямо перед номером дома — СТРОГО перед mkr-fallback (веткой
# 3), иначе «мкр Пионерский, 5» тоже сюда попала бы (см. docstring
# _bare_street_before_house_segment).
bare = _bare_street_before_house_segment(s)
if bare is not None:
bare_street, bare_house = bare
return bare_street, _normalize_house_no(bare_house)
if m is None:
# Ветка 3 (#2269 в): без уличного типа — микрорайон-fallback. Токен
# получает префикс «mkr », чтобы не пересекаться с уличными токенами.
m = _DEDUP_MKR_PREFIX_RE.search(s) or _DEDUP_MKR_SUFFIX_RE.search(s)
mkr = m is not None
if m is None:
return "", ""
words = [
w
for w in m.group(1).split()
if w and w not in _DEDUP_HOUSE_MARKER_WORDS and w not in _DEDUP_ADDR_NOISE_WORDS
]
street = " ".join(words).strip()
if not street:
return "", ""
if mkr:
street = f"mkr {street}"
house = ""
hm = _DEDUP_HOUSE_NO_RE.search(s, m.end())
if hm:
house = _normalize_house_no(hm.group(1))
return street, house
def _lot_dedup_components(
lot: dict[str, Any],
*,
include_price: bool = True,
) -> tuple[str, str, tuple[str, Any, int, int] | None, tuple[str, Any, int, int] | None]:
"""(cadnum, house_no, cadnum-композит, street-композит) лота для union-find.
Каждый композит = (building, floor, area_bucket, price_bucket) или None,
если нет соответствующего сигнала. area_bucket = round(area_m2) (~±0.5 м² —
гасит 66.9 vs 67.0), price_bucket = round(price_rub / _DEDUP_PRICE_BUCKET_RUB)
(у настоящего кросс-поста цена идентична, допуск лишь гасит округление).
house_no — extra-сигнал для street-union guard (не входит в equality-ключ).
Нет площади (или, при include_price=True, нет цены) → композиты None (лот
уникален, не дедупится).
include_price=False (display-only дедуп, см. _dedup_display_lots) отключает
price_bucket-компонент композита (фиксирован в 0, цена не требуется) —
кросс-постинг одного физлота с ценовым дрейфом между площадками (частый
случай: перевыставили дешевле/дороже) всё равно схлопывается в один
building+floor+area_bucket ключ. НЕ используется в статистическом пайплайне
(_dedup_cross_source, влияет на n_analogs/median/cv) — только для UI-карточек.
"""
area = lot.get("area_m2")
price = lot.get("price_rub")
if not area or (include_price and not price):
return "", "", None, None
floor = lot.get("floor")
area_b = round(float(area))
price_b = round(float(price) / _DEDUP_PRICE_BUCKET_RUB) if include_price and price else 0
cad = lot.get("building_cadastral_number")
cad_s = str(cad).strip() if cad and str(cad).strip() else ""
cad_key = (cad_s, floor, area_b, price_b) if cad_s else None
street, house = _parse_street_house(lot.get("address"))
street_key = (street, floor, area_b, price_b) if street else None
return cad_s, house, cad_key, street_key
# #2674: здесь жили `_phys_dedup_key` и `_extract_street_token` — однострочные обёртки
# над _lot_dedup_components / _parse_street_house. Прод не звал ни ту, ни другую ни разу
# (25 ссылок, все из тестов). Хуже: _phys_dedup_key утверждала правило «первичный ключ =
# кадастр ИЛИ улица», которого в проде нет — живой путь (_union_find_phys_dedup) держит
# ОБА композита и сливает по любому совпадению, с guard'ами на разные кадастры/номера
# домов. Тесты, проверявшие обёртку, проверяли не тот алгоритм; они переведены на живые
# функции (tests/test_estimator_dedup_cross_source_2087.py).
def _dedup_rep_key(lot: dict[str, Any]) -> tuple[float, int, str, str]:
"""Ключ выбора представителя из группы-дубля (больше = предпочтительнее).
Свежайший scraped_at → есть фото → source → source_url (детерминированный
tie-break). Свежайшая цена наиболее актуальна для оценки.
"""
sa = lot.get("scraped_at")
sa_ord = sa.timestamp() if isinstance(sa, datetime) else 0.0
has_photo = 1 if lot.get("photo_urls") else 0
return (sa_ord, has_photo, lot.get("source") or "", lot.get("source_url") or "")
def _union_find_phys_dedup(
lots: list[dict[str, Any]], *, include_price: bool
) -> list[dict[str, Any]]:
"""Общий union-find движок для _dedup_cross_source / _dedup_display_lots.
Схлопывает кросс-source дубли одного физического лота (#2087 H4 / #2265):
два лота — дубли, если совпал ЛЮБОЙ ключ — cadnum-композит ИЛИ street-композит
(_lot_dedup_components). Из группы оставляет ОДНОГО представителя (свежайший
scraped_at), НЕ суммирует. Порядок вывода — по первому появлению группы
(сохраняет relevance-сортировку из _fetch_analogs для top-N UI).
#2265: cadnum-композит обрабатывается первым (авторитетный), затем street-
композит — но union по street НЕ сливает два РАЗНЫХ непустых кадастра
(разные здания) И два РАЗНЫХ извлечённых номера дома (equal-or-missing =
сливаем, both-present-different = нет). Оба guard'а — на уровне корня группы,
устойчивы к порядку. Лоты без площади (или, при include_price=True, без цены)
не дедупятся — остаются уникальными.
include_price=True — статистический ключ (влияет на n_analogs/median/cv,
гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего
_dedup_cross_source). include_price=False — display-only ключ без
price_bucket (см. _dedup_display_lots) — ценовой дрейф между кросс-постами
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
пары для merge (require_diff_source) — без цены единственный оставшийся
сигнал «это правда репост», а не два разных юнита в одном доме/этаже/
area_bucket, случайно совпавших по округлённой площади.
"""
n = len(lots)
parent = list(range(n))
comps = [_lot_dedup_components(lot, include_price=include_price) for lot in lots]
# cadnum/house группы (root → «» если группа без/со смешанным значением).
root_cad = [c[0] for c in comps]
root_house = [c[1] for c in comps]
def find(x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]] # path-halving
x = parent[x]
return x
# display-only (include_price=False): без price_bucket в ключе два РАЗНЫХ
# реальных юнита в одном доме/этаже/area_bucket (обычный случай — соседние
# квартиры на площадке) стали бы неотличимы от кросс-поста ОДНОГО юнита.
# Требуем разный source для merge — реальный кросс-пост живёт на РАЗНЫХ
# площадках (avito/cian/yandex/domklik); одинаковый source + одинаковый
# физключ, но разная цена → разные юниты (или дефект данных), не схлопываем.
require_diff_source = not include_price
def union(a: int, b: int, guard_house: bool) -> None:
ra, rb = find(a), find(b)
if ra == rb:
return
if require_diff_source and lots[a].get("source") == lots[b].get("source"):
return # display-only: тот же source → не кросс-пост, не сливаем
ca, cb = root_cad[ra], root_cad[rb]
if ca and cb and ca != cb:
return # разные кадастры → разные здания, не сливаем
ha, hb = root_house[ra], root_house[rb]
if guard_house and ha and hb and ha != hb:
return # разные номера дома (оба извлечены) → не сливаем по street
merged_cad = ca or cb
# both-present-different номер дома возможен лишь при cadnum-union
# (guard_house=False) → помечаем группу как «смешанную» ("").
merged_house = "" if (ha and hb and ha != hb) else (ha or hb)
# меньший индекс — корень: сохраняет first-seen порядок групп.
if ra < rb:
parent[rb] = ra
root_cad[ra] = merged_cad
root_house[ra] = merged_house
else:
parent[ra] = rb
root_cad[rb] = merged_cad
root_house[rb] = merged_house
# 1) cadnum-композит — авторитетный (тот же кадастр = то же здание).
cad_owner: dict[tuple[str, Any, int, int], int] = {}
for i, (_cad_s, _house, cad_key, _street_key) in enumerate(comps):
if cad_key is None:
continue
prev = cad_owner.get(cad_key)
if prev is None:
cad_owner[cad_key] = i
else:
union(prev, i, guard_house=False)
# 2) street-композит — union при отсутствии cadnum- И house-конфликта.
# #2291 (класс 3): area_b — банкирское округление (round()), физически
# одинаковый лот, измеренный source'ами чуть по-разному (38.5 vs 39.0),
# может попасть в СОСЕДНИЕ bucket'ы и никогда не сравниться. Допуск ±1
# bucket СКОПИРОВАН только на street-union lookup (не на cadnum-union, не
# на сам area_bucket — расширение бакета вызвало бы over-merge). Owner
# регистрируется ВСЕГДА под своим ТОЧНЫМ ключом (не под ключом соседа) —
# иначе допуск мог бы дрейфовать сколь угодно далеко цепочкой ±1,±1,±1…
street_owner: dict[tuple[str, Any, int, int], int] = {}
for i, (_cad_s, _house, _cad_key, street_key) in enumerate(comps):
if street_key is None:
continue
street, floor, area_b, price_b = street_key
prev = street_owner.get(street_key)
if prev is None:
prev = street_owner.get((street, floor, area_b - 1, price_b))
if prev is None:
prev = street_owner.get((street, floor, area_b + 1, price_b))
if prev is None:
street_owner[street_key] = i
else:
union(prev, i, guard_house=True)
groups: dict[int, list[dict[str, Any]]] = {}
order: list[int] = []
for i, lot in enumerate(lots):
root = find(i)
if root not in groups:
groups[root] = []
order.append(root)
groups[root].append(lot)
result: list[dict[str, Any]] = []
collapsed = 0
for root in order:
group = groups[root]
if len(group) == 1:
result.append(group[0])
else:
result.append(max(group, key=_dedup_rep_key))
collapsed += len(group) - 1
if collapsed:
logger.info(
"phys dedup (include_price=%s): %d lots → %d unique (-%d dups)",
include_price,
len(lots),
len(result),
collapsed,
)
return result
def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
median/cv/source_counts/sources_used (представитель несёт свой source → лот
считается один раз; source кросс-поста, не ставший представителем, выпадает
из выборки — по нему нет независимой ценовой точки). Флаг
estimate_dedup_analogs_enabled OFF → no-op (байт-идентичный проход, нужно
для frozen backtest regression gate). Ключ включает price_bucket
(_lot_dedup_components include_price=True по умолчанию) — см.
_union_find_phys_dedup.
"""
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
return lots
return _union_find_phys_dedup(lots, include_price=True)
def _market_percentile(target_ppm2: float | None, pool: list[dict]) -> int | None:
"""Позиция объекта внутри когорты аналогов, 1..99. PURE.
Перцентиль РАНГА, а не квантиль: отвечает на вопрос «какая доля когорты дешевле
нас», тогда как `_percentile` решает обратную задачу — «какая цена стоит на
заданной доле». Формула midrank: доля строго дешевле плюс половина равных, что
даёт 50 для объекта ровно по медиане симметричной когорты и не зависит от того,
попал ли сам объект в пул.
Пул — тот же, что дал headline (`anchor_comps_used` либо ценовые `listings_clean`),
и ДО `_dedup_display_lots`: дедуп режет пул под показ (на проде 15 карточек из 528
усечены именно им), а позиция должна считаться по той популяции, из которой взята
цена.
Возвращает None, если считать не по чему или когорта меньше
MARKET_PERCENTILE_MIN_N — см. обоснование порога у константы. Зажимаем в 1..99:
«0-й перцентиль» и «100-й» читаются как «дешевле всех на свете», хотя означают
лишь край конкретной выборки.
"""
if target_ppm2 is None or not pool:
return None
prices = [float(lot["price_per_m2"]) for lot in pool if lot.get("price_per_m2") is not None]
if len(prices) < MARKET_PERCENTILE_MIN_N:
return None
below = sum(1 for p in prices if p < target_ppm2)
equal = sum(1 for p in prices if p == target_ppm2)
raw = 100.0 * (below + 0.5 * equal) / len(prices)
return max(1, min(99, round(raw)))
def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Дедуп ОТОБРАЖАЕМЫХ карточек-аналогов по физическому ключу БЕЗ price_bucket.
Display-consistency fix (2026-07): применяется к финальному пулу, из которого
строятся analogs_lots в estimate_quality — ПОСЛЕ MAD-clip/anchor-фильтрации и
ПОСЛЕ статистического _dedup_cross_source, но независимо от него. Ловит
остаточные кросс-source дубли одного физлота, которые price_bucket-строгий
_dedup_cross_source пропустил из-за ценового дрейфа между площадками (частый
случай: перевыставили на другой площадке дешевле/дороже) — без такого
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет
статистикой (median/n_analogs), этот — только честностью UI-карточек и
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
n_analogs/median/cv — вызывается ПОСЛЕ того, как эти величины уже вычислены
из отдельного (не обязательно совпадающего по итоговой длине) пула.
"""
if len(lots) < 2:
return lots
return _union_find_phys_dedup(lots, include_price=False)
def _filter_outliers(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Tukey IQR rule: исключаем точки вне [Q1 - k×IQR, Q3 + k×IQR].
#1795 шаг 4: на малых выборках (n < estimate_outlier_small_n_threshold)
ужесточаем k с 1.5 до estimate_outlier_tukey_k_small — несколько элитных
листингов на тонкой выборке премиум-дома иначе остаются в IQR-границах и
тянут радиусную медиану вверх. threshold=0 → выключено (старое поведение).
"""
if len(lots) < 5:
return lots # на маленькой выборке нечего фильтровать
prices = sorted(lot["price_per_m2"] for lot in lots if lot.get("price_per_m2"))
if len(prices) < 4:
return lots
tukey_k = 1.5
if (
settings.estimate_outlier_tukey_k_small < 1.5
and len(prices) < settings.estimate_outlier_small_n_threshold
):
tukey_k = settings.estimate_outlier_tukey_k_small
q1 = _percentile(prices, 0.25)
q3 = _percentile(prices, 0.75)
iqr = q3 - q1
low = q1 - tukey_k * iqr
high = q3 + tukey_k * iqr
# None-safe: listings.price_per_m2 is nullable, и lot.get(..., 0) вернёт None
# (а не дефолт 0) когда ключ ПРИСУТСТВУЕТ со значением None → low <= None <= high
# бросает TypeError в Python 3. Лоты без цены судить как outlier нечем — оставляем их.
clean = []
for lot in lots:
ppm2 = lot.get("price_per_m2")
if ppm2 is None:
clean.append(lot) # нечего сравнивать — keep
continue
if low <= ppm2 <= high:
clean.append(lot) # priced лот внутри Tukey-границ — keep
# else: priced outlier за пределами границ — drop
if len(clean) < len(lots):
logger.info("outlier filter: %d%d (Q1=%d Q3=%d)", len(lots), len(clean), q1, q3)
return clean
def _percentile(sorted_values: list[float], p: float) -> float:
"""Linear interpolation percentile (не округляем — оставляем float)."""
if not sorted_values:
return 0.0
if len(sorted_values) == 1:
return float(sorted_values[0])
n = len(sorted_values)
rank = p * (n - 1)
lo = int(rank)
hi = min(lo + 1, n - 1)
frac = rank - lo
return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * frac
def _apply_range_floor(low: int, high: int, point: int) -> tuple[int, int]:
"""#2209: min-width floor ценового диапазона вокруг точки оценки.
Если диапазон уже́ 2×RANGE_MIN_HALFWIDTH_PCT×point (вырожденный n=1..2 хвост),
расширяем симметрично вокруг point до floor-полуширины. Floor ТОЛЬКО расширяет
(никогда не сужает уже-широкий диапазон), точку (median/expected_sold) не двигает.
low не опускается ниже 0. Возвращает целые рубли.
"""
if point <= 0:
return low, high
floor_half = round(RANGE_MIN_HALFWIDTH_PCT * point)
if (high - low) < 2 * floor_half:
return max(0, point - floor_half), point + floor_half
return low, high
def _enforce_zero_analog_low(
confidence: str,
n_analogs: int,
explanation: str | None,
*,
median_price: int | float | None = None,
sources_used: list[str] | None = None,
) -> tuple[str, str]:
"""#1871 P1.2 — defensive invariant: n_analogs == 0 МОЖЕТ означать только 'low'.
Гард ловит любой будущий путь (external-valuation blend, rehydrate, миграция),
который наполнит median/sources_used без единого реального аналога ("ghost-anchor").
Текущий mainline уже честен (_compute_confidence → 'low' при median=0, #755 cap) —
это belt-and-suspenders, не load-bearing. Не вшивает fake median: реальное число
от внешнего сервиса остаётся, мы лишь понижаем confidence + добавляем caveat.
Возвращает (confidence, explanation): при n_analogs==0 и confidence!='low'
форсит 'low' + caveat-suffix; иначе значения проходят без изменений.
"""
if n_analogs == 0 and confidence != "low":
logger.warning(
"ghost_anchor_guard #1871: forcing confidence 'low' (was %s, median=%s, sources=%s)",
confidence,
median_price,
sources_used,
)
explanation_suffix = (
" Оценка построена без сопоставимых аналогов рядом "
"(использованы только внешние оценочные сервисы) — точность "
"ориентировочная, рекомендуем дополнительную проверку."
)
return "low", (explanation or "") + explanation_suffix
return confidence, explanation or ""
def _downgrade_confidence(confidence: str) -> str:
"""#1871 P2 — понизить confidence на одну ступень: high→medium→low→low.
Зеркалит inline downgrade-map в _compute_confidence (concentration bias) и
coarse-geo gate. Неизвестные значения возвращаются как есть (defensive).
"""
return {"high": "medium", "medium": "low", "low": "low"}.get(confidence, confidence)
_CONFIDENCE_ORDER: dict[str, int] = {"low": 0, "medium": 1, "high": 2}
def _cap_confidence_by_reliability(confidence: str, reliability: str) -> str:
"""#conf-cap (confidence↔reliability consistency, live-prod smoke 2026-08-11).
`confidence` (this function's sibling `_compute_confidence` — unique-
address/IQR metric) and `reliability` (estimate_quality — n_analogs-bucket
+ #oblast-F relaxation-cascade metric) used to be computed fully
independently. The #oblast-F cascade's room-adjacency/freshness/
novostroyki steps never set `fallback_used`, so `_compute_confidence`
stayed blind to them and could keep scoring a post-relaxation sample
(wide, tight-IQR) as "high" while `reliability` — which DOES see the
relaxations — honestly read "low". Live prod repro: n_analogs=39,
confidence='high', reliability='low', relaxations=[3 items] — a client
could see a "высокая уверенность" badge next to a "точность снижена"
banner on the SAME estimate.
Rule (applied ONCE, by the caller, after both values are final — NOT
spread across the relaxation cascade):
reliability == 'very_low' → confidence forced to 'low'.
reliability == 'low' → confidence capped at 'medium' (high→medium;
medium/low pass through unchanged).
reliability == 'ok' → confidence untouched (unaffected — this is
the common, unrelaxed case; byte-identical
to pre-#conf-cap behaviour).
Never RAISES confidence — only caps/lowers it. Unknown confidence/
reliability values pass through unchanged (defensive, mirrors
_downgrade_confidence above).
"""
if reliability == "very_low":
return "low"
if reliability == "low":
current = _CONFIDENCE_ORDER.get(confidence)
cap = _CONFIDENCE_ORDER["medium"]
if current is not None and current > cap:
return "medium"
return confidence
def _radius_ru(radius_m: int) -> str:
"""«2 км» / «5 км» / «1500 м» — целые километры словом, остальное метрами."""
return f"{radius_m // 1000} км" if radius_m % 1000 == 0 else f"{radius_m} м"
def _compute_confidence(
n_analogs: int,
median_ppm2: float,
q1: float,
q3: float,
fallback_radius_used: bool,
area_widened: bool = False,
listings: list[dict] | None = None,
radius_used_m: int | None = None,
) -> tuple[str, str]:
"""Confidence + explanation string.
Уровень определяется по количеству уникальных адресов, а не по raw n_analogs.
Это защищает от overstated confidence когда много лотов из одного здания
(например, MIN_ANALOGS_PER_SOURCE=5 + same-building bias).
high — unique_addr ≥ 7 AND IQR/median < 0.15
medium — (unique_addr ≥ 4 AND IQR/median < 0.35) OR (unique_addr ≥ 2 AND IQR/median < 0.25)
low — иначе
#R2-H2: dispersion-ceiling на medium (0.35) + force-low при fallback-расширении
с разбросом > 0.30 — badge не должен противоречить собственному объяснению.
Downgrade на один уровень если avg_lots_per_addr > 2.5 (concentration bias).
"""
if median_ppm2 == 0:
return "low", "Не найдено аналогов — попробуйте уточнить адрес или расширить параметры."
# Вычисляем метрики уникальных адресов
if listings:
unique_addrs = {
(lot.get("address") or "").strip().lower() for lot in listings if lot.get("address")
}
unique_addr_count = len(unique_addrs)
avg_lots_per_addr = n_analogs / max(unique_addr_count, 1)
else:
unique_addr_count = n_analogs # fallback: считаем каждый лот уникальным
avg_lots_per_addr = 1.0
iqr = q3 - q1
iqr_pct = iqr / median_ppm2 if median_ppm2 > 0 else 1.0
notes = []
if fallback_radius_used:
# #2632: НАЗЫВАЕМ фактический радиус. До этого здесь было прибито «2 км»
# — константа старого 1км→2км fallback'а, а каскад #oblast-F уходит до
# 3/5 км: текст утверждал 2 км там, где поиск реально шёл по 5 км
# (прод, 2026-08-11: relaxations «радиус расширен до 5000 м», самый
# дальний аналог 4381 м, объяснение — «расширили радиус до 2 км»).
notes.append(f"расширили радиус до {_radius_ru(radius_used_m or FALLBACK_RADIUS_M)}")
if area_widened:
notes.append("расширили допуск по площади до ±25%")
fallback_note = f" ({', '.join(notes)} из-за нехватки данных)" if notes else ""
# Базовый уровень по уникальным адресам
if unique_addr_count >= 7 and iqr_pct < 0.15:
base = "high"
elif unique_addr_count >= 4 and iqr_pct < 0.35:
base = "medium"
elif unique_addr_count >= 2 and iqr_pct < 0.25:
base = "medium"
else:
base = "low"
# #R2-H2: расширение радиуса/площади из-за нехватки данных + высокий разброс
# (IQR/median > 0.30) не может честно оставаться medium/high — иначе badge
# противоречит объяснению («расширили радиус … ±45%»). Форсим low.
if base != "low" and (fallback_radius_used or area_widened) and iqr_pct > 0.30:
base = "low"
# Downgrade на один шаг если слишком много лотов сконцентрировано на малом числе адресов
if avg_lots_per_addr > 2.5 and base != "low":
downgrade_map = {"high": "medium", "medium": "low"}
downgraded = downgrade_map[base]
explanation = (
f"Найдено {n_analogs} аналогов из {unique_addr_count} разных адресов, "
f"разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}. "
f"Снижена точность (≥2.5 лотов на адрес — возможен bias)."
)
return downgraded, explanation
explanation = (
f"Найдено {n_analogs} аналогов из {unique_addr_count} разных адресов, "
f"разброс цены ±{int(iqr_pct * 100 / 2)}% от медианы{fallback_note}."
)
return base, explanation
def _date_precision_for_source(source: str | None) -> Literal["day", "quarter"] | None:
"""Честная маркировка precision listing_date/deal_date (#1995).
Rosreestr open dataset публикует ДКП-сделки с точностью до КВАРТАЛА
(deals.deal_date = period_start_date, первый день квартала — см. data/sql/
01_schema_rosreestr_deals.sql, комментарий "Excluded: ... exact deal date").
Live-аудит prod (2026-07): 9 загруженных кварталов, ровно 1 distinct deal_date
на квартал — подтверждает granularity источника, а НЕ баг/заглушку.
listings (avito/cian/yandex/domklik) несут реальную day-level дату
скрапинга/парсинга. source=None (неизвестен) → None (precision не заявляем).
"""
if source is None:
return None
return "quarter" if source == "rosreestr" else "day"
def _listing_to_analog(row: dict[str, Any]) -> AnalogLot:
return AnalogLot(
address=row.get("address") or "",
area_m2=float(row.get("area_m2") or 0),
rooms=int(row.get("rooms") or 0),
floor=row.get("floor"),
total_floors=row.get("total_floors"),
price_rub=int(row["price_rub"]),
price_per_m2=int(row.get("price_per_m2") or 0),
listing_date=row.get("listing_date"),
days_on_market=row.get("days_on_market"),
date_precision=_date_precision_for_source(row.get("source")),
photo_url=(row["photo_urls"] or [None])[0] if row.get("photo_urls") else None,
source=row.get("source"),
source_url=row.get("source_url"),
distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None,
lat=float(row["lat"]) if row.get("lat") is not None else None,
lon=float(row["lon"]) if row.get("lon") is not None else None,
)
def _anchor_comp_to_analog(c: dict[str, Any]) -> AnalogLot:
"""Same-building/micro-radius comp → AnalogLot (#694).
Когда якорь сработал, headline построен на этих комплах — показываем их в UI.
ROBUST к отсутствию display-полей: реальные комплы несут address/source/price_rub
(SELECT их тянет), но тестовые моки и legacy-строки могут давать только числа
(price_per_m2/area_m2/rooms). price_rub тогда вычисляем ppm²×area
(fallback на ppm² если area 0/None — без zero-result/crash). distance_m не
значим для same-building → None, если явно не передан.
"""
ppm2 = int(c.get("price_per_m2") or 0)
area = float(c.get("area_m2") or 0)
price_rub_raw = c.get("price_rub")
if price_rub_raw is not None:
price_rub = int(price_rub_raw)
elif area > 0:
price_rub = round(ppm2 * area)
else:
# area неизвестна — деградируем до ppm² (никогда 0/crash при ppm²>0).
price_rub = ppm2
photo_urls = c.get("photo_urls")
return AnalogLot(
address=c.get("address") or "",
area_m2=area,
rooms=int(c.get("rooms") or 0),
floor=c.get("floor"),
total_floors=c.get("total_floors"),
price_rub=price_rub,
price_per_m2=ppm2,
listing_date=c.get("listing_date"),
days_on_market=c.get("days_on_market"),
date_precision=_date_precision_for_source(c.get("source")),
photo_url=(photo_urls or [None])[0] if photo_urls else None,
source=c.get("source"),
source_url=c.get("source_url"),
distance_m=int(c["distance_m"]) if c.get("distance_m") is not None else None,
lat=float(c["lat"]) if c.get("lat") is not None else None,
lon=float(c["lon"]) if c.get("lon") is not None else None,
)
def _deal_to_analog(row: dict[str, Any]) -> AnalogLot:
"""deals не имеют photo_url — упрощённо.
Tier classification (PR M / #564 Phase 3):
T0_per_house — kadastr_num exact match (НЕ доступно в open dataset Росреестра)
T1_per_street — street-level only (default для всех ДКП open dataset)
"""
kad = row.get("cadastral_number")
# Per-house tier требует kadastr_num типа "66:41:0204016:10" (с участком).
# Street-only patterns: "66:41:0000000:0" или NULL → T1.
tier = "T0_per_house" if kad and not kad.endswith(":0") else "T1_per_street"
return AnalogLot(
address=row.get("address") or "",
area_m2=float(row.get("area_m2") or 0),
rooms=int(row.get("rooms") or 0),
floor=row.get("floor"),
total_floors=row.get("total_floors"),
price_rub=int(row["price_rub"]),
price_per_m2=int(row.get("price_per_m2") or 0),
listing_date=row.get("deal_date"),
days_on_market=row.get("days_on_market"),
date_precision=_date_precision_for_source(row.get("source")),
photo_url=None,
source=row.get("source"),
source_url=None, # rosreestr сделки без публичной ссылки
distance_m=int(row["distance_m"]) if row.get("distance_m") is not None else None,
lat=float(row["lat"]) if row.get("lat") is not None else None,
lon=float(row["lon"]) if row.get("lon") is not None else None,
tier=tier,
)
def _empty_estimate(
payload: TradeInEstimateInput,
db: Session,
*,
reason: str,
created_by: str | None = None,
client_ip: str | None = None,
require_consent: bool = False,
) -> AggregatedEstimate:
"""Fallback когда нет данных для оценки.
Сохраняет запись в БД (confidence='low', пустые analogs/deals), чтобы GET /estimate/{id}
не возвращал 404. C-4 security audit.
ЭТАП 4 B2C: этот путь тоже пишет адрес в trade_in_estimates -- consent-gate в
estimate_quality() уже отработал ДО вызова (require_consent=True подразумевает
payload.consent is True), здесь просто персистим то же consent-доказательство,
что и главный путь (_estimate_consent_persist_fields).
"""
estimate_id = uuid4()
now = datetime.now(tz=UTC)
expires_at = now + timedelta(hours=settings.trade_in_estimate_retention_hours)
db.execute(
text(
"""
INSERT INTO trade_in_estimates (
id, address,
area_m2, rooms, floor, total_floors,
year_built, house_type, repair_state, has_balcony,
ownership_type, has_mortgage,
median_price, range_low, range_high, median_price_per_m2,
confidence, confidence_explanation, n_analogs,
analogs, actual_deals,
sources_used,
relaxations, reliability,
created_by,
expires_at,
consent, client_ip, consent_policy_version, consent_text_snapshot
) VALUES (
CAST(:id AS uuid), :address,
:area, :rooms, :floor, :total_floors,
:year_built, :house_type, :repair_state, :has_balcony,
:ownership_type, :has_mortgage,
0, 0, 0, 0,
'low', :explanation, 0,
'[]'::jsonb, '[]'::jsonb,
'[]'::jsonb,
-- #oblast-F: поиск аналогов вообще не выполнялся (geocode failed /
-- no coords) — каскад послаблений не запускался, relaxations честно
-- пуст; reliability='very_low' зеркалит то, что возвращает Python
-- ниже (см. AggregatedEstimate(..., reliability="very_low")).
'[]'::jsonb, 'very_low',
:created_by,
:expires_at,
:consent, CAST(:client_ip AS inet), :consent_policy_version,
:consent_text_snapshot
)
"""
),
{
"id": str(estimate_id),
"address": payload.address,
"area": payload.area_m2,
"rooms": payload.rooms,
"floor": payload.floor,
"total_floors": payload.total_floors,
"year_built": payload.year_built,
"house_type": payload.house_type,
"repair_state": payload.repair_state,
"has_balcony": payload.has_balcony,
"ownership_type": payload.ownership_type,
"has_mortgage": payload.has_mortgage,
"explanation": reason,
"created_by": created_by,
"expires_at": expires_at,
**_estimate_consent_persist_fields(require_consent, client_ip),
},
)
db.commit()
logger.info(
"empty_estimate: id=%s reason=%s addr=%s", estimate_id, reason, payload.address[:60]
)
return AggregatedEstimate(
estimate_id=estimate_id,
median_price_rub=0,
range_low_rub=0,
range_high_rub=0,
median_price_per_m2=0,
confidence="low",
confidence_explanation=reason,
n_analogs=0,
period_months=DEALS_PERIOD_MONTHS,
analogs=[],
actual_deals=[],
expires_at=expires_at,
cian_valuation=None,
# Адрес не геокодирован (DaData не отрабатывала) → точность неизвестна.
address_precision=None,
analog_tier=None, # нет данных при empty estimate
# #oblast-F: n_analogs=0 здесь честно — поиск аналогов вообще не выполнялся
# (geocode failed / no coords), а не просто "мало нашлось".
reliability="very_low",
)