From 9f6701d3a5fc2b647c3aa0f3e274d643c1d80ab3 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 14:28:28 +0500 Subject: [PATCH] =?UTF-8?q?=D0=91=D1=8D=D0=BA=D1=82=D0=B5=D1=81=D1=82=20?= =?UTF-8?q?=D0=B1=D0=B5=D1=80=D1=91=D1=82=20=D1=82=D0=B8=D0=BF=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=B0=20=D0=B8=D0=B7=20=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B0=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=BA=D0=B0=D0=BA=20=D0=B1=D0=BE=D0=B5=D0=B2?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B0=20(#2862)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deals.house_type пуст у всех 434 911 сделок Росреестра (прод 17.09), поэтому в прогоне с --resolve-house-id штраф за несовпадение типа дома в отборе аналогов не срабатывал ни разу. Боевой estimate_quality с #3234 подставляет тип из houses (по резолвленному house_id, иначе ближайший дом в 60 м), когда его нет в форме; бэктест этого не повторял. Теперь при --resolve-house-id пустой тип сделки дозаполняется тем же _lookup_house_facts, свой тип сделки главнее. Счётчик house_type_from_houses в блоке house_id_resolution показывает, скольким сделкам тип подставлен. Год и этажность из houses намеренно не берутся: они сдвинули бы метрики по другим признакам и смешали бы замер эффекта типа дома. Без флага вывод прежний. Основная часть issue (код материала стен Росреестра -> тип дома) не сделана: нужен справочник 126-УНСИ и решение владельца по соответствию. Co-Authored-By: Claude Opus 5 --- .../backend/scripts/backtest_estimator.py | 19 ++- .../backend/tests/test_backtest_estimator.py | 121 +++++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 1a4b1645..0a6a0f88 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1674,12 +1674,16 @@ class _HouseIdResolution: total: int = 0 # deals processed in the full-engine path under the flag resolved: int = 0 # deals where match_house_readonly returned an id imv_reachable: int = 0 # of resolved, those whose house_imv anchor fired + # #2862: сделкам, у которых тип дома подставлен из `houses` (deals.house_type пуст + # у всех строк). Ноль здесь = штраф за тип дома в прогоне не участвовал вовсе. + house_type_from_houses: int = 0 def as_json(self) -> dict[str, int]: return { "resolved": self.resolved, "total": self.total, "imv_reachable": self.imv_reachable, + "house_type_from_houses": self.house_type_from_houses, } @@ -2550,6 +2554,18 @@ def run_backtest_full( target_house_id = _resolve_house_id_for_deal(db, deal) if target_house_id is not None: resolution.resolved += 1 + # #2862: боевой estimate_quality (#3234) берёт тип дома из `houses`, когда + # его нет в форме — по резолвленному house_id, иначе ближайший дом в 60 м. + # deals.house_type пуст у всех сделок, так что без этого штраф за тип дома + # в прогоне не срабатывал ни разу. Только тип: год и этажность из `houses` + # сдвинули бы метрики по другим признакам и смешали бы замер. + if deal.house_type is None: + facts = est.m._lookup_house_facts( + db, target_house_id=target_house_id, lat=deal.lat, lon=deal.lon + ) + if facts is not None and facts.house_type is not None: + deal = dataclasses.replace(deal, house_type=facts.house_type) + resolution.house_type_from_houses += 1 try: pr = _predict_full_spine( db, @@ -2616,10 +2632,11 @@ def run_backtest_full( if resolution is not None: metrics["house_id_resolution"] = resolution.as_json() logger.info( - "house_id resolution: resolved=%d/%d imv_reachable=%d", + "house_id resolution: resolved=%d/%d imv_reachable=%d house_type_from_houses=%d", resolution.resolved, resolution.total, resolution.imv_reachable, + resolution.house_type_from_houses, ) if dump_fixture is not None and capture is not None: diff --git a/tradein-mvp/backend/tests/test_backtest_estimator.py b/tradein-mvp/backend/tests/test_backtest_estimator.py index 7d5caf0f..afac780a 100644 --- a/tradein-mvp/backend/tests/test_backtest_estimator.py +++ b/tradein-mvp/backend/tests/test_backtest_estimator.py @@ -519,13 +519,23 @@ def test_argparse_resolve_house_id_override() -> None: def test_house_id_resolution_as_json_shape() -> None: - res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2) - assert res.as_json() == {"resolved": 4, "total": 10, "imv_reachable": 2} + res = bt._HouseIdResolution(total=10, resolved=4, imv_reachable=2, house_type_from_houses=3) + assert res.as_json() == { + "resolved": 4, + "total": 10, + "imv_reachable": 2, + "house_type_from_houses": 3, + } def test_house_id_resolution_defaults_zero() -> None: res = bt._HouseIdResolution() - assert res.as_json() == {"resolved": 0, "total": 0, "imv_reachable": 0} + assert res.as_json() == { + "resolved": 0, + "total": 0, + "imv_reachable": 0, + "house_type_from_houses": 0, + } # --------------------------------------------------------------------------- # @@ -1006,3 +1016,108 @@ def test_predict_full_spine_passes_resolved_city_to_corridor( assert captured.get("called") is True assert captured.get("city") == "нижний тагил" assert pred is not None + + +# --------------------------------------------------------------------------- # +# #2862: тип дома из `houses` доезжает до отбора аналогов, как в estimate_quality. +# --------------------------------------------------------------------------- # + + +def _run_full_with_house_facts( + monkeypatch: pytest.MonkeyPatch, *, resolve_house_id: bool +) -> tuple[dict[int, set[object]], list[dict[str, object]], dict[str, object]]: + from types import SimpleNamespace + + from app.services import estimator as est_mod + + def _deal(deal_id: int, house_type: str | None) -> object: + return bt.DealSample( + id=deal_id, + lon=60.6, + lat=56.8, + rooms=2, + sold_ppm2=100_000.0, + deal_date=None, + area_m2=50.0, + address="Екатеринбург, ул. Ленина, 5", + floor=3, + year_built=None, + house_type=house_type, + ) + + # Сделка 1 — как все сделки на проде (house_type пуст); сделка 2 — тип уже есть. + monkeypatch.setattr(bt, "_load_sample", lambda *a, **kw: [_deal(1, None), _deal(2, "brick")]) + monkeypatch.setattr(bt, "_resolve_house_id_for_deal", lambda _db, _deal: 77) + + lookups: list[dict[str, object]] = [] + + def _fake_lookup(_db: object, **kw: object) -> object: + lookups.append(kw) + return est_mod._HouseFacts( + house_id=77, total_floors=16, year_built=1985, house_type="panel", material_walls=None + ) + + seen: dict[int, set[object]] = {} + current: dict[str, int] = {} + + def _fake_fetch_analogs(_db: object, **kw: object) -> tuple[list[object], bool, str]: + seen.setdefault(current["id"], set()).add(kw["house_type"]) + return [], False, "W" + + def _fake_price_from_inputs(**_kw: object) -> SimpleNamespace: + return SimpleNamespace( + median_price=5_000_000.0, + median_ppm2=100_000.0, + confidence="low", + anchor_tier=None, + expected_sold_per_m2=95_000.0, + expected_sold_price=4_750_000.0, + expected_sold_range_low=4_000_000.0, + expected_sold_range_high=5_500_000.0, + ) + + real_predict = bt._predict_full_spine + + def _tracking_predict(db: object, deal: object, est: object, **kw: object) -> object: + current["id"] = deal.id # type: ignore[attr-defined] + return real_predict(db, deal, est, **kw) # type: ignore[arg-type] + + monkeypatch.setattr(est_mod, "_lookup_house_facts", _fake_lookup) + monkeypatch.setattr(est_mod, "_fetch_analogs", _fake_fetch_analogs) + monkeypatch.setattr(est_mod, "_resolve_target_city", lambda _addr: None) + monkeypatch.setattr(est_mod, "_fetch_dkp_corridor", lambda *a, **kw: None) + monkeypatch.setattr(est_mod, "_fetch_anchor_comps", lambda *a, **kw: ([], None)) + monkeypatch.setattr(est_mod, "_fetch_house_imv_anchor", lambda *a, **kw: None) + monkeypatch.setattr(est_mod, "_price_from_inputs", _fake_price_from_inputs) + monkeypatch.setattr(bt, "_predict_full_spine", _tracking_predict) + + metrics = bt.run_backtest_full( + None, # type: ignore[arg-type] + sample=2, + since="2025-06-01", + resolve_house_id=resolve_house_id, + ) + return seen, lookups, metrics + + +def test_full_backtest_takes_house_type_from_houses_like_prod( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=True) + + # Пустой тип сделки заменён типом дома — именно он уходит в каждый отбор аналогов. + assert seen[1] == {"panel"} + # Свой тип сделки главнее справочника, как форма главнее houses на проде. + assert seen[2] == {"brick"} + assert lookups == [{"target_house_id": 77, "lat": 56.8, "lon": 60.6}] + assert metrics["house_id_resolution"]["house_type_from_houses"] == 1 # type: ignore[index] + + +def test_full_backtest_without_flag_keeps_house_type_untouched( + monkeypatch: pytest.MonkeyPatch, +) -> None: + seen, lookups, metrics = _run_full_with_house_facts(monkeypatch, resolve_house_id=False) + + assert seen[1] == {None} + assert lookups == [] + assert "house_id_resolution" not in metrics