All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m25s
Deploy Trade-In / deploy (push) Successful in 49s
Deploy Trade-In / build-backend (push) Successful in 53s
285 lines
11 KiB
Python
285 lines
11 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 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"]]],
|
||
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(monkeypatch: pytest.MonkeyPatch) -> 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.
|
||
# #2002: this asserts the recorded ratio drives the result (bias -5%). Hold the
|
||
# orthogonal hedonic correction OFF so expected_sold stays exactly headline × ratio.
|
||
from app.core.config import settings
|
||
|
||
monkeypatch.setattr(settings, "estimate_hedonic_correction_enabled", False)
|
||
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)
|