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 по
|
# NB: domrf_kn_objects имеет ~3 snapshot per obj_id → DISTINCT ON по
|
||||||
# latest snapshot, иначе дубликаты ЖК в выдаче.
|
# latest snapshot, иначе дубликаты ЖК в выдаче.
|
||||||
competitor_rows = (
|
competitor_rows = (
|
||||||
|
|
@ -1341,26 +1347,46 @@ def analyze_parcel(
|
||||||
FROM domrf_kn_objects
|
FROM domrf_kn_objects
|
||||||
WHERE latitude IS NOT NULL
|
WHERE latitude IS NOT NULL
|
||||||
ORDER BY obj_id, snapshot_date DESC NULLS LAST
|
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,
|
SELECT o.obj_id,
|
||||||
comm_name,
|
o.comm_name,
|
||||||
dev_name,
|
o.dev_name,
|
||||||
obj_class,
|
o.obj_class,
|
||||||
flat_count,
|
o.flat_count,
|
||||||
district_name,
|
o.district_name,
|
||||||
site_status,
|
o.site_status,
|
||||||
ready_dt,
|
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_Distance(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
) AS distance_m
|
) AS distance_m
|
||||||
FROM latest_obj o
|
FROM latest_obj o
|
||||||
|
LEFT JOIN obj_pricing p ON p.domrf_obj_id = o.obj_id
|
||||||
WHERE ST_DWithin(
|
WHERE ST_DWithin(
|
||||||
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
ST_SetSRID(ST_MakePoint(o.longitude, o.latitude), 4326)::geography,
|
||||||
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
3000
|
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
|
distance_m ASC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
"""),
|
"""),
|
||||||
|
|
@ -2146,7 +2172,7 @@ def analyze_parcel(
|
||||||
"competitors": [dict(c) for c in competitor_rows],
|
"competitors": [dict(c) for c in competitor_rows],
|
||||||
# D4 (#36): 24-month pipeline competition
|
# D4 (#36): 24-month pipeline competition
|
||||||
"pipeline_24mo": pipeline_24mo,
|
"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,
|
"velocity": velocity_data,
|
||||||
"noise": {
|
"noise": {
|
||||||
"score": round(noise_score, 2),
|
"score": round(noise_score, 2),
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
Mock-based — не требуют живой БД.
|
Mock-based — не требуют живой БД.
|
||||||
Источник данных — objective_corpus_room_month (мигрировано с domrf_kn_sale_graph).
|
Источник данных — objective_corpus_room_month (мигрировано с domrf_kn_sale_graph).
|
||||||
Mock shape совместим: sales query возвращает те же aliases (obj_id, total_sqm,
|
Mock shape совместим: sales query возвращает aliases (obj_id, total_sqm,
|
||||||
months_with_data, period_start, period_end) через GROUP BY domrf_obj_id.
|
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).
|
Третий вызов execute — bucket_rows (obj_id, room_bucket, units_sold, sqm_sold).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -46,6 +47,7 @@ def _sales_row(
|
||||||
months: int,
|
months: int,
|
||||||
start: str = "2024-11-01",
|
start: str = "2024-11-01",
|
||||||
end: str = "2025-04-01",
|
end: str = "2025-04-01",
|
||||||
|
has_mapping: bool = True,
|
||||||
) -> MagicMock:
|
) -> MagicMock:
|
||||||
r = MagicMock()
|
r = MagicMock()
|
||||||
start_d = datetime.date.fromisoformat(start)
|
start_d = datetime.date.fromisoformat(start)
|
||||||
|
|
@ -56,6 +58,9 @@ def _sales_row(
|
||||||
"months_with_data": months,
|
"months_with_data": months,
|
||||||
"period_start": start_d,
|
"period_start": start_d,
|
||||||
"period_end": end_d,
|
"period_end": end_d,
|
||||||
|
# OBJ-2: LEFT JOIN all_competitors — все конкуренты включены,
|
||||||
|
# has_mapping=True если есть маппинг в objective_complex_mapping.
|
||||||
|
"has_mapping": has_mapping,
|
||||||
}[k]
|
}[k]
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
@ -199,9 +204,14 @@ def test_score_capped_at_1():
|
||||||
|
|
||||||
|
|
||||||
def test_score_zero_when_no_sales_sqm():
|
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)]
|
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)]
|
sales_rows = [_sales_row(1, total_sqm=0.0, months=5)]
|
||||||
|
|
||||||
db = _make_db(comp_rows=comp_rows, sales_rows=sales_rows)
|
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)
|
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():
|
def test_as_dict_structure():
|
||||||
|
|
@ -228,6 +241,8 @@ def test_as_dict_structure():
|
||||||
period_end="2025-02",
|
period_end="2025-02",
|
||||||
sample_competitors=[],
|
sample_competitors=[],
|
||||||
by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}},
|
by_room_bucket={"1": {"units": 10, "sqm": 450.0, "complexes_count": 2}},
|
||||||
|
velocity_data_available=True,
|
||||||
|
velocity_source="objective",
|
||||||
)
|
)
|
||||||
d = vr.as_dict()
|
d = vr.as_dict()
|
||||||
assert "competitors_count" in d
|
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 d["velocity_score"] == pytest.approx(0.333, abs=1e-3)
|
||||||
assert "by_room_bucket" in d
|
assert "by_room_bucket" in d
|
||||||
assert d["by_room_bucket"]["1"]["units"] == 10
|
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():
|
def test_sample_competitors_top5():
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue