feat(obj-3): SF backend migrate to objective_lots ground truth (price 0.3% → 81%) #324
2 changed files with 60 additions and 16 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue