feat(tradein/backtest): hermetic estimator regression gate — frozen fixture + baseline (#1966 PR 3/3)
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

This commit is contained in:
lekss361 2026-06-27 12:55:35 +00:00
parent 16f375782b
commit ec9ed56fad
7 changed files with 914 additions and 23 deletions

View file

@ -17,6 +17,11 @@ repos:
- id: check-toml
- id: check-added-large-files
args: ["--maxkb=512"]
# #1966: the frozen backtest regression-gate fixture is gzipped prod
# inputs (~3 MB). It is re-extracted rarely (only when ground-truth
# refreshes), so it does not bloat history per estimator change — the
# per-change artifact is the 3 KB backtest_baseline.json.
exclude: ^tradein-mvp/backend/tests/fixtures/backtest_full_fixture\.json\.gz$
- id: check-merge-conflict
- id: detect-private-key

View file

@ -30,7 +30,8 @@ For a held-out sample of ДКП deals we, per deal:
``_fetch_house_imv_anchor``) + inject the DB callables
(``_get_asking_sold_ratio``, ``_lookup_quarter_index(es)``).
2. Call ``_price_from_inputs`` for a byte-identical headline + expected_sold.
Deals the spine cannot price (median<=0 / <3 analogs) are skipped.
Deals the spine cannot price (median<=0) are skipped prod parity (#1966):
there is NO analog-count floor, low-analog deals surface at low confidence.
3. Score ``expected_sold_per_m2`` vs the realised SOLD ppm².
METRICS (full spine)
@ -87,10 +88,16 @@ USAGE
from __future__ import annotations
import argparse
import dataclasses
import gzip
import json
import logging
import math
import statistics
from collections.abc import Callable
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from pathlib import Path
from types import SimpleNamespace
from typing import Any
@ -1146,7 +1153,199 @@ def _select_analogs_full(
return listings, analog_tier, fallback_used, area_widened
def _predict_full_spine(db: Session, deal: DealSample, est: SimpleNamespace) -> Prediction | None:
# --------------------------------------------------------------------------- #
# Fixture capture + hermetic replay (#1966 PR 3/3)
#
# ``--dump-fixture`` freezes per-deal RESOLVED inputs to ``_price_from_inputs``
# into a committed JSON file; ``replay_fixture`` replays the spine offline (ZERO
# DB) so a CI test can assert metrics match a frozen baseline. The 3 DB-backed
# callables (asking→sold ratio + quarter-index lookups) hide all I/O, so we
# capture each as an ordered ``[arg -> return]`` call-list and replay it via an
# exact-match stub. Everything else fed to ``_price_from_inputs`` is pure data.
# --------------------------------------------------------------------------- #
FIXTURE_SCHEMA_VERSION = 1
def _sanitize_json(obj: Any) -> Any:
"""Recursively coerce a captured value into a finite, JSON-plain structure.
- non-finite float (inf / nan) None (``json.dump(allow_nan=False)`` would
otherwise raise);
- Decimal float; date / datetime ISO str; tuple list; dict / list
recursed; bool / int / str / None pass through; any other type ``str()``.
Idempotent on already-plain data, so applying it both at capture time (to the
recorded call args) and at replay time (to the live args before stub lookup)
keeps the lookup keys byte-stable across capture and replay.
"""
if obj is None or isinstance(obj, bool | int | str):
return obj
if isinstance(obj, float):
return obj if math.isfinite(obj) else None
if isinstance(obj, Decimal):
f = float(obj)
return f if math.isfinite(f) else None
if isinstance(obj, dict):
return {str(k): _sanitize_json(v) for k, v in obj.items()}
if isinstance(obj, list | tuple):
return [_sanitize_json(x) for x in obj]
if isinstance(obj, date): # date | datetime (datetime is a date subclass)
return obj.isoformat()
return str(obj)
def _coerce_ratio_return(ret: Any) -> tuple[Any, Any]:
"""Recorded ``[ratio, basis]`` → ``(ratio, basis)`` tuple (unpacked at the call site)."""
return (ret[0], ret[1])
def _coerce_qi_return(ret: Any) -> tuple[Any, Any] | None:
"""Recorded ``[qi, n]`` or ``null`` → ``(qi, n)`` tuple or None."""
return None if ret is None else (ret[0], ret[1])
def _coerce_qis_return(ret: Any) -> dict[str, float]:
"""Recorded ``{quarter: qi}`` dict → ``dict[str, float]`` (keys forced to str)."""
return {str(k): v for k, v in dict(ret).items()}
def load_fixture(path: str) -> dict[str, Any]:
"""Load a backtest fixture JSON — transparently handles gzip (``.gz``) files.
The committed fixture is gzipped (~3 MB raw); a plain ``.json`` path is also
accepted for ad-hoc runs. Exported for the CI regression-gate test.
"""
if str(path).endswith(".gz"):
with gzip.open(path, "rt", encoding="utf-8") as fh:
return json.loads(fh.read())
return json.loads(Path(path).read_text(encoding="utf-8"))
def _make_call_stub(
calls: list[Any], *, label: str, coerce: Callable[[Any], Any]
) -> Callable[[Any], Any]:
"""Build an ORDER-based (FIFO) replay stub from recorded ``[arg, return]`` pairs.
The stub returns the recorded RETURNS in invocation order and IGNORES the arg
value. Rationale: the DB-derived ratio / quarter-index is a FROZEN input when
estimator logic later shifts e.g. ``median_ppm2``, the regression gate must
surface that as a clean metric diff vs the baseline, NOT mask it as a lookup
miss. Matching on the exact computed float arg is also not bit-stable across
platforms (libm last-ulp jitter), so a Linux-captured fixture would crash when
replayed off-Linux. The recorded arg is kept (for debuggability) but never
matched. Each callable fires 1×/deal today (so index 0 in practice); the FIFO
stays correct if a call site ever loops. Calling the stub MORE times than
recorded raises RuntimeError control flow diverged from capture.
``coerce`` maps each JSON-plain recorded return back to the live callable's
return type (tuple / dict) so unpacking at the call site behaves identically.
"""
returns = [coerce(ret) for _arg, ret in calls]
idx = 0
def _stub(_arg: Any) -> Any:
nonlocal idx
if idx >= len(returns):
raise RuntimeError(
f"{label}: replay made call #{idx + 1} but fixture recorded only "
f"{len(returns)} — control flow diverged from capture"
)
ret = returns[idx]
idx += 1
return ret
return _stub
def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]:
"""Replay a frozen backtest fixture through the full spine — hermetic, ZERO DB.
For every captured deal record: rebuild the ``GeocodeResult``, build order-based
(FIFO) stubs for the 3 DB callables from the recorded call-lists, call
``_price_from_inputs`` with the frozen kwargs, and rebuild the ``Prediction``
EXACTLY as ``_predict_full_spine`` does. Runs the harness's own
``_compute_full_metrics`` over the replayed predictions plus a city-wide
headline computed from the stored (priced) deals.
The returned dict keeps ``expected_sold`` / ``range_coverage`` / ``calibration``
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
``params`` block. Touches NO DB / network and does NOT consult
``settings_at_capture`` it prices against the live committed
``estimator.settings`` defaults (so a settings change is caught as a metric
drift, not silently honoured). Deterministic: same fixture identical dict.
"""
est = _import_estimator_full()
m = est.m
deals = fixture.get("deals") or []
predictions: list[Prediction] = []
sold_ppm2_all: list[float] = []
pred_ppm2_all: list[float] = []
for rec in deals:
kw = dict(rec["kwargs"])
sold_ppm2_all.append(float(rec["sold_ppm2"]))
kw["geo"] = est.GeocodeResult(**kw["geo"])
kw["ratio_resolver"] = _make_call_stub(
rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return
)
kw["quarter_index_lookup"] = _make_call_stub(
rec.get("qi_calls") or [], label="quarter_index_lookup", coerce=_coerce_qi_return
)
kw["quarter_indexes_lookup"] = _make_call_stub(
rec.get("qis_calls") or [], label="quarter_indexes_lookup", coerce=_coerce_qis_return
)
pr = m._price_from_inputs(**kw)
es_ppm2 = float(pr.expected_sold_per_m2) if pr.expected_sold_per_m2 is not None else None
es_price = float(pr.expected_sold_price) if pr.expected_sold_price is not None else None
r_low = (
float(pr.expected_sold_range_low) if pr.expected_sold_range_low is not None else None
)
r_high = (
float(pr.expected_sold_range_high) if pr.expected_sold_range_high is not None else None
)
prediction = Prediction(
deal_id=int(rec["deal_id"]),
rooms=rec["rooms"],
area_m2=float(rec["area_m2"]),
sold_ppm2=float(rec["sold_ppm2"]),
median_ppm2=float(pr.median_ppm2),
confidence=pr.confidence,
anchor_tier=pr.anchor_tier,
expected_sold_ppm2=es_ppm2,
expected_sold_price=es_price,
range_low=r_low,
range_high=r_high,
)
predictions.append(prediction)
pred_ppm2_all.append(prediction.median_ppm2)
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
metrics = _compute_full_metrics(predictions, n_no_prediction=0)
deal_median = statistics.median(sold_ppm2_all) if sold_ppm2_all else None
ask_median = statistics.median(pred_ppm2_all) if pred_ppm2_all else None
spread_pct: float | None = None
if deal_median and ask_median and deal_median > 0:
spread_pct = round(100.0 * (ask_median - deal_median) / deal_median, 2)
metrics["headline"] = {
"deal_median_ppm2": deal_median,
"ask_median_ppm2": ask_median,
"spread_pct": spread_pct,
}
return metrics
def _predict_full_spine(
db: Session,
deal: DealSample,
est: SimpleNamespace,
*,
capture: list[dict[str, Any]] | None = None,
) -> Prediction | None:
"""Predict one deal through the FULL deterministic spine (#1966).
Selects analogs via the replicated tier ladder, pre-fetches the spine inputs
@ -1156,7 +1355,13 @@ def _predict_full_spine(db: Session, deal: DealSample, est: SimpleNamespace) ->
the network valuation layers excluded (imv_eval=None, yandex/cian absent).
Returns a Prediction, or None when the spine cannot price the deal
(median<=0 or <MIN_CANDIDATES analogs) mirroring the asking-core skip.
(median<=0). Prod parity (#1966): there is NO analog-count skip — a positive
median surfaces an estimate (at low confidence for thin samples), matching
estimate_quality which never hard-drops a priced result.
When ``capture`` is a list, every deal that yields a prediction also appends a
fully JSON-plain replay record (frozen kwargs + the 3 callables' recorded
``[arg -> return]`` call-lists) see ``--dump-fixture`` / ``replay_fixture``.
"""
m = est.m
settings = est.settings
@ -1165,6 +1370,10 @@ def _predict_full_spine(db: Session, deal: DealSample, est: SimpleNamespace) ->
# ── Pre-fetch the spine inputs (same calls estimate_quality hoists) ───────
dkp_raw = m._fetch_dkp_corridor(db, address=deal.address, rooms=deal.rooms, area=deal.area_m2)
# #1966 prod parity: same-building anchor pre-fetch is GATED exactly like
# estimate_quality (estimator.py L2862-2881) — disabled / no-area / no-address
# → ([], None) instead of an unconditional fetch.
if settings.estimate_same_building_anchor_enabled and deal.area_m2 and deal.address:
anchor_comps, anchor_tier = m._fetch_anchor_comps(
db,
address=deal.address,
@ -1174,6 +1383,8 @@ def _predict_full_spine(db: Session, deal: DealSample, est: SimpleNamespace) ->
rooms=deal.rooms,
area=deal.area_m2,
)
else:
anchor_comps, anchor_tier = [], None
imv_anchor = m._fetch_house_imv_anchor(
db, target_house_id=None, rooms=deal.rooms, area=deal.area_m2
)
@ -1191,22 +1402,38 @@ def _predict_full_spine(db: Session, deal: DealSample, est: SimpleNamespace) ->
confidence="exact",
)
# In capture mode each closure RECORDS its (single-arg -> return) call so the
# offline replay can rebuild a DB-free stub. The real value is still returned.
ratio_calls: list[Any] = []
qi_calls: list[Any] = []
qis_calls: list[Any] = []
def _ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
return m._get_asking_sold_ratio(db, deal.rooms, anchor_ppm2=appm2)
res = m._get_asking_sold_ratio(db, deal.rooms, anchor_ppm2=appm2)
if capture is not None:
ratio_calls.append([_sanitize_json(appm2), _sanitize_json(list(res))])
return res
def _qi_lookup(q: str) -> tuple[float, int] | None:
return m._lookup_quarter_index(
res = m._lookup_quarter_index(
db,
quarter_cad_number=q,
min_n_deals=settings.estimate_quarter_index_min_n_deals,
)
if capture is not None:
ret = _sanitize_json(list(res) if res is not None else None)
qi_calls.append([_sanitize_json(q), ret])
return res
def _qis_lookup(qs: list[str]) -> dict[str, float]:
return m._lookup_quarter_indexes(
res = m._lookup_quarter_indexes(
db,
quarter_cad_numbers=qs,
min_n_deals=settings.estimate_quarter_index_min_n_deals,
)
if capture is not None:
qis_calls.append([_sanitize_json(list(qs)), _sanitize_json(dict(res))])
return res
pr = m._price_from_inputs(
listings=listings,
@ -1235,14 +1462,54 @@ def _predict_full_spine(db: Session, deal: DealSample, est: SimpleNamespace) ->
dadata_qc_geo=None,
)
# Skip when the spine couldn't price it — mirror the asking-core skip.
if pr.median_price <= 0 or pr.n_analogs < MIN_CANDIDATES:
# #1966 prod parity: skip ONLY when the spine could not price the deal
# (median<=0). NO analog-count floor — estimate_quality surfaces any positive
# estimate at low confidence. MIN_CANDIDATES still gates the asking-core path.
if pr.median_price <= 0:
return None
es_ppm2 = float(pr.expected_sold_per_m2) if pr.expected_sold_per_m2 is not None else None
es_price = float(pr.expected_sold_price) if pr.expected_sold_price is not None else None
r_low = float(pr.expected_sold_range_low) if pr.expected_sold_range_low is not None else None
r_high = float(pr.expected_sold_range_high) if pr.expected_sold_range_high is not None else None
if capture is not None:
capture.append(
{
"deal_id": deal.id,
"sold_ppm2": float(deal.sold_ppm2),
"area_m2": float(deal.area_m2),
"rooms": deal.rooms,
"deal_date": str(deal.deal_date),
"kwargs": {
"listings": _sanitize_json(listings),
"area_m2": float(deal.area_m2),
"rooms": deal.rooms,
"repair_state": None,
"floor": deal.floor,
"total_floors": deal.total_floors,
"target_year": deal.year_built,
"analog_tier": analog_tier,
"fallback_used": fallback_used,
"area_widened": area_widened,
"anchor_comps": _sanitize_json(anchor_comps),
"anchor_tier_fetched": anchor_tier,
"dkp_raw": _sanitize_json(dkp_raw),
"imv_anchor": _sanitize_json(imv_anchor),
"imv_eval": None,
"yandex_val_present": False,
"cian_val_present": False,
"target_house_cadnum": None,
"dadata_coarse": False,
"geo": _sanitize_json(dataclasses.asdict(geo)),
"dadata_qc_geo": None,
},
"ratio_calls": ratio_calls,
"qi_calls": qi_calls,
"qis_calls": qis_calls,
}
)
return Prediction(
deal_id=deal.id,
rooms=deal.rooms,
@ -1390,19 +1657,26 @@ def run_backtest(
return metrics
def run_backtest_full(db: Session, *, sample: int, since: str) -> dict[str, Any]:
def run_backtest_full(
db: Session, *, sample: int, since: str, dump_fixture: str | None = None
) -> dict[str, Any]:
"""Drive the FULL-spine read-only backtest and return a metrics dict (#1966).
Per deal: load sample ``_predict_full_spine`` (replicate the analog tier
ladder + pre-fetch spine inputs ``_price_from_inputs``) collect Prediction
records + no-prediction counts ``_compute_full_metrics`` (expected_sold
error overall/per-rooms/per-segment + range-coverage + calibration +
sharpness) + a city-wide asking-vs-deal headline spread. No writes.
sharpness) + a city-wide asking-vs-deal headline spread. No writes to the DB.
``--radius`` / ``--rooms-tolerance`` / ``--holdout-split`` do NOT apply here
the tier ladder uses the estimator's OWN constants (DEFAULT_RADIUS_M /
FALLBACK_RADIUS_M) and there is no per-rooms correction block (the spine
already emits expected_sold via the askingsold ratio).
When ``dump_fixture`` is a path, every priced deal's RESOLVED spine inputs +
the 3 DB callables' recorded call-lists are frozen into a committed JSON file
(schema_version=1) so ``replay_fixture`` can re-score offline against a frozen
baseline (the CI regression gate). This is the only path that writes a file.
"""
est = _import_estimator_full()
deals = _load_sample(db, sample=sample, since=since)
@ -1414,10 +1688,11 @@ def run_backtest_full(db: Session, *, sample: int, since: str) -> dict[str, Any]
sold_ppm2_all: list[float] = [d.sold_ppm2 for d in deals]
pred_ppm2_all: list[float] = [] # asking headline median_ppm2 (priced deals)
capture: list[dict[str, Any]] | None = [] if dump_fixture else None
for i, deal in enumerate(deals, start=1):
try:
pr = _predict_full_spine(db, deal, est)
pr = _predict_full_spine(db, deal, est, capture=capture)
except Exception as exc:
# Read-only: a failed SELECT can poison the tx → rollback so the next
# deal's queries run clean. Skip this deal (counts as no-prediction).
@ -1467,9 +1742,47 @@ def run_backtest_full(db: Session, *, sample: int, since: str) -> dict[str, Any]
"n_no_prediction": n_no_prediction,
"price_segments_ppm2": [list(seg) for seg in PRICE_SEGMENTS_PPM2],
}
if dump_fixture is not None and capture is not None:
_write_fixture(dump_fixture, capture=capture, since=since, est=est)
return metrics
def _write_fixture(
path: str, *, capture: list[dict[str, Any]], since: str, est: SimpleNamespace
) -> None:
"""Freeze captured per-deal replay records into a committed JSON fixture.
``settings_at_capture`` records every ``estimate_*`` Settings field as an
informational snapshot (NOT consulted by ``replay_fixture``). A final
recursive ``_sanitize_json`` pass guarantees the whole document is finite +
JSON-plain before ``json.dump(allow_nan=False)``.
"""
settings_at_capture = {
name: _sanitize_json(getattr(est.settings, name))
for name in sorted(type(est.settings).model_fields)
if name.startswith("estimate_")
}
fixture = {
"schema_version": FIXTURE_SCHEMA_VERSION,
"engine": "full",
"since": since,
"n_deals": len(capture),
"settings_at_capture": settings_at_capture,
"deals": capture,
}
fixture = _sanitize_json(fixture)
out_path = Path(path)
if out_path.parent != Path():
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", encoding="utf-8") as fh:
json.dump(fixture, fh, ensure_ascii=False, indent=2, allow_nan=False)
fh.write("\n")
logger.info("dumped fixture: %s (%d deals)", out_path, len(capture))
print(f"dumped fixture: {out_path} ({len(capture)} deals)")
# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #
@ -1531,27 +1844,76 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
action="store_true",
help="Emit machine-readable JSON instead of the text table.",
)
# #1966 PR 3/3 — fixture capture + hermetic replay. --dump-fixture (DB run,
# full engine) and --from-fixture (NO DB) are mutually exclusive modes.
fixture_mode = p.add_mutually_exclusive_group()
fixture_mode.add_argument(
"--dump-fixture",
metavar="PATH",
default=None,
help="FULL engine only: freeze each priced deal's resolved _price_from_"
"inputs inputs + the 3 DB callables' recorded calls into a committed JSON "
"fixture at PATH, so replay_fixture can re-score offline (CI gate).",
)
fixture_mode.add_argument(
"--from-fixture",
metavar="PATH",
default=None,
help="Hermetic replay: load the frozen fixture at PATH (gzip-transparent — "
"a .gz path is decompressed on the fly), re-run the spine offline (ZERO DB) "
"via replay_fixture, and print the metrics JSON. No DB connection is opened.",
)
p.add_argument(
"--update-baseline",
metavar="OUT",
default=None,
help="With --from-fixture: also write the replayed metrics to OUT "
"(sorted-keys JSON + trailing newline) — regenerates the frozen baseline.",
)
return p.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns the count of matched (predicted) deals."""
"""CLI entry point. Returns the count of matched (predicted) / replayed deals."""
args = _parse_args(argv)
# ── Hermetic replay path (#1966 PR 3/3) — ZERO DB, no SessionLocal opened. ──
if args.from_fixture:
fixture = load_fixture(args.from_fixture)
metrics = replay_fixture(fixture)
print(json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True))
if args.update_baseline:
out = Path(args.update_baseline)
out.write_text(
json.dumps(metrics, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
logger.info("wrote baseline: %s", out)
return len(fixture.get("deals") or [])
if args.update_baseline:
raise SystemExit("--update-baseline requires --from-fixture")
if args.dump_fixture and args.engine != "full":
raise SystemExit("--dump-fixture is only supported with --engine full")
logger.info(
"backtest start: engine=%s sample=%d since=%s radius=%dm "
"rooms_tolerance=%d holdout_split=%s",
"rooms_tolerance=%d holdout_split=%s dump_fixture=%s",
args.engine,
args.sample,
args.since,
args.radius,
args.rooms_tolerance,
args.holdout_split,
args.dump_fixture,
)
db = _session()
try:
if args.engine == "full":
metrics = run_backtest_full(db, sample=args.sample, since=args.since)
metrics = run_backtest_full(
db, sample=args.sample, since=args.since, dump_fixture=args.dump_fixture
)
else:
metrics = run_backtest(
db,

View file

@ -0,0 +1,6 @@
# #1966 PR 3/3 — frozen backtest regression-gate artifacts.
# The fixture is gzipped binary: never apply CRLF/text filters (would corrupt it).
backtest_full_fixture.json.gz binary
# The baseline is the diff-visible artifact; keep it LF so `--update-baseline`
# regen (which writes LF) never shows spurious line-ending churn.
backtest_baseline.json text eol=lf

View file

@ -0,0 +1,154 @@
{
"calibration": {
"high": {
"coverage_pct": null,
"mape_pct": null,
"n": 0,
"n_covered": 0
},
"low": {
"coverage_pct": 55.27,
"mape_pct": 18.63,
"n": 275,
"n_covered": 152
},
"medium": {
"coverage_pct": 50.0,
"mape_pct": 19.18,
"n": 2,
"n_covered": 1
}
},
"confidence_order": [
"high",
"medium",
"low"
],
"expected_sold": {
"overall": {
"mape_pct": 18.63,
"median_bias_pct": -1.2,
"n": 277,
"n_no_analogs": 0,
"p25_pct": -16.25,
"p75_pct": 20.26
},
"per_rooms": {
"0": {
"label": "студия",
"mape_pct": 27.97,
"median_bias_pct": 27.56,
"n": 37,
"n_no_analogs": 0,
"p25_pct": -8.97,
"p75_pct": 38.45
},
"1": {
"label": "1к",
"mape_pct": 19.55,
"median_bias_pct": -8.08,
"n": 93,
"n_no_analogs": 0,
"p25_pct": -21.27,
"p75_pct": 13.3
},
"2": {
"label": "2к",
"mape_pct": 16.22,
"median_bias_pct": -8.82,
"n": 74,
"n_no_analogs": 0,
"p25_pct": -21.01,
"p75_pct": 6.13
},
"3": {
"label": "3к",
"mape_pct": 10.52,
"median_bias_pct": 4.08,
"n": 43,
"n_no_analogs": 0,
"p25_pct": -6.54,
"p75_pct": 11.96
},
"4": {
"label": "4+",
"mape_pct": 23.53,
"median_bias_pct": 14.35,
"n": 30,
"n_no_analogs": 0,
"p25_pct": -0.72,
"p75_pct": 35.27
}
},
"per_segment": {
"бизнес": {
"mape_pct": 22.14,
"median_bias_pct": -21.49,
"n": 46,
"p25_pct": -28.22,
"p75_pct": -9.82
},
"комфорт": {
"mape_pct": 16.74,
"median_bias_pct": -8.05,
"n": 104,
"p25_pct": -20.63,
"p75_pct": 7.55
},
"премиум": {
"mape_pct": 59.37,
"median_bias_pct": -59.37,
"n": 1,
"p25_pct": -59.37,
"p75_pct": -59.37
},
"эконом": {
"mape_pct": 18.01,
"median_bias_pct": 17.17,
"n": 120,
"p25_pct": 1.73,
"p75_pct": 46.96
},
"элит": {
"mape_pct": 38.62,
"median_bias_pct": -38.62,
"n": 6,
"p25_pct": -47.98,
"p75_pct": -33.18
}
}
},
"headline": {
"ask_median_ppm2": 147545.8502510892,
"deal_median_ppm2": 125063.0,
"spread_pct": 17.98
},
"range_coverage": {
"overall": {
"coverage_pct": 55.23,
"n": 277,
"n_covered": 153
},
"per_confidence": {
"high": {
"coverage_pct": null,
"n": 0,
"n_covered": 0
},
"low": {
"coverage_pct": 55.27,
"n": 275,
"n_covered": 152
},
"medium": {
"coverage_pct": 50.0,
"n": 2,
"n_covered": 1
}
}
},
"sharpness": {
"median_rel_width": 0.4638,
"n": 277
}
}

Binary file not shown.

View file

@ -0,0 +1,280 @@
"""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 /; 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)

View file

@ -0,0 +1,84 @@
"""Hermetic estimator regression gate (#1966 PR 3/3).
Replays the committed frozen backtest fixture through the full pricing spine
(``app.services.estimator._price_from_inputs``) with ZERO DB / network, recomputes
the backtest metrics, and asserts they match the committed baseline. Any change to
the spine, the metric code, or a config default that moves a metric beyond float
jitter fails this test regenerate the baseline deliberately:
cd tradein-mvp/backend
uv run python -m scripts.backtest_estimator \
--from-fixture tests/fixtures/backtest_full_fixture.json.gz \
--update-baseline tests/fixtures/backtest_baseline.json
and justify the per-segment MAPE / coverage deltas in the PR. This is a RELATIVE
regression gate, not an absolute SLA (live coverage ~55% is data-blocked, see #1966).
The fixture is gzipped frozen prod inputs (opaque, rarely changes); the baseline is
the small diff-visible artifact that surfaces accuracy movement right in the PR diff.
"""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scripts.backtest_estimator import load_fixture, replay_fixture
_FIXTURES = Path(__file__).parent / "fixtures"
_FIXTURE_PATH = _FIXTURES / "backtest_full_fixture.json.gz"
_BASELINE_PATH = _FIXTURES / "backtest_baseline.json"
# Floats: a small relative+absolute tolerance absorbs cross-platform / Python
# libm last-ulp jitter (the replay is otherwise deterministic). A real regression
# moves a metric by orders of magnitude more than this, so it is still caught.
_REL_TOL = 1e-6
_ABS_TOL = 1e-6
def _assert_match(path: str, expected: object, actual: object) -> None:
if isinstance(expected, dict):
assert isinstance(actual, dict), f"{path}: expected dict, got {type(actual).__name__}"
assert (
expected.keys() == actual.keys()
), f"{path}: key set differs\n expected={sorted(expected)}\n actual= {sorted(actual)}"
for k in expected:
_assert_match(f"{path}.{k}", expected[k], actual[k])
elif isinstance(expected, list):
assert isinstance(actual, list), f"{path}: expected list, got {type(actual).__name__}"
assert len(expected) == len(actual), f"{path}: list length {len(actual)} != {len(expected)}"
for i, (e, a) in enumerate(zip(expected, actual, strict=True)):
_assert_match(f"{path}[{i}]", e, a)
elif expected is None or isinstance(expected, bool):
assert actual is expected or actual == expected, f"{path}: {actual!r} != {expected!r}"
elif isinstance(expected, int): # bool already handled above
assert actual == expected, f"{path}: int {actual!r} != {expected!r}"
elif isinstance(expected, float):
assert isinstance(actual, int | float), f"{path}: {type(actual).__name__} not numeric"
assert math.isclose(actual, expected, rel_tol=_REL_TOL, abs_tol=_ABS_TOL), (
f"{path}: {actual!r} != baseline {expected!r} (Δ={actual - expected:.3e}). "
f"The estimator/metrics changed — if intentional, regenerate the baseline "
f"(--from-fixture --update-baseline) and justify the deltas in the PR."
)
else:
assert actual == expected, f"{path}: {actual!r} != {expected!r}"
def test_fixture_and_baseline_committed() -> None:
assert _FIXTURE_PATH.exists(), f"frozen fixture missing: {_FIXTURE_PATH}"
assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}"
def test_backtest_regression_gate() -> None:
fixture = load_fixture(_FIXTURE_PATH)
baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8"))
# Round-trip the replay output through JSON before comparing: the committed
# baseline is JSON (string object keys), while replay_fixture returns native
# dicts whose per_rooms buckets are int keys (0..4). Round-tripping normalises
# key types to match — the same transform `--update-baseline` applies on write.
metrics = json.loads(json.dumps(replay_fixture(fixture), ensure_ascii=False))
_assert_match("metrics", baseline, metrics)