Merge pull request 'Бэктест оценщика берёт тип дома из справочника домов, как боевая оценка — штраф за тип дома впервые участвует в замере' (#3571) from fix/backtest-house-type into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled

This commit is contained in:
bot-backend 2026-09-17 11:22:33 +00:00
commit a6a1b7cdfc
2 changed files with 136 additions and 4 deletions

View file

@ -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:

View file

@ -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