estimate_dedup_analogs_enabled: False → True. Бэктест #1966 OFF vs ON
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias −3.83%, median
width/cv без изменений) — включение меняет только user-visible n_analogs:
перестаёт быть раздутым кросс-постингом одного физлота на avito+cian+domklik
×3. Откат — ENV ESTIMATE_DEDUP_ANALOGS_ENABLED=false.
Тесты разведены default vs explicit (не ослаблены):
- test_estimator_dedup_cross_source_2087: добавлен test_dedup_default_is_on
(без monkeypatch — дедуп активен по умолчанию); test_dedup_flag_off_is_noop
оставляет ЯВНЫЙ OFF-override.
- test_backtest_regression_gate: пиним флаг OFF — гейт это байт-идентичный
replay frozen OFF-capture (recorded call-sequence фикстуры без дедупа); с
ON _dedup_cross_source триммит listings до quarter_indexes_lookup и
control-flow расходится с записью.
- test_estimator_n_analogs_priced (autouse) + test_radius_path_n_analogs_unchanged:
пиним OFF — инвариант «n_analogs = число priced-аналогов»/radius-passthrough
ортогонален дедупу, а синтетические аналоги делят адрес/этаж/площадь и
схлопнулись бы как кросс-посты.
Refs #2087, #2173
Один физический лот кросс-постится на avito+cian+domklik (разные source и
source_id) → existing radius-дедуп по (source, source_id) его НЕ ловит →
n_analogs И source_counts раздуты кросс-постами (аудит #2087: лот
80м²/265000₽/м² = N1+Домклик+Циан ×3; «14 аналогов» → ~6-7 уникальных).
_dedup_cross_source схлопывает дубли по физическому ключу (building_cadastral
| нормализованный address + floor + area-bucket round(m²) + price-bucket
round(₽/100k)) ДО подсчёта n_analogs/median/cv, оставляя свежайшего (scraped_at)
представителя. За флагом estimate_dedup_analogs_enabled (default OFF =
байт-идентично; регресс-гейт зелёный).
Бэктест #1966 (400 ДКП, full spine, OFF vs ON, тот же сэмпл): MAPE 13.89%→13.89%,
coverage 83.33%→83.33%, bias −3.83%→−3.83%, median width 0.743→0.743, median cv
0.0988→0.0988, avg n_analogs 27.64→27.57 (дедуп отработал 107× на 335 оценках).
Кросс-посты имеют identical price → нулевой вклад в дисперсию → cv/коридор НЕ
сужаются: это фикс ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут, source_counts по
физлотам), accuracy-нейтральный, а НЕ рычаг cv-сужения (рычаг cv→коридор —
estimate_sb_clip_after_weight, уже default ON). Default OFF — narrowing не
продемонстрирован; флаг готов к canary/монкипатч-бэктесту.
Refs #2087
Два связанных фикса из диагностики 2026-06-27:
ФИКС C (run_cian_full_load, per-fetch timeout): monkey-patch _browser.fetch →
asyncio.wait_for(_f(url), timeout=cian_full_load_per_fetch_timeout_s=90s).
BrowserFetcher.fetch имеет httpx-timeout 120s, но browser-сервис иногда виснет
так что httpx не получает ответ (нет EOF) → fetch висит → asyncio.gather
блокирует весь bucket → heartbeat_at не обновляется → reap_zombies убивает живой run.
wait_for(90s) отменяет зависший fetch → TimeoutError → _fetch_page_html ловит
как Exception → None → _one_page → [] → gather завершается нормально.
За флагом (cian_full_load_per_fetch_timeout_s=0 = отключить, >0 = включить).
ФИКС D (background heartbeat): asyncio.create_task(_background_heartbeat())
обновляет heartbeat_at каждые 60s независимо от on_bucket/on_progress прогресса.
Без него пустые bucket или зависший gather замораживает heartbeat на несколько часов.
Task отменяется в finally → не течёт при return/raise/cancel.
Новая настройка (ENV):
CIAN_FULL_LOAD_PER_FETCH_TIMEOUT_S=90.0 — timeout одного browser-fetch
Два связанных фикса из диагностики 2026-06-27:
ФИКС A (_rotate_proxy_ip): вместо одношотного GET с timeout=30s — retry-loop
(proxy_rotate_attempts=3 попыток по proxy_rotate_attempt_timeout_s=8s каждая).
Зависший changeip-сервис больше не блокирует весь sweep на 30s.
Первый успешный attempt → True; все провалились → False (логируется как error).
ФИКС B (run_avito_city_sweep): когда SERP-фаза уже сохранила лоты
(lots_inserted + lots_updated > 0) и упала только detail/houses-фаза
(AvitoBlockedError / RateLimitedError), помечаем run как done (не banned)
при avito_serp_ok_not_banned=True (default). Метрика banned зарезервирована
для случаев где SERP сам заблокирован (0 лотов).
Новые настройки (ENV):
PROXY_ROTATE_ATTEMPT_TIMEOUT_S=8.0 — timeout одной changeip-попытки
PROXY_ROTATE_ATTEMPTS=3 — число попыток перед отказом
AVITO_SERP_OK_NOT_BANNED=true — флаг нового поведения (default on)
Добавляет settings-флаг estimate_confidence_floor_no_analogs (дефолт True),
гейтящий вызов _enforce_zero_analog_low перед сборкой AggregatedEstimate.
При n_analogs==0 и confidence!='low' форсит 'low' + добавляет caveat в
explanation («без сопоставимых аналогов рядом»), предотвращая показ выдуманного
«высокого» доверия когда оценка построена только на внешних оценщиках
(yandex_valuation/cian_valuation) без реальных рыночных аналогов.
Включает wide-corridor disclosure (PR #1880, был default OFF) — но с
ИСПРАВЛЕННЫМ порогом по prod-данным.
Проверка на trade_in_estimates (60д): corridor_pct = (range_high-range_low)/
median_price имеет median≈0.48, p90≈0.93, p99≈1.38; **30.7% оценок > 0.6**.
Старый порог 0.6 прилепил бы caveat «дом разбит на секции» к ~трети оценок —
большинство НЕ split-дома (широкий коридор бывает от разных причин). Это была
бы ложная атрибуция (дезинформация).
- estimate_wide_corridor_threshold 0.6 → 1.2: genuine split-дома из аудита =
148-170% (1.48-1.70) → 1.2 ловит только экстремальный хвост (>p99), не трогая
нормальную оценочную неопределённость.
- estimate_wide_corridor_disclosure_enabled False → True (включаем после
валидации). ENV-override сохранён (kill-switch).
- Формулировка смягчена: «дом разбит на секции» → «вероятно, дом разбит на
секции разной этажности ИЛИ разнородный фонд» — мы ИНФЕРИМ split по ширине
коридора, не доказываем структурно.
- Логика не тронута: фаерит только Tier A + corridor>threshold, только понижает
confidence + дописывает explanation; point/median/range не трогает.
Тесты: обновлены default-ассерты (флаг ON, порог 1.2) + 2 behavioral boundary-
теста (фаерит при pct>threshold, НЕ фаерит при умеренном 1.045<1.2 — доказывает
что raise убрал false-positive flood). 13 passed.
Refs #1871
Добавляет _is_price_sane() в cian_valuation.py: если sale_price_rub вне
[cian_valuation_min_rub, cian_valuation_max_rub] (дефолты 500k/500M),
или low_price > high_price, или любой bound отрицательный — результат
не кэшируется и возвращается None (graceful). Защита от garbage-ответов
API (999_999 < min, 9_999_999_999 > max).
Новые settings: cian_valuation_min_rub=500_000, cian_valuation_max_rub=500_000_000.
Тесты: 11 новых тест-кейсов в test_cian_valuation.py. Пофиксен pre-existing
KeyError в test_cache_hit_returns_cached (missing low_price/high_price в mock).
Anchor-путь имеет hard floor от comp_min_ppm2×(1-tol). Radius-путь аналогичной
защиты снизу не имел — quarter-index-вниз или corridor-clamp могли занизить
median неоправданно (asymmetry).
Добавлен floor: если итоговый median_ppm2 < dkp_low_ppm2 × factor → поднять
до floor + лог. Только radius-путь (anchor_tier is None); без dkp_raw → no-op.
За флагами estimate_radius_floor_enabled (default True) и
estimate_radius_floor_factor (default 0.8).
FIX 1: в _parse_html добавлен гео-фильтр per-card source_url. При
avito_serp_ekb_only=True (ENV AVITO_SERP_EKB_ONLY, дефолт True) карточки без
/ekaterinburg/ в source_url отбрасываются — Авито добивает выдачу
«по всей России» при малом числе ЕКБ-результатов (rooms=4 давал 96% не-ЕКБ).
Карточки без распознанного city-slug пропускаются консервативно.
Лог: «avito SERP: dropped N non-EKB cards (padding)».
FIX 2 (анализ): ROOM_SLUGS уже содержит отдельные бакеты «4-комн.» и «5+комн.»
(разбиты ранее). rooms-значение берётся из текста заголовка карточки
(_extract_rooms_from_title: regex «(\d)-к. квартира»), а не из бакета —
4/5/6 различаются по факту парсинга. Сплит slug'ов был сделан ранее, регрессий нет.
Тесты: 4 новых unit-теста в test_avito_ekb_geo_filter.py покрывают:
EKB+non-EKB → только EKB; ekb_only=False → все проходят;
без city-slug → консервативно оставляем; все EKB → ничего не дропается.
Якорь с confidence=low ИЛИ (n<gate_min_n И FSD>gate_max_fsd) не заменяет
headline — fallback на radius-median. Дефолты (min_n=3, max_fsd=0.20) не
режут здоровые якоря (n≥4, FSD<0.15 проходят). За флагом
estimate_sb_low_conf_gate_enabled (дефолт True). Логируется с причиной.
The Cian Valuation enrichment was the only ungated external call in
estimate_quality() — its internal curl timeout (~25s) blocks the whole
/estimate request on every cache-miss, intermittently pushing latency
past the gateway/patience window (perceived as 'сервис не считает').
Wrap it in _with_budget() (mirrors the existing Yandex-valuation guard):
on timeout it degrades to None — the same graceful path as a network
error — instead of stalling. New setting estimate_cian_valuation_timeout_s
(default 8.0s, env ESTIMATE_CIAN_VALUATION_TIMEOUT_S).
Tier A (same building) dropped cian listings tagged listing_segment='novostroyki'
even in delivered buildings where that tag marks owner resales/переуступки
(sale_type=free). Gated relaxation of the #1186 guard, Tier A ONLY: include
novostroyki rows iff the same house has secondary (vtorichka/NULL) listings AND
secondary_count >= primary_count — secondary-dominant signal of a delivered house.
Prevents a lone vtorichka + clustered developer-priced novostroyki from anchoring
on застройщик prices after MAD-clip drops the rare secondary (~27% overvaluation).
Pure-primary / primary-dominated houses keep the #1186 guard. Behind
estimate_sb_tier_a_allow_primary_if_secondary_present (default True).
Dedup duplicate active rows by (source, source_id) primary key (codebase canon,
namespaced id/url tags, source_url fallback) — collapses rows whose source_url
differs only by trailing slash / query-param (e.g. cian source_id=330047129 ×2).
Tier C / radius / asking_to_sold_ratio paths untouched. 84 unit tests pass.
Prod debug (ssh, headless=True + mobileproxy): the camoufox fetch was returning
a 274KB generic avito shell with 0 listings — NOT a block/captcha (no
captcha/datadome/firewall markers, status 200, correct final_url). Avito hydrates
the SERP listings into the DOM client-side AFTER domcontentloaded; at the 2500ms
wait the page.content() snapshot still had only the generic shell. At 5-6s it's
the full SERP: 3.2MB, priceDetailed=50.
Fix: BROWSER_WAIT_MS default 2500→6000 (5s proven full + 1s margin), config.py
browser_wait_ms 2500→6000 to match. The whole camoufox+proxy migration works —
this was the last blocker. NOT headless detection (headless=True returns real
data); the abandoned xvfb/headless=virtual change is unnecessary.
Refs #905, #883
The playwright WS-server path (camoufox.server.launch_server) is incompatible
with playwright >=1.45: camoufox 0.4.11's launchServer.js requires
playwright/driver/package/lib/browserServerImpl.js, which no longer exists in
any playwright 1.45-1.60 → tradein-browser crash-looped with MODULE_NOT_FOUND
('Server process terminated unexpectedly'). Verified by running the built image.
Pivot (no playwright protocol between containers):
- browser/server.py: aiohttp service holding a local AsyncCamoufox (headless,
os/locale/geoip/humanize, proxy from SCRAPER_PROXY_URL). POST /fetch {url} →
new_page/goto/content/close → {html}; GET /health. Page-recycle every N +
crash-recovery (relaunch + 1 retry), serialized via asyncio.Lock.
- browser/Dockerfile: +aiohttp (camoufox fetch-as-root/#908 block untouched).
- backend BrowserFetcher: playwright.connect → httpx POST to the browser service
(one retry on transport/HTTP error). All playwright imports dropped.
- config: browser_ws_endpoint → browser_http_endpoint (http://tradein-browser:3000).
Verified by a REAL local image build + run: image builds, container stays Up
(Restarts=0, no crash-loop), GET /health=200, POST /fetch launches camoufox and
drives Firefox (only NS_ERROR_UNKNOWN_HOST = no DNS egress in the local build env,
proving the full HTTP→camoufox→Firefox chain; real fetch validates on prod with
network+proxy). 7 unit tests + ruff clean.
Dormant: scraper_fetch_mode stays curl_cffi. Refs #905, #883, #884
CLIENT-VISIBLE pricing (killer-factor, demo 2026-06-01). The same-building
anchor replaced headline median/range/confidence with an over-confident
premium on as few as 2 same-building asking comps. Human-chosen (Anton)
3-part safe-guard, all implemented:
1. min_comps 2 -> 4: anchor fires only with >=4 same-building comps.
2. confidence cap: at <5 anchor comps, confidence cannot be 'high' -> medium.
3. MAD-clip: drop price_per_m2 outliers among anchor comps before aggregation
(k=estimate_sb_mad_k=3.5); if <min_comps survive, anchor does NOT fire
(fallback to radius-median). Guards against price typos inflating anchor.
Anchor still REPLACES the headline (uplift-no-replace variant NOT chosen).
quarter-index/radius-tier/asking-sold untouched. 8 new unit tests; 61 anchor
tests pass, ruff clean.
MERGE-GATE (client-visible, like #764/#858): do NOT merge without a green
backtest #763 (held-out MAPE <= 18.7% + canonical A/B Малышева 125 /
Заводская 44а). Backtest needs live DB — pre-merge human/qa step.
Refs #755, #694