gendesign/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py

235 lines
8.4 KiB
Python

"""Synthetic round-trip test for the hermetic fixture replay (#1966 PR 3/3).
Proves the ``--dump-fixture`` / ``replay_fixture`` machinery end-to-end WITHOUT a
DB: a hand-crafted in-memory fixture (the exact JSON shape ``--dump-fixture``
writes) is replayed through the full pricing spine via ``bt.replay_fixture`` and
the resulting metrics dict is asserted for structure + determinism.
The deals are crafted so every call ``_price_from_inputs`` makes to the 3 injected
callables is recorded up-front, and so each headline ``median_ppm2`` is an exact,
predictable value (3 listings → the middle ₽/m²; no anchor / quarter-index / ДКП
mutation), which is what the recorded ``ratio_calls`` key must match.
NOTE: importing scripts.backtest_estimator → app.services.estimator →
app.core.config.Settings REQUIRES DATABASE_URL. Set a dummy value BEFORE importing
app modules (same pattern as tests/test_backtest_estimator.py:19-21). The dummy URL
is never connected to — replay_fixture opens NO session.
"""
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import json
from typing import Any
from scripts import backtest_estimator as bt
# --------------------------------------------------------------------------- #
# Fixture builders — hand-crafted, fully predictable per-deal records.
# --------------------------------------------------------------------------- #
def _geo(address: str) -> dict[str, Any]:
"""A synthetic GeocodeResult dict (the asdict shape replay rebuilds)."""
return {
"lat": 56.84,
"lon": 60.60,
"full_address": address,
"provider": "cache",
"confidence": "exact",
}
def _deal_record(
*,
deal_id: int,
sold_ppm2: float,
area_m2: float,
rooms: int,
listings: list[dict[str, Any]],
anchor_comps: list[dict[str, Any]],
anchor_tier_fetched: str | None,
ratio_calls: list[Any],
qi_calls: list[Any],
qis_calls: list[Any],
address: str,
) -> dict[str, Any]:
"""Assemble one fixture deal record in the exact ``--dump-fixture`` schema."""
return {
"deal_id": deal_id,
"sold_ppm2": sold_ppm2,
"area_m2": area_m2,
"rooms": rooms,
"deal_date": "2025-06-15",
"kwargs": {
"listings": listings,
"area_m2": area_m2,
"rooms": rooms,
"repair_state": None,
"floor": 3,
"total_floors": 9,
"target_year": 2010,
"analog_tier": "W",
"fallback_used": False,
"area_widened": False,
"anchor_comps": anchor_comps,
"anchor_tier_fetched": anchor_tier_fetched,
"dkp_raw": None,
"imv_anchor": None,
"imv_eval": None,
"yandex_val_present": False,
"cian_val_present": False,
"target_house_cadnum": None,
"dadata_coarse": False,
"geo": _geo(address),
"dadata_qc_geo": None,
},
"ratio_calls": ratio_calls,
"qi_calls": qi_calls,
"qis_calls": qis_calls,
}
def _build_fixture() -> dict[str, Any]:
"""3 hand-crafted deals spanning эконом / бизнес / элит SOLD segments.
deal 1: plain — no anchor, no quarter index → single ratio call.
deal 2: a listing carries a cadastral number → one recorded quarter-index
call (returns null, leaves the median untouched) + a ratio call.
deal 3: carries non-empty anchor_comps (2 comps < min_comps=4 → anchor never
fires, so the median stays the radius median) + a ratio call.
"""
# ── deal 1 — median of [90k, 100k, 110k] = 100k → ratio_resolver(100000.0). ──
deal1 = _deal_record(
deal_id=1,
sold_ppm2=100_000.0, # SOLD эконом (< 120k)
area_m2=50.0,
rooms=1,
listings=[
{"price_per_m2": 90_000.0, "source": "avito"},
{"price_per_m2": 100_000.0, "source": "avito"},
{"price_per_m2": 110_000.0, "source": "avito"},
],
anchor_comps=[],
anchor_tier_fetched=None,
ratio_calls=[[100_000.0, [0.95, "per_rooms_all"]]],
qi_calls=[],
qis_calls=[],
address="ул. Тестовая, 1",
)
# ── deal 2 — median 150k; first lot's cadnum → quarter "66:41:0204016". ──
deal2 = _deal_record(
deal_id=2,
sold_ppm2=200_000.0, # SOLD бизнес (160k..220k)
area_m2=60.0,
rooms=2,
listings=[
{
"price_per_m2": 140_000.0,
"source": "cian",
"building_cadastral_number": "66:41:0204016:350",
},
{"price_per_m2": 150_000.0, "source": "cian"},
{"price_per_m2": 160_000.0, "source": "cian"},
],
anchor_comps=[],
anchor_tier_fetched=None,
# quarter-index lookup returns null → spine leaves the median untouched.
qi_calls=[["66:41:0204016", None]],
qis_calls=[],
ratio_calls=[[150_000.0, [0.90, "per_rooms_all"]]],
address="ул. Тестовая, 2",
)
# ── deal 3 — median 310k; anchor_comps present but below min_comps → no fire. ──
deal3 = _deal_record(
deal_id=3,
sold_ppm2=290_000.0, # SOLD элит (220k..300k)
area_m2=80.0,
rooms=3,
listings=[
{"price_per_m2": 300_000.0, "source": "yandex"},
{"price_per_m2": 310_000.0, "source": "yandex"},
{"price_per_m2": 320_000.0, "source": "yandex"},
],
anchor_comps=[
{"price_per_m2": 305_000.0, "area_m2": 80.0, "rooms": 3, "floor": 5, "total_floors": 9},
{"price_per_m2": 308_000.0, "area_m2": 82.0, "rooms": 3, "floor": 6, "total_floors": 9},
],
anchor_tier_fetched=None,
ratio_calls=[[310_000.0, [0.92, "per_rooms_tier:high"]]],
qi_calls=[],
qis_calls=[],
address="ул. Тестовая, 3",
)
deals = [deal1, deal2, deal3]
return {
"schema_version": bt.FIXTURE_SCHEMA_VERSION,
"engine": "full",
"since": "2025-06-01",
"n_deals": len(deals),
"settings_at_capture": {},
"deals": deals,
}
# --------------------------------------------------------------------------- #
# Tests
# --------------------------------------------------------------------------- #
def test_replay_fixture_structure_and_keys() -> None:
fixture = _build_fixture()
metrics = bt.replay_fixture(fixture)
# Keeps the non-volatile metric blocks ...
for key in (
"expected_sold",
"range_coverage",
"calibration",
"sharpness",
"confidence_order",
"headline",
):
assert key in metrics, f"missing metric block: {key}"
# ... and DROPS the volatile params block.
assert "params" not in metrics
# Every crafted deal resolves a ratio → an expected_sold row, so overall n
# equals the number of deals in the fixture.
assert metrics["expected_sold"]["overall"]["n"] == len(fixture["deals"])
def test_replay_fixture_is_deterministic() -> None:
fixture = _build_fixture()
first = bt.replay_fixture(fixture)
second = bt.replay_fixture(fixture)
# Byte-identical across two independent replays (no DB, no RNG, no caches).
assert json.dumps(first, ensure_ascii=False, sort_keys=True) == json.dumps(
second, ensure_ascii=False, sort_keys=True
)
def test_replay_fixture_segments_span_multiple_bands() -> None:
# The 3 deals sit in distinct SOLD price-segments (эконом / бизнес / элит),
# so the per-segment expected_sold breakdown must show ≥ 3 non-empty bands.
metrics = bt.replay_fixture(_build_fixture())
per_segment = metrics["expected_sold"]["per_segment"]
non_empty = [label for label, m in per_segment.items() if m["n"] > 0]
assert len(non_empty) >= 3
def test_unseen_callable_arg_raises_keyerror() -> None:
# A fixture whose recorded ratio call does not match the median the spine
# actually computes must surface a clear KeyError (not a silent wrong answer).
fixture = _build_fixture()
fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.9, "per_rooms_all"]]]
try:
bt.replay_fixture(fixture)
except KeyError as exc:
assert "ratio_resolver" in str(exc)
else:
raise AssertionError("expected KeyError for an unrecorded ratio_resolver arg")