"""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 gzip import json import tempfile from pathlib import Path from typing import Any import pytest 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_replay_is_arg_insensitive_order_based() -> None: # Order-based (FIFO) replay returns the recorded ratio REGARDLESS of the arg # value the spine actually computes — so a recorded arg that can never equal # the live median (999_999.0) still replays cleanly. This is the cross-platform # robustness contract: a Linux-captured fixture must replay off-Linux even when # libm last-ulp jitter shifts the computed median_ppm2 by an ulp. fixture = _build_fixture() fixture["deals"][0]["ratio_calls"] = [[999_999.0, [0.95, "per_rooms_all"]]] metrics = bt.replay_fixture(fixture) # Replay succeeded (no KeyError) and still priced every deal. assert metrics["expected_sold"]["overall"]["n"] == len(fixture["deals"]) # The recorded ratio (0.95) was applied to deal 1 (median 100k → expected_sold # ppm² 95k), so its signed error vs SOLD 100k is -5% — proving the recorded # RETURN drove the result, not the (mismatched) arg. econom = metrics["expected_sold"]["per_segment"]["эконом"] assert econom["n"] == 1 assert econom["median_bias_pct"] == pytest.approx(-5.0) def test_replay_exhaustion_raises_runtime_error() -> None: # If a callable's recorded list is SHORTER than the spine's call count (here: # the spine calls ratio_resolver once but nothing is recorded), the exhaustion # guard must raise a clear RuntimeError — control flow diverged from capture, # NOT a silent wrong answer. fixture = _build_fixture() fixture["deals"][0]["ratio_calls"] = [] # spine will still request the ratio with pytest.raises(RuntimeError) as exc: bt.replay_fixture(fixture) assert "ratio_resolver" in str(exc.value) assert "diverged from capture" in str(exc.value) def test_load_fixture_plain_and_gzip_roundtrip() -> None: # load_fixture is gzip-transparent: a .gz path is decompressed, a plain .json # path is read directly. Both must yield a replay-able fixture dict. fixture = _build_fixture() payload = json.dumps(fixture, ensure_ascii=False) with tempfile.TemporaryDirectory() as d: plain = Path(d) / "fx.json" plain.write_text(payload, encoding="utf-8") gz = Path(d) / "fx.json.gz" with gzip.open(gz, "wt", encoding="utf-8") as fh: fh.write(payload) loaded_plain = bt.load_fixture(str(plain)) loaded_gz = bt.load_fixture(str(gz)) assert loaded_plain == loaded_gz == fixture # Both load paths produce identical replay metrics. m_plain = bt.replay_fixture(loaded_plain) m_gz = bt.replay_fixture(loaded_gz) assert json.dumps(m_plain, sort_keys=True) == json.dumps(m_gz, sort_keys=True)