feat(obj-3): SF backend migrate to objective_lots ground truth (price 0.3% → 81%) #324

Merged
lekss361 merged 1 commit from feat/obj-3-sf-migrate-objective into main 2026-05-17 20:08:18 +00:00
2 changed files with 60 additions and 16 deletions

View file

@ -1330,7 +1330,13 @@ def analyze_parcel(
}
)
# 5) Конкуренты в радиусе 3 км из DOM.РФ.
# 5) Конкуренты в радиусе 3 км из DOM.РФ с ценами из objective_lots.
# OBJ-3: обогащаем каждый ЖК данными objective_lots через маппинг
# domrf_kn_objects.obj_id → objective_complex_mapping.domrf_obj_id
# → objective_lots.project_name.
# Агрегат: avg_price_per_m2_rub (81% coverage), avg_area_pd, units_sold,
# units_available — для UI-блока «Конкуренты».
# LEFT JOIN — ЖК без маппинга остаются в выдаче (поля = NULL).
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
# latest snapshot, иначе дубликаты ЖК в выдаче.
competitor_rows = (
@ -1341,26 +1347,46 @@ def analyze_parcel(
FROM domrf_kn_objects
WHERE latitude IS NOT NULL
ORDER BY obj_id, snapshot_date DESC NULLS LAST
),
obj_pricing AS (
SELECT
cm.domrf_obj_id,
ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub,
ROUND(AVG(ol.area_pd)::numeric, 1) AS avg_area_pd,
COUNT(*) FILTER (WHERE ol.is_sold) AS units_sold,
COUNT(*) FILTER (WHERE NOT ol.is_sold) AS units_available,
COUNT(*) FILTER (
WHERE ol.price_per_m2_rub IS NOT NULL
) AS lots_with_price
FROM objective_complex_mapping cm
JOIN objective_lots ol ON ol.project_name = cm.objective_complex_name
GROUP BY cm.domrf_obj_id
)
SELECT obj_id,
comm_name,
dev_name,
obj_class,
flat_count,
district_name,
site_status,
ready_dt,
SELECT o.obj_id,
o.comm_name,
o.dev_name,
o.obj_class,
o.flat_count,
o.district_name,
o.site_status,
o.ready_dt,
p.avg_price_per_m2_rub,
p.avg_area_pd,
p.units_sold,
p.units_available,
p.lots_with_price,
ST_Distance(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
) AS distance_m
FROM latest_obj o
LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
WHERE ST_DWithin(
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
3000
)
ORDER BY CASE site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
ORDER BY CASE o.site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END,
distance_m ASC
LIMIT 20
"""),
@ -2146,7 +2172,7 @@ def analyze_parcel(
"competitors": [dict(c) for c in competitor_rows],
# D4 (#36): 24-month pipeline competition
"pipeline_24mo": pipeline_24mo,
# D2 (#34): velocity-score из domrf_kn_sale_graph
# D2 (#34): velocity-score из objective_corpus_room_month (OBJ-3 migrated)
"velocity": velocity_data,
"noise": {
"score": round(noise_score, 2),

View file

@ -2,8 +2,9 @@
Mock-based не требуют живой БД.
Источник данных objective_corpus_room_month (мигрировано с domrf_kn_sale_graph).
Mock shape совместим: sales query возвращает те же aliases (obj_id, total_sqm,
months_with_data, period_start, period_end) через GROUP BY domrf_obj_id.
Mock shape совместим: sales query возвращает aliases (obj_id, total_sqm,
months_with_data, period_start, period_end, has_mapping) через LEFT JOIN
all_competitors + mapped (OBJ-2: ALL competitors included, unmapped has_mapping=False).
Третий вызов execute bucket_rows (obj_id, room_bucket, units_sold, sqm_sold).
"""
@ -46,6 +47,7 @@ def _sales_row(
months: int,
start: str = "2024-11-01",
end: str = "2025-04-01",
has_mapping: bool = True,
) -> MagicMock:
r = MagicMock()
start_d = datetime.date.fromisoformat(start)
@ -56,6 +58,9 @@ def _sales_row(
"months_with_data": months,
"period_start": start_d,
"period_end": end_d,
# OBJ-2: LEFT JOIN all_competitors — все конкуренты включены,
# has_mapping=True если есть маппинг в objective_complex_mapping.
"has_mapping": has_mapping,
}[k]
return r
@ -199,9 +204,14 @@ def test_score_capped_at_1():
def test_score_zero_when_no_sales_sqm():
"""total_sqm=0 → None (нет данных, не score=0)."""
"""total_sqm=0 → VelocityResult с velocity_data_available=False, score=0.
OBJ-2: функция больше не возвращает None при нулевых продажах
возвращает «пустое» состояние (velocity_data_available=False, source='none').
Rosreestr-fallback пропускается т.к. cad_quarter не передан.
"""
comp_rows = [_comp_row(1)]
# total_sqm=0 — нет продаж → должен вернуть None
# total_sqm=0 — нет продаж → velocity_data_available=False
sales_rows = [_sales_row(1, total_sqm=0.0, months=5)]
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
@ -212,7 +222,10 @@ def test_score_zero_when_no_sales_sqm():
):
result = compute_velocity(db, parcel_geom_wkt=_PARCEL_WKT)
assert result is None
assert result is not None
assert result.velocity_data_available is False
assert result.velocity_score == pytest.approx(0.0)
assert result.velocity_source == "none"
def test_as_dict_structure():
@ -228,6 +241,8 @@ def test_as_dict_structure():
period_end="2025-02",
sample_competitors=[],
by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}},
velocity_data_available=True,
velocity_source="objective",
)
d = vr.as_dict()
assert "competitors_count" in d
@ -239,6 +254,9 @@ def test_as_dict_structure():
assert d["velocity_score"] == pytest.approx(0.333, abs=1e-3)
assert "by_room_bucket" in d
assert d["by_room_bucket"]["1"]["units"] == 10
# OBJ-2: новые поля velocity_data_available и velocity_source
assert d["velocity_data_available"] is True
assert d["velocity_source"] == "objective"
def test_sample_competitors_top5():