Deterministic, no-LLM market metrics from Objective data for a location
(district and/or obj_ids): absorption_rate, months_of_supply, sell_through_pct,
unit/area_velocity, liquidity_index per room-type, overstock_index,
demand_concentration (HHI), price_sensitivity (reuses
analytics_queries._elasticity_coef). Frozen MarketMetrics dataclass + as_dict,
sample-size confidence (§15 spirit).
Graceful on thin data (the #1 coverage risk): no data → metric None (never
0/crash), confidence='low'. Filters by district/obj_ids to sidestep the sparse
domrf↔objective mapping. Read-only SELECTs, 55 unit/mock tests. Consumed by
forecast (#952) + relevance (#949 PR B). No migration, no endpoint.
Round 1 (commit bcd7dc8) был broken: на 2-bucket входах surplus уходил в free
полностью без учёта capacity → free превышал cap → следующая итерация
clamp'ировала его и наоборот. Infinite oscillation в FastAPI handler.
Round 2 fix per review BLOCK (#282 comment):
- Surplus распределяется пропорционально available capacity (cap - v),
не текущему v. Free никогда не вылетит выше cap.
- free = строго < cap (не <=) — иначе деление на 0 capacity.
- Hard guard `for _ in range(N+1)` — гарантированно завершается.
- Pathological (surplus > total_capacity): возвращаем оригинальный pct_map
+ cap_skipped=True (sum=100 invariant сохранён).
- Hamilton round вынесен в _hamilton_round() helper.
Tests:
- 2-bucket cases (90/10, 70/30, 99/1) expected cap_skipped=True
- test_cap_iteration_count_bounded — все pathological завершаются < 100ms
- All 13 cases verified standalone (3 fast-path + 7 reproduced + 3 pathological)
Раньше _VELOCITY_DIVISORS делил агрегаты mv_layout_velocity (24 мес)
на 4/12 для quarter/year, не меняя реальное окно данных. Теперь
inline SQL из objective_corpus_room_month с CAST(:window_interval AS interval).
velocity_per_month = deals_window / months_in_window (1.0/3.0/12.0).
Разные time_window → разные строки из БД → разный mix/velocity/jk_count.
Closes (epic part) #271 item 1