competitors._AVG_PRICE_SQL читает domrf_kn_flats.price_per_m2, но на последнем
snapshot эта колонка пуста (0 строк, скрапер перестал отдавать per-flat цены
~2026-06-03) -> avg_price_per_m2=None почти всем конкурентам, price_similarity
вырождается в нейтраль 0.5. Добавлен additive fallback: где domrf-цены нет,
берём медиану objective_lots.price_per_m2_rub через objective_complex_mapping
(1:1 мост, ~80% покрытие, прод-median 136k руб/м²). domrf-цены приоритетны
(precedence domrf > objective); новое поле price_source ('domrf'/'objective')
для прозрачности инвест-рекомендаций. Веса и mv_layout_velocity не тронуты
(#295 defer). Медиана (не weighted-avg) устойчива к хвостам. api-types.ts
regenerated (v7.13.0 pinned + prettier).
Refs #307
_ACTIVE_STATUSES = frozenset({"sales", "construction"}) — английский словарь
никогда не совпадал с domrf_kn_objects.site_status, который scraper берёт
СЫРЫМ из siteStatus дом.рф (domrf_kn.py:316). Реальные prod-значения
русские: «Строящиеся»/«Сданные».
Прод-аудит:
- data/sql/105_add_sales_started_flag.sql фильтрует по 'Строящиеся' (~1322 строки).
- partial index 66_indexes_recommend.sql использует те же.
- analytics_queries.py, MarketTab.tsx, CompetitorTable.tsx — все на русских.
Эффект: у ВСЕХ Competitor в POST /parcels/{cad}/competitors is_active=False
и CompetitorsSummary.active_count=0 при любых данных — типизированный
контракт систематически врал.
Patch: _ACTIVE_STATUSES = frozenset({"Строящиеся"}). Заодно обновил два
unit-теста которые кодировали баг (использовали "sales"/"construction"
в моках, тестировали логику против сломанного словаря). Теперь моки
матчат реальную prod-форму.
51/51 competitors-тестов зелёные. ruff clean.
Closes#1213
weighted_avg_velocity was a naive mean despite the name — a 500-flat ЖК weighed
the same as a 20-flat one. Now count-weighted by flats_total (sql.md AVG
principle): Σ(velocity*flats_total)/Σ(flats_total). Competitors with unknown
flats_total are excluded from weights; if sizes are unknown for ALL, graceful
fallback to the simple mean (den>0 guard). Field name + API contract UNCHANGED
(zero consumer ripple — traced: only CompetitorsSummary, no frontend ref).
Tests: equal sizes → weighted==naive (existing 6.0 stays); NEW test with
500-flat@40 + 20-flat@2 → 38.54 (not naive 21.0), proving the weighting.
domrf_kn_flats.status is NULL in ~99.8% of rows, so WHERE status='sold'
always returned 0 rows and avg_price_per_m2 was always None. Drop the
filter; AVG over all rows with price_per_m2 IS NOT NULL is semantically
correct for a complex-level price estimate.
Adds regression test test_competitors_avg_price_populated (Issue #227).