fix(site-finder): non-overlapping L2/L3 ready_dt windows, stop double-count of 13-24mo supply (#1355) #1677

Merged
lekss361 merged 1 commit from fix/supply-layers-overlap-1355 into main 2026-06-17 17:45:09 +00:00

View file

@ -81,9 +81,14 @@ _EKB_REGION_CODE: int = 66
# Ниже — слишком много исключённых (NULL free_flats) объектов → 'low' (см. limitations).
_L2_MEDIUM_MIN_COVERAGE: float = 0.6
# L2/L3 граница (включительно в L2, исключительно в L3) — половинно-открытые интервалы:
# L2 (hidden) : ready_dt IS NULL OR ready_dt <= now + L2_NEAR_HORIZON [now, now+12mo]
# L3 (future) : ready_dt IS NOT NULL AND ready_dt > now + L3_FUTURE_HORIZON (now+12mo, ∞)
# Оба используют одно и то же значение 12 мес., но по разные стороны границы — нет overlap.
_L2_NEAR_HORIZON_MONTHS: int = 12 # L2 берёт объекты с ready_dt ≤ сейчас+12мес (или NULL)
# L3: «будущее» = ready_dt дальше этого горизонта от сегодня. Раньше — это уже
# почти-на-рынке предложение (ловится L1/L2), не «future».
_L3_FUTURE_HORIZON_MONTHS: int = 12
_L3_FUTURE_HORIZON_MONTHS: int = 12 # L3 берёт объекты с ready_dt > сейчас+12мес
# L3: «ещё не продаётся» = выставлено не больше этого числа квартир. >0 порог (не строго
# 0) терпит единичные тестовые/служебные листинги в проекте, который по сути не открыт.
@ -215,6 +220,10 @@ _L1_OPEN_SQL = text(
# free_flats) к району. ИСКЛЮЧАЕМ free_flats IS NULL (нет sold-колонки → не вычислим;
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
#
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
_L2_HIDDEN_SQL = text(
"""
WITH latest AS (
@ -222,7 +231,8 @@ _L2_HIDDEN_SQL = text(
o.obj_id,
o.district_name,
o.flat_count,
o.free_flats
o.free_flats,
o.ready_dt
FROM domrf_kn_objects o
WHERE o.region_cd = :region_cd
AND o.district_name IS NOT NULL
@ -240,6 +250,8 @@ _L2_HIDDEN_SQL = text(
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
FROM latest
WHERE ready_dt IS NULL
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
GROUP BY district_name
"""
)
@ -368,7 +380,11 @@ def compute_layer2_hidden(
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
units_estimate всегда 0 (clamp). Пустые данные []. Не бросает.
"""
params: dict[str, Any] = {"region_cd": _EKB_REGION_CODE, "district": district}
params: dict[str, Any] = {
"region_cd": _EKB_REGION_CODE,
"district": district,
"near_horizon": f"{_L2_NEAR_HORIZON_MONTHS} months",
}
rows = _safe_rows(db, _L2_HIDDEN_SQL, params, layer=2)
out: list[SupplyLayerRow] = []