gendesign/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py
lekss361 50684ce87e
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
fix(tradein/estimator): remove dead tier-aware-ratio path — truncation artifact + footgun (#2002)
2026-06-27 17:34:54 +00:00

285 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)