feat(site-finder): additive objective_lots price fallback for competitors (#307 OBJ-3)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (push) Successful in 1m5s
CI / frontend-tests (pull_request) Successful in 59s
CI / openapi-codegen-check (push) Successful in 2m10s
CI / openapi-codegen-check (pull_request) Successful in 1m39s
CI / backend-tests (push) Successful in 9m2s
CI / backend-tests (pull_request) Successful in 8m56s
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (push) Successful in 1m5s
CI / frontend-tests (pull_request) Successful in 59s
CI / openapi-codegen-check (push) Successful in 2m10s
CI / openapi-codegen-check (pull_request) Successful in 1m39s
CI / backend-tests (push) Successful in 9m2s
CI / backend-tests (pull_request) Successful in 8m56s
competitors._AVG_PRICE_SQL читает domrf_kn_flats.price_per_m2, но на последнем
snapshot эта колонка пуста (0 строк, скрапер перестал отдавать per-flat цены
~2026-06-03) -> avg_price_per_m2=None почти всем конкурентам, price_similarity
вырождается в нейтраль 0.5. Добавлен additive fallback: где domrf-цены нет,
берём медиану objective_lots.price_per_m2_rub через objective_complex_mapping
(1:1 мост, ~80% покрытие, прод-median 136k руб/м²). domrf-цены приоритетны
(precedence domrf > objective); новое поле price_source ('domrf'/'objective')
для прозрачности инвест-рекомендаций. Веса и mv_layout_velocity не тронуты
(#295 defer). Медиана (не weighted-avg) устойчива к хвостам. api-types.ts
regenerated (v7.13.0 pinned + prettier).
Refs #307
This commit is contained in:
parent
f9dd903f4d
commit
0778af5d2c
4 changed files with 168 additions and 1 deletions
|
|
@ -198,6 +198,11 @@ class Competitor(BaseModel):
|
|||
sold_pct: float | None
|
||||
velocity_per_month: float
|
||||
avg_price_per_m2: float | None
|
||||
# OBJ-3 (#307): источник avg_price_per_m2 для прозрачности инвест-рекомендаций.
|
||||
# "domrf" — per-flat цена domrf_kn_flats (приоритет); "objective" — медиана
|
||||
# objective_lots.price_per_m2_rub через objective_complex_mapping (additive
|
||||
# fallback, заполняет пробелы где domrf пуст); None — цены нет ни там, ни там.
|
||||
price_source: str | None = None
|
||||
is_active: bool
|
||||
# #949 PR B (ТЗ §9.1, §16): детерминированный relevance_weight ∈ [0,1] —
|
||||
# взвешенная комбинация geo_proximity / class_similarity / price_similarity /
|
||||
|
|
|
|||
|
|
@ -473,6 +473,37 @@ _AVG_PRICE_SQL = text("""
|
|||
GROUP BY f.obj_id
|
||||
""")
|
||||
|
||||
# OBJ-3 (#307): additive fallback цены м² из objective_lots, когда domrf-цены нет.
|
||||
# Замер прода 2026-06: на ПОСЛЕДНЕМ snapshot domrf_kn_flats.price_per_m2 = 0 строк
|
||||
# (скрапер перестал отдавать per-flat цены), поэтому _AVG_PRICE_SQL выше даёт
|
||||
# avg_price_per_m2=None почти всем конкурентам. objective_lots.price_per_m2_rub
|
||||
# покрывает ~80% (919795/1123771 строк) и мостится к domrf-obj_id через
|
||||
# objective_complex_mapping (то же звено, что _INLINE_VELOCITY_SQL в best_layouts).
|
||||
#
|
||||
# Мост: domrf_kn_objects.obj_id == objective_complex_mapping.domrf_obj_id
|
||||
# → objective_complex_mapping.objective_complex_name == objective_lots.project_name.
|
||||
# Маппинг 1:1 (прод: 263 строки, 263 distinct obj_id, 0 obj_id с >1 именем), поэтому
|
||||
# на obj_id приходится РОВНО один project_name; корпуса проекта агрегируются в один
|
||||
# проект естественно. Берём МЕДИАНУ price_per_m2_rub по всем лотам проекта
|
||||
# (percentile_cont 0.5) — устойчива к хвостовым выбросам пентхаусов/студий, в отличие
|
||||
# от AVG. Прод-санити: медиана медиан 136k руб/м² (диапазон 80k–250k для ЕКБ ✓).
|
||||
#
|
||||
# Additive-контракт: возвращаем ТОЛЬКО obj_id, у которых objective-цена есть; в Python
|
||||
# заполняем пробелы (domrf-цена приоритетна, objective — fallback). Существующие
|
||||
# непустые domrf-выводы НЕ меняются. price_source делает источник прозрачным.
|
||||
_OBJECTIVE_PRICE_FALLBACK_SQL = text("""
|
||||
SELECT
|
||||
cm.domrf_obj_id AS obj_id,
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY ol.price_per_m2_rub)
|
||||
AS median_price_per_m2
|
||||
FROM objective_complex_mapping cm
|
||||
JOIN objective_lots ol
|
||||
ON ol.project_name = cm.objective_complex_name
|
||||
WHERE cm.domrf_obj_id = ANY(:obj_ids)
|
||||
AND ol.price_per_m2_rub IS NOT NULL
|
||||
GROUP BY cm.domrf_obj_id
|
||||
""")
|
||||
|
||||
|
||||
def _quarter_from_cad(cad_num: str) -> str:
|
||||
"""Извлечь кадастровый квартал из номера участка/здания.
|
||||
|
|
@ -579,6 +610,7 @@ def get_competitors(
|
|||
# Читаем через _row_get: существующие тесты мокируют только avg_price_per_m2,
|
||||
# отсутствующий ключ flats_sold → None (не KeyError) → нейтральный stage.
|
||||
avg_price_map: dict[int, float] = {}
|
||||
price_source_map: dict[int, str] = {}
|
||||
sold_count_map: dict[int, int] = {}
|
||||
try:
|
||||
price_rows = db.execute(_AVG_PRICE_SQL, {"obj_ids": obj_ids}).mappings().all()
|
||||
|
|
@ -587,12 +619,36 @@ def get_competitors(
|
|||
price = _row_get(r, "avg_price_per_m2")
|
||||
if price is not None:
|
||||
avg_price_map[oid] = float(price)
|
||||
price_source_map[oid] = "domrf"
|
||||
sold = _row_get(r, "flats_sold")
|
||||
if sold is not None:
|
||||
sold_count_map[oid] = int(sold)
|
||||
except Exception:
|
||||
logger.warning("competitors: avg_price query failed, continuing without prices")
|
||||
|
||||
# ── 4a. OBJ-3 (#307) additive fallback: где domrf-цены НЕТ → objective_lots ──
|
||||
# Заполняем ТОЛЬКО пробелы (obj_id без domrf-цены). Существующие domrf-выводы
|
||||
# не трогаем (precedence: domrf > objective). Прод 2026-06: domrf на последнем
|
||||
# snapshot = 0 цен → почти все obj_id попадают в этот fallback (~80% покрытие).
|
||||
missing_price_ids = [oid for oid in obj_ids if oid not in avg_price_map]
|
||||
if missing_price_ids:
|
||||
try:
|
||||
obj_price_rows = (
|
||||
db.execute(_OBJECTIVE_PRICE_FALLBACK_SQL, {"obj_ids": missing_price_ids})
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
for r in obj_price_rows:
|
||||
oid = int(r["obj_id"])
|
||||
price = _row_get(r, "median_price_per_m2")
|
||||
if price is not None and oid not in avg_price_map:
|
||||
avg_price_map[oid] = float(price)
|
||||
price_source_map[oid] = "objective"
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"competitors: objective price fallback failed, continuing with domrf only"
|
||||
)
|
||||
|
||||
# ── 4b. Локальные эталоны для relevance (медиана цены, доминирующий класс) ─
|
||||
radius_m = request.radius_km * 1000.0
|
||||
local_median_ppm2 = _median(list(avg_price_map.values()))
|
||||
|
|
@ -656,6 +712,7 @@ def get_competitors(
|
|||
sold_pct=sold_pct,
|
||||
velocity_per_month=velocity,
|
||||
avg_price_per_m2=avg_price_map.get(obj_id),
|
||||
price_source=price_source_map.get(obj_id),
|
||||
is_active=is_active,
|
||||
relevance_weight=round(relevance_weight, 3),
|
||||
relevance_breakdown=relevance_breakdown,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ def _price_row(obj_id: int, price: float) -> MagicMock:
|
|||
return r
|
||||
|
||||
|
||||
def _obj_price_row(obj_id: int, price: float) -> MagicMock:
|
||||
"""Строка objective-fallback цены (_OBJECTIVE_PRICE_FALLBACK_SQL, OBJ-3 #307)."""
|
||||
r = MagicMock()
|
||||
r.__getitem__ = lambda self, k: {"obj_id": obj_id, "median_price_per_m2": price}[k]
|
||||
return r
|
||||
|
||||
|
||||
# ── Построение mock DB ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -60,13 +67,17 @@ def _make_db(
|
|||
coord: MagicMock | None = None,
|
||||
obj_rows: list[MagicMock] | None = None,
|
||||
price_rows: list[MagicMock] | None = None,
|
||||
obj_price_rows: list[MagicMock] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Сконструировать mock Session.
|
||||
|
||||
Порядок вызовов execute:
|
||||
1. centroid query → coord
|
||||
2. competitors query → obj_rows
|
||||
3. avg_price query → price_rows
|
||||
3. domrf avg_price query → price_rows
|
||||
4. objective price fallback (OBJ-3 #307) → obj_price_rows
|
||||
(вызывается ТОЛЬКО если у части obj_id нет domrf-цены — почти всегда,
|
||||
т.к. domrf на последнем snapshot пуст; по умолчанию [] = fallback пуст)
|
||||
"""
|
||||
db = MagicMock()
|
||||
|
||||
|
|
@ -75,6 +86,7 @@ def _make_db(
|
|||
(coord, True),
|
||||
(obj_rows or [], False),
|
||||
(price_rows or [], False),
|
||||
(obj_price_rows or [], False),
|
||||
]:
|
||||
result = MagicMock()
|
||||
if is_first:
|
||||
|
|
@ -390,6 +402,97 @@ def test_competitors_avg_price_populated() -> None:
|
|||
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0), (
|
||||
"avg_price_per_m2 должен быть не None — регрессия #227 status='sold' filter"
|
||||
)
|
||||
# OBJ-3 #307: domrf-hit → price_source='domrf'.
|
||||
assert comp["price_source"] == "domrf"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── OBJ-3 (#307) additive fallback из objective_lots ────────────────────────────
|
||||
|
||||
|
||||
def test_competitors_objective_price_fallback_fills_gap() -> None:
|
||||
"""domrf-цены НЕТ → objective_lots заполняет avg_price_per_m2 (OBJ-3 #307).
|
||||
|
||||
Прод 2026-06: domrf_kn_flats.price_per_m2 на последнем snapshot = 0 строк,
|
||||
поэтому domrf даёт None почти всем. objective_lots (через
|
||||
objective_complex_mapping) покрывает ~80% — этот fallback восстанавливает цену.
|
||||
"""
|
||||
rows = [_obj_row(obj_id=1)]
|
||||
# domrf price пуст → fallback на objective median.
|
||||
obj_price_rows = [_obj_price_row(obj_id=1, price=136_000.0)]
|
||||
db = _make_db(
|
||||
coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=obj_price_rows
|
||||
)
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
comp = resp.json()["competitors"][0]
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(136_000.0)
|
||||
assert comp["price_source"] == "objective"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_competitors_domrf_price_wins_over_objective() -> None:
|
||||
"""domrf-цена приоритетна: objective НЕ перетирает существующий domrf-вывод.
|
||||
|
||||
Additive-контракт OBJ-3: где domrf есть → не трогаем; objective только пробелы.
|
||||
"""
|
||||
rows = [_obj_row(obj_id=1)]
|
||||
price_rows = [_price_row(obj_id=1, price=150_000.0)]
|
||||
# objective тоже вернул бы цену, но domrf уже есть → fallback её игнорирует.
|
||||
obj_price_rows = [_obj_price_row(obj_id=1, price=99_000.0)]
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
obj_rows=rows,
|
||||
price_rows=price_rows,
|
||||
obj_price_rows=obj_price_rows,
|
||||
)
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
comp = resp.json()["competitors"][0]
|
||||
assert comp["avg_price_per_m2"] == pytest.approx(150_000.0)
|
||||
assert comp["price_source"] == "domrf"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_competitors_no_price_anywhere_is_none() -> None:
|
||||
"""Ни domrf, ни objective → avg_price_per_m2 None, price_source None (graceful)."""
|
||||
rows = [_obj_row(obj_id=1)]
|
||||
db = _make_db(coord=_coord_row(), obj_rows=rows, price_rows=[], obj_price_rows=[])
|
||||
|
||||
from app.core.db import get_db
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db(db)
|
||||
try:
|
||||
client = TestClient(app)
|
||||
resp = client.post(
|
||||
"/api/v1/parcels/66:41:0303161:5/competitors",
|
||||
json={"radius_km": 1.0, "time_window": "last_quarter"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
comp = resp.json()["competitors"][0]
|
||||
assert comp["avg_price_per_m2"] is None
|
||||
assert comp["price_source"] is None
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
|
|
|||
|
|
@ -3070,6 +3070,8 @@ export interface components {
|
|||
velocity_per_month: number;
|
||||
/** Avg Price Per M2 */
|
||||
avg_price_per_m2: number | null;
|
||||
/** Price Source */
|
||||
price_source?: string | null;
|
||||
/** Is Active */
|
||||
is_active: boolean;
|
||||
/** Relevance Weight */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue