Цена продажи в финмодели калибровалась по name-match админ-района Objective —
но только 4 из 9 районов ЕКБ совпадают по имени, 5 проваливались в class_norm.
Добавлен ценовой tier "objective_geo_radius": ST_DWithin-медиана цен новостроек
Objective (objective_lots ⋈ complexes по lat/lon) в радиусе 3 км от центроида
участка, между квартальной MV и district_reference. Порог n≥10 лотов и ≥2 ЖК
(один ЖК не задаёт цену). Прод: 5 «гео»-районов теперь дают реальную медиану
(129–158k ₽/м²) вместо NULL.
- data/sql/168: функциональный GIST-индекс complexes((ST_MakePoint(lon,lat))::geography)
— без него ST_DWithin делает Seq Scan (+~90мс на /analyze, у которого уже
таймаут-давление #1850). Прод EXPLAIN: Seq Scan 114мс → Index Scan 28мс.
- guard: при вырожденном центроиде (fallback на центр ЕКБ) гео-радиус
пропускается (source=no_data_centroid_fallback) — не выдаём центр-города
медиану за калиброванную цену участка.
- price_source="objective_geo_radius" (plain str, без schema-change).
- ptica-adapt: price_source → честные RU-подписи (не сырой токен).
- +4 теста (4-tier priority chain). deep-review ✅.
Refs #1881
Финмодель брала фиксированное окно продаж 30 мес независимо от рынка
(schedule_is_default всегда true). Теперь окно считается из ЛОКАЛЬНОГО темпа
поглощения, который уже вычисляется в том же /analyze, но финмодель его
игнорировала.
Корректность абсорбции (ключевое): velocity.monthly_velocity_sqm — это СУММА
поглощения ВСЕГО конкурентного набора в радиусе (м²/мес), НЕ темп одного
проекта. Поэтому per-project absorption = monthly_velocity / max(n_with_sales,1)
(темп одного типичного локального продавца) — иначе модель считала бы, что новый
проект забирает весь рыночный темп (дико оптимистично). Поле
project_absorption_sqm_per_month добавлено в VelocityResult (objective-путь);
rosreestr-fallback и вырожденные пути → None (поквартальный count без
по-проектной декомпозиции не может задавать график).
financial.py: окно = clamp(ceil(residential/velocity), MIN=6, MAX=120) при
конечной velocity>0; иначе дефолт 30. Эскроу-инвариант сохранён:
sales_end=max(sales_start+base, constr_end). Инвариант Σ cashflow == net_profit
держится (перенос выручки во времени не меняет сумму). schedule_is_default
флипается в false когда график рыночный; новое поле sales_duration_months
(реализованное окно) для UI/PDF.
Wiring: parcels.py → synthesize_parcel_financial(velocity_sqm_per_month) →
compute_financial(market_velocity_sqm_per_month). Generative §1c путь пока
передаёт None (out of scope, follow-up).
Тесты: +13 (None→дефолт+инвариант; рыночная velocity; клампы MIN/MAX; эскроу;
non-finite→fallback; rosreestr→None; инвариант по размерам окна; регресс PR-3 —
ровно одна смена знака на коротком окне). Полный бэкенд: 3414 passed, 0 failed.
ruff+mypy(strict financial.py) чисто. api-types перегенерены.
Code-review: 2× approve, 0 majors (adversarial correctness-lens подтвердил
семантику абсорбции, инвариант, не-proxy IRR, клампы, rosreestr-None).
Refs #1881
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>