All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
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 / build-backend (push) Successful in 55s
Deploy Trade-In / deploy (push) Successful in 54s
280 lines
11 KiB
Python
280 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_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)
|