gendesign/tradein-mvp/backend/tests/test_backtest_fixture_roundtrip.py
bot-backend ac99886201
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/estimator): remove dead tier-aware-ratio path — truncation artifact + footgun (#2002)
The tier_aware_ratio_enabled path (#928) binned SOLD deals by sold-ppm² against
ASKING-derived percentile bounds, then divided within-tier medians. That is a
ratio-of-truncated-medians ARTIFACT: a Monte-Carlo with a CONSTANT true
sold/asking=0.84 reproduced the prod tier values (0.94/0.99/0.96) and the skewed
deal split (57/27/15%) exactly — the "premium sells closer to asking" gradient is
100% spurious. Flipping the flag ON made prod WORSE (overall MAPE 14.6→17.2,
эконом bias +5.8→+18.1). It shipped dark (default False) but is a latent footgun.

A valid within-price-tier sold/asking is uncomputable from asking-less ДКП deals;
the per-rooms blend + the shipped hedonic (year+area) are the correct conditioning.
So the dead path is removed entirely, not just disabled.

- estimator._get_asking_sold_ratio: drop the tier branch (bounds read, t33/t66,
  asking_to_sold_ratios_tiered read, empirical-Bayes shrink) + the _legacy/tier
  cache-key split; keep only the legacy per-rooms → global -1 lookup. Cache key
  collapses to rooms bucket. anchor_ppm2 param retained for call-site compat (now
  unused).
- config: remove tier_aware_ratio_enabled + tier_ratio_shrink_k settings.
- tasks/asking_to_sold_ratio: drop the tiered refresh (DELETE/re-derive bounds +
  tier rows, tiered counters); the daily task no longer touches the dead tier
  tables. Legacy asking_to_sold_ratios DELETE+re-derive intact.
- tests: delete the tier-ratio unit test file (legacy path covered by
  test_estimator_expected_sold.py Layer 1); fix the daily-refresh fake-db
  (8→3 execute calls, 3 counter keys); drop the two segment-guard tests that
  asserted the removed tiered SQL; clean a dead per_rooms_tier basis in fixtures.

Tier tables asking_to_sold_ratios_tiered / asking_to_sold_tier_bounds + migration
098 are left in place (inert once nothing reads/writes them); a DROP migration is
a separate, optional follow-up.

Regression gate stays byte-identical (flag was False in prod → active path
unchanged). Refs #2002
2026-06-27 20:31:49 +03: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)