"""Backtest harness — measures the estimator's asking-median accuracy vs real ДКП sold prices.

Forgejo issue #648. **STRICTLY READ-ONLY**: this script issues only SELECT
queries against prod. It never INSERTs, UPDATEs, or runs DDL.

WHAT IT MEASURES
----------------
The estimator predicts a квартира's value from the median of *active asking*
prices of nearby analog listings (Tukey 1.5×IQR filter → median ppm²). Ground
truth is `deals` — registered rosreestr ДКП sales (source='rosreestr') that
carry geom + price_per_m2 + rooms. For a held-out sample of ДКП deals we:

  1. Sample deals (id, lon, lat, rooms, sold_ppm2, deal_date).
  2. For each deal, fetch nearby *active* listings of the same rooms within
     `--radius` metres (PostGIS ST_DWithin on geography).
  3. Predict via the estimator's OWN pure functions for fidelity:
     `_filter_outliers` (Tukey IQR) → `_percentile(sorted, 0.5)` (median).
     This mirrors exactly what `estimate_quality` does to its analog pool.
     Deals with <3 surviving candidates are skipped (no prediction).
  4. Per deal: signed_error_pct = 100*(pred - sold)/sold; abs = |signed|.
  5. Aggregate overall + per-rooms: n_matched, n_no_analogs, median_bias_pct
     (systematic over/under), MAPE (median |error|), p25/p75 of signed error.
     Plus a city-wide deal_median_ppm2 vs ask_median_ppm2 headline spread.

CORRECTION BLOCK (issue #648 Stage 1)
-------------------------------------
On top of the raw ASKING metrics the harness now emits a second CORRECTED
block. From the SAME matched sample it derives a per-rooms asking→sold ratio
``ratio[bucket] = median(sold_ppm2) / median(pred_ask_ppm2)`` (global fallback
for thin buckets), then re-scores ``pred_sold = pred_ask * ratio[bucket]``
through the same `_compute_metrics`. This DEMONSTRATES that a per-rooms factor
removes the systematic +29.6% asking→sold bias.

  !! HONESTY — this ratio is IN-SAMPLE by default: it is derived AND evaluated
     on the same deals, so the corrected bias lands near zero BY CONSTRUCTION.
     That proves the MECHANISM, NOT out-of-sample accuracy. Pass
     ``--holdout-split`` to fit on even-id deals and evaluate on the odd-id
     half (deterministic, no RNG) for an honest number. The production ratio
     (Stage 2) is fit over a SEPARATE window and the real A/B is current-vs-
     corrected on held-out data. This script changes NOTHING in prod — it only
     proves the correction is worth building.

CAVEATS (read these before trusting the numbers)
-----------------------------------------------
  (a) TIME MISMATCH — this compares **CURRENT** active listings against
      **PAST** sold deals. It is NOT a point-in-time backtest: a deal closed
      in 2025-06 is being judged against listings active today. As the market
      moves, asking prices drift away from the historical sold price, which
      inflates the apparent bias. A faithful point-in-time backtest needs
      `listing_source_snapshots` (#570) — once that table accumulates enough
      history we can query the asking median *as of each deal's date*.
  (b) PARTIAL ESTIMATOR — this exercises only the asking-median + IQR CORE.
      It does NOT include the full estimator's tiering (same-house / cohort /
      class-coef), DaData enrichment, Avito-IMV, Cian/Yandex valuation, or the
      repair-state coefficient. Real estimate accuracy may differ.
  (c) ДКП ≠ TRUE MARKET — a registered ДКП price is what the parties declared
      to rosreestr; it can diverge from the genuine transaction price (tax
      optimisation, related-party sales, etc.).

PERFORMANCE
-----------
One spatial listings subquery runs per sampled deal, so runtime scales with
`--sample`. The default (300) finishes quickly; large samples (thousands) are
slow because of the per-deal PostGIS ST_DWithin scan. Bump `--sample` only
when you need tighter per-rooms confidence intervals.

USAGE
-----
    DATABASE_URL=postgresql+psycopg://... \
        python -m scripts.backtest_estimator --sample 300 --since 2025-06-01

    # machine-readable:
    python -m scripts.backtest_estimator --json
"""

from __future__ import annotations

import argparse
import json
import logging
import statistics
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from sqlalchemy import text
from sqlalchemy.orm import Session


def _import_estimator() -> tuple[Any, Any]:
    """Lazy import of the estimator's pure funcs (_filter_outliers, _percentile).

    Deferred so `--help` / the pure-metric unit tests don't pull
    app.core.config.Settings (which fail-fasts when DATABASE_URL is unset).
    Supports both `python -m scripts.backtest_estimator` and stand-alone runs.
    """
    try:
        from app.services.estimator import (  # type: ignore[import-not-found]
            _filter_outliers,
            _percentile,
        )
    except ImportError:  # pragma: no cover — fallback for adhoc invocation
        import sys

        sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
        from app.services.estimator import _filter_outliers, _percentile
    return _filter_outliers, _percentile


def _session() -> Session:
    """Lazy SessionLocal factory — see _import_estimator for why it's deferred."""
    try:
        from app.core.db import SessionLocal  # type: ignore[import-not-found]
    except ImportError:  # pragma: no cover — fallback for adhoc invocation
        import sys

        sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
        from app.core.db import SessionLocal
    return SessionLocal()


logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
logger = logging.getLogger("backtest_estimator")

# Price-per-m² sanity band — shared by the deal sample and the listings
# subquery. Mirrors the estimator's working range for EKB вторичка and drops
# obvious data-entry garbage / commercial outliers.
PPM2_MIN = 30_000
PPM2_MAX = 600_000

# Minimum surviving candidates required to emit a prediction. Below this the
# median is too noisy to be meaningful — count the deal as "no analogs".
MIN_CANDIDATES = 3

# Room buckets for the per-rooms breakdown. 0 = студия; the top bucket is "4+".
ROOM_BUCKETS: tuple[int, ...] = (0, 1, 2, 3, 4)

# Minimum matched deals a rooms-bucket must hold before we trust its OWN
# asking→sold ratio. Thin buckets fall back to the global (all-buckets) ratio,
# whose larger n keeps the correction stable instead of overfitting a handful
# of deals. See _derive_room_ratios.
MIN_BUCKET = 20


# --------------------------------------------------------------------------- #
# Data carriers
# --------------------------------------------------------------------------- #


@dataclass
class DealSample:
    """One held-out ДКП deal to backtest against."""

    id: int
    lon: float
    lat: float
    rooms: int
    sold_ppm2: float
    deal_date: Any  # datetime.date | None — carried through for reporting only


# --------------------------------------------------------------------------- #
# Pure metric helpers — NO DB. These are unit-tested in
# tests/test_backtest_estimator.py without a live database.
# --------------------------------------------------------------------------- #


def _rooms_label(rooms: int) -> str:
    """Human label for a room bucket: 0 → 'студия', 4 → '4+', else '<n>к'."""
    if rooms <= 0:
        return "студия"
    if rooms >= ROOM_BUCKETS[-1]:
        return f"{ROOM_BUCKETS[-1]}+"
    return f"{rooms}к"


def _bucketize_rooms(rooms: int) -> int:
    """Clamp a raw room count into a ROOM_BUCKETS key (4+ collapse to 4)."""
    if rooms <= 0:
        return 0
    return min(rooms, ROOM_BUCKETS[-1])


def _errors_summary(signed_errors: list[float]) -> dict[str, Any]:
    """Bias / MAPE / spread for a list of signed error percentages.

    - median_bias_pct  = median(signed_errors)  — systematic over/under-predict
    - mape_pct         = median(|signed_errors|) — typical magnitude (robust;
                          we use the MEDIAN absolute error, matching the brief's
                          MAPE-as-median-abs-error definition rather than mean)
    - p25 / p75        = quartiles of the SIGNED error (skew of the bias)

    Empty input → all-None (caller decides how to render "no data").
    """
    n = len(signed_errors)
    if n == 0:
        return {
            "n": 0,
            "median_bias_pct": None,
            "mape_pct": None,
            "p25_pct": None,
            "p75_pct": None,
        }
    abs_errors = [abs(e) for e in signed_errors]
    _, _percentile = _import_estimator()  # reuse estimator's interpolation percentile
    return {
        "n": n,
        "median_bias_pct": round(statistics.median(signed_errors), 2),
        "mape_pct": round(statistics.median(abs_errors), 2),
        "p25_pct": round(_percentile(sorted(signed_errors), 0.25), 2),
        "p75_pct": round(_percentile(sorted(signed_errors), 0.75), 2),
    }


def _compute_metrics(
    rows: list[tuple[float, float, int]],
    *,
    n_no_analogs: int = 0,
    per_rooms_no_analogs: dict[int, int] | None = None,
) -> dict[str, Any]:
    """Aggregate predicted-vs-sold rows into overall + per-rooms metrics.

    Each input row is (pred_ppm2, sold_ppm2, rooms) for a deal that DID get a
    prediction. `n_no_analogs` / `per_rooms_no_analogs` carry the skipped-deal
    counts so the report can show match coverage; they don't affect the error
    stats (those are computed only over matched deals).

    Returns a dict::

        {
          "overall": {n, n_no_analogs, median_bias_pct, mape_pct, p25_pct, p75_pct},
          "per_rooms": {
              0: {label, n, n_no_analogs, median_bias_pct, mape_pct, p25_pct, p75_pct},
              ...
          },
        }

    Pure: no DB, no I/O. signed_error_pct = 100*(pred - sold)/sold per row.
    Rows with sold_ppm2 <= 0 are dropped (cannot divide) — defensive; the SQL
    sample already excludes them.
    """
    per_rooms_no_analogs = per_rooms_no_analogs or {}

    overall_signed: list[float] = []
    by_bucket_signed: dict[int, list[float]] = {b: [] for b in ROOM_BUCKETS}

    for pred_ppm2, sold_ppm2, rooms in rows:
        if sold_ppm2 <= 0:
            continue
        signed = 100.0 * (pred_ppm2 - sold_ppm2) / sold_ppm2
        overall_signed.append(signed)
        by_bucket_signed[_bucketize_rooms(rooms)].append(signed)

    overall = _errors_summary(overall_signed)
    overall["n_no_analogs"] = n_no_analogs

    per_rooms: dict[int, dict[str, Any]] = {}
    for bucket in ROOM_BUCKETS:
        summary = _errors_summary(by_bucket_signed[bucket])
        summary["label"] = _rooms_label(bucket)
        summary["n_no_analogs"] = per_rooms_no_analogs.get(bucket, 0)
        per_rooms[bucket] = summary

    return {"overall": overall, "per_rooms": per_rooms}


def _derive_room_ratios(
    rows: list[tuple[float, float, int]],
    *,
    min_bucket: int = MIN_BUCKET,
) -> tuple[dict[int, float], dict[str, Any]]:
    """Derive a per-rooms asking→sold correction ratio from matched deals.

    For each row ``(pred_ask_ppm2, sold_ppm2, rooms)`` we want a multiplier that
    maps the estimator's asking-median prediction onto the realised SOLD price::

        ratio[bucket] = median(sold_ppm2) / median(pred_ask_ppm2)

    computed over the deals in that ROOM_BUCKETS bucket. A bucket holding fewer
    than ``min_bucket`` matched deals (or whose own median pred_ask is ≤ 0)
    falls back to the GLOBAL ratio — median(all sold) / median(all pred_ask) —
    so a handful of noisy deals can't overfit a per-rooms factor.

    Returns ``(ratios, meta)`` where:

      - ``ratios``: ``{bucket: float}`` for every bucket that has data (either
        its own ratio or the global fallback). Buckets with no matched deals at
        all are omitted (nothing to correct).
      - ``meta``: ``{"global_ratio": float | None, "fallback_buckets": [int],
        "bucket_n": {bucket: int}}`` — diagnostics for the report.

    !!! IN-SAMPLE WARNING
        These ratios are DERIVED from the same rows they will later be evaluated
        on (unless --holdout-split feeds disjoint rows). Used that way they make
        the corrected bias near-zero BY CONSTRUCTION — that proves the MECHANISM
        (a per-rooms multiplier removes the systematic asking→sold gap), NOT
        out-of-sample accuracy. The production ratio (Stage 2) is fit over a
        separate window and A/B'd on held-out deals.

    Pure: no DB, no I/O. Guards div-by-zero and empty input (→ ({}, meta)).
    """
    _, _percentile = _import_estimator()  # reuse estimator's interpolation percentile

    def _median(values: list[float]) -> float | None:
        if not values:
            return None
        return _percentile(sorted(values), 0.5)

    # Collect valid (pred>0, sold>0) ppm² pairs per bucket and globally.
    by_bucket_pred: dict[int, list[float]] = {b: [] for b in ROOM_BUCKETS}
    by_bucket_sold: dict[int, list[float]] = {b: [] for b in ROOM_BUCKETS}
    all_pred: list[float] = []
    all_sold: list[float] = []

    for pred_ppm2, sold_ppm2, rooms in rows:
        if pred_ppm2 <= 0 or sold_ppm2 <= 0:
            continue
        bucket = _bucketize_rooms(rooms)
        by_bucket_pred[bucket].append(pred_ppm2)
        by_bucket_sold[bucket].append(sold_ppm2)
        all_pred.append(pred_ppm2)
        all_sold.append(sold_ppm2)

    bucket_n = {b: len(by_bucket_pred[b]) for b in ROOM_BUCKETS}

    # Global fallback ratio — None when there's no data or pred median is 0.
    global_pred_med = _median(all_pred)
    global_sold_med = _median(all_sold)
    global_ratio: float | None = None
    if global_pred_med and global_pred_med > 0 and global_sold_med is not None:
        global_ratio = global_sold_med / global_pred_med

    ratios: dict[int, float] = {}
    fallback_buckets: list[int] = []
    for bucket in ROOM_BUCKETS:
        n = bucket_n[bucket]
        if n == 0:
            continue  # no deals in this bucket → nothing to correct
        pred_med = _median(by_bucket_pred[bucket])
        sold_med = _median(by_bucket_sold[bucket])
        if n >= min_bucket and pred_med and pred_med > 0 and sold_med is not None:
            ratios[bucket] = sold_med / pred_med
        elif global_ratio is not None:
            ratios[bucket] = global_ratio
            fallback_buckets.append(bucket)
        # else: no own ratio AND no global fallback → leave bucket uncorrected.

    meta = {
        "global_ratio": (round(global_ratio, 4) if global_ratio is not None else None),
        "fallback_buckets": fallback_buckets,
        "bucket_n": bucket_n,
        "min_bucket": min_bucket,
    }
    return ratios, meta


def _apply_ratios(
    rows: list[tuple[float, float, int]],
    ratios: dict[int, float],
) -> list[tuple[float, float, int]]:
    """Apply per-rooms ratios → corrected rows for re-scoring via _compute_metrics.

    ``pred_sold = pred_ask * ratio[bucket]``. A bucket with no ratio (e.g. no
    global fallback was available) leaves its rows unchanged so they still count
    in the corrected block rather than vanishing. Pure: no DB.
    """
    out: list[tuple[float, float, int]] = []
    for pred_ask, sold, rooms in rows:
        ratio = ratios.get(_bucketize_rooms(rooms), 1.0)
        out.append((pred_ask * ratio, sold, rooms))
    return out


def _render_table(metrics: dict[str, Any], headline: dict[str, Any]) -> str:
    """Render the aggregated metrics as a plain-text stdout report."""
    lines: list[str] = []
    lines.append("=" * 78)
    lines.append("BACKTEST: estimator asking-median vs rosreestr ДКП sold prices")
    lines.append("=" * 78)

    # Headline city-wide spread (asking median vs deal median, ppm²).
    dm = headline.get("deal_median_ppm2")
    am = headline.get("ask_median_ppm2")
    spread = headline.get("spread_pct")
    lines.append("")
    lines.append("CITY-WIDE HEADLINE (sample medians, ₽/m²):")
    lines.append(f"  deal_median_ppm2 (SOLD):    {_fmt_ppm2(dm)}")
    lines.append(f"  ask_median_ppm2  (ASKING):  {_fmt_ppm2(am)}")
    lines.append(f"  spread (ask vs deal):       {_fmt_pct(spread)}")

    # ASKING block — the estimator's raw asking-median prediction vs SOLD.
    lines.append("")
    lines.append(
        "PER-DEAL ERROR (signed = 100*(pred-sold)/sold; +ve = over-predict):"
    )
    lines.extend(
        _render_metrics_block(
            "[ASKING] estimator asking-median (uncorrected)", metrics
        )
    )

    # CORRECTED block — asking-median × per-rooms asking→sold ratio (#648 S1).
    corrected = metrics.get("corrected")
    ratios_meta = metrics.get("ratios_meta") or {}
    if corrected is not None:
        lines.append("")
        lines.extend(
            _render_metrics_block(
                "[CORRECTED] asking-median × per-rooms asking→sold ratio",
                corrected,
            )
        )
        lines.append("")
        lines.extend(_render_ratios(metrics.get("ratios") or {}, ratios_meta))
        lines.append("")
        if ratios_meta.get("holdout_split"):
            lines.append(
                "Ratios fit on EVEN-id deals, evaluated on ODD-id deals "
                "(--holdout-split) → this is an OUT-OF-SAMPLE corrected number."
            )
        else:
            lines.append(
                "!! IN-SAMPLE: ratios were derived AND evaluated on the same "
                "deals, so the"
            )
            lines.append(
                "   corrected bias is near-zero BY CONSTRUCTION. This proves "
                "the MECHANISM"
            )
            lines.append(
                "   (a per-rooms ratio removes the systematic asking→sold "
                "gap), NOT out-of-"
            )
            lines.append(
                "   sample accuracy. Re-run with --holdout-split for an honest "
                "number; the"
            )
            lines.append(
                "   real A/B (Stage 2) fits the ratio on a separate window."
            )

    lines.append("")
    lines.append("Caveats: CURRENT listings vs PAST deals (not point-in-time);")
    lines.append("measures asking-median+IQR core only; ДКП = registered price.")
    lines.append("=" * 78)
    return "\n".join(lines)


def _render_metrics_block(title: str, metrics: dict[str, Any]) -> list[str]:
    """Render one OVERALL + per-rooms metrics table (shared by both blocks)."""
    header = (
        f"  {'bucket':<8} {'n':>5} {'no_analog':>10} "
        f"{'bias%':>8} {'MAPE%':>8} {'p25%':>8} {'p75%':>8}"
    )
    out: list[str] = [title, header, "  " + "-" * (len(header) - 2)]
    out.append(_fmt_row("OVERALL", metrics["overall"]))
    for bucket in ROOM_BUCKETS:
        row = metrics["per_rooms"][bucket]
        out.append(_fmt_row(row["label"], row))
    return out


def _render_ratios(ratios: dict[int, float], meta: dict[str, Any]) -> list[str]:
    """Render the derived per-rooms asking→sold ratios + fallback flags."""
    out: list[str] = ["DERIVED per-rooms asking→sold ratios (sold_med / ask_med):"]
    fallback = set(meta.get("fallback_buckets") or [])
    bucket_n = meta.get("bucket_n") or {}
    gr = meta.get("global_ratio")
    min_bucket = meta.get("min_bucket", MIN_BUCKET)
    if not ratios:
        out.append("  (none — empty / no-prediction sample)")
    for bucket in ROOM_BUCKETS:
        if bucket not in ratios:
            continue
        flag = f" [global fallback, n<{min_bucket}]" if bucket in fallback else ""
        out.append(
            f"  {_rooms_label(bucket):<8} "
            f"ratio={ratios[bucket]:.4f}  n={bucket_n.get(bucket, 0)}{flag}"
        )
    out.append(f"  global fallback ratio: {gr if gr is not None else 'n/a'}")
    return out


def _fmt_row(label: str, m: dict[str, Any]) -> str:
    """Format one metrics row for the table."""
    return (
        f"  {label:<8} {m.get('n', 0):>5} {m.get('n_no_analogs', 0):>10} "
        f"{_fmt_pct(m.get('median_bias_pct')):>8} {_fmt_pct(m.get('mape_pct')):>8} "
        f"{_fmt_pct(m.get('p25_pct')):>8} {_fmt_pct(m.get('p75_pct')):>8}"
    )


def _fmt_pct(v: float | None) -> str:
    return "  n/a" if v is None else f"{v:+.1f}"


def _fmt_ppm2(v: float | None) -> str:
    return "n/a" if v is None else f"{round(v):,}".replace(",", " ")


# --------------------------------------------------------------------------- #
# DB layer — READ-ONLY SELECTs only.
# --------------------------------------------------------------------------- #

# ДКП deal sample. lon/lat extracted via ST_X/ST_Y so the per-deal listings
# query can rebuild the point without re-reading geom. Parameterized;
# psycopg3 CAST(:x AS type), never :x::type.
_SAMPLE_SQL = text(
    """
    SELECT
        id,
        ST_X(geom::geometry) AS lon,
        ST_Y(geom::geometry) AS lat,
        rooms,
        price_per_m2 AS sold_ppm2,
        deal_date
    FROM deals
    WHERE source = 'rosreestr'
      AND geom IS NOT NULL
      AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
      AND rooms IS NOT NULL
      AND deal_date >= CAST(:since AS date)
    ORDER BY id DESC
    LIMIT CAST(:sample AS integer)
    """
)

# Per-deal candidate active listings. rooms matched within :rooms_lo..:rooms_hi
# (exact when tolerance=0). Returns raw price_per_m2 values — _filter_outliers
# is applied in Python for byte-for-byte fidelity with the estimator.
_CANDIDATES_SQL = text(
    """
    SELECT price_per_m2
    FROM listings
    WHERE is_active
      AND rooms BETWEEN CAST(:rooms_lo AS integer) AND CAST(:rooms_hi AS integer)
      AND price_per_m2 BETWEEN CAST(:ppm2_min AS numeric) AND CAST(:ppm2_max AS numeric)
      AND ST_DWithin(
              geom::geography,
              ST_SetSRID(ST_MakePoint(
                  CAST(:lon AS double precision),
                  CAST(:lat AS double precision)
              ), 4326)::geography,
              CAST(:radius AS double precision)
          )
    """
)


def _load_sample(db: Session, *, sample: int, since: str) -> list[DealSample]:
    """Run the held-out ДКП deal sampling SELECT → list[DealSample]."""
    rows = (
        db.execute(
            _SAMPLE_SQL,
            {
                "ppm2_min": PPM2_MIN,
                "ppm2_max": PPM2_MAX,
                "since": since,
                "sample": sample,
            },
        )
        .mappings()
        .all()
    )
    out: list[DealSample] = []
    for r in rows:
        if r["lon"] is None or r["lat"] is None or r["sold_ppm2"] is None:
            continue
        out.append(
            DealSample(
                id=r["id"],
                lon=float(r["lon"]),
                lat=float(r["lat"]),
                rooms=int(r["rooms"]),
                sold_ppm2=float(r["sold_ppm2"]),
                deal_date=r["deal_date"],
            )
        )
    return out


def _predict_for_deal(
    db: Session,
    deal: DealSample,
    *,
    radius: int,
    rooms_tolerance: int,
) -> float | None:
    """Predict asking ppm² for one deal by reusing the estimator's pure funcs.

    Fetches candidate active-listing ppm² values, wraps them as
    ``{"price_per_m2": p}`` dicts, applies the estimator's `_filter_outliers`
    (Tukey IQR), then `_percentile(sorted, 0.5)`. Returns None when fewer than
    MIN_CANDIDATES survive (caller counts it as a no-analog miss).
    """
    rows = db.execute(
        _CANDIDATES_SQL,
        {
            "rooms_lo": deal.rooms - rooms_tolerance,
            "rooms_hi": deal.rooms + rooms_tolerance,
            "ppm2_min": PPM2_MIN,
            "ppm2_max": PPM2_MAX,
            "lon": deal.lon,
            "lat": deal.lat,
            "radius": radius,
        },
    ).all()

    # Build the same dict shape the estimator feeds _filter_outliers.
    lots = [{"price_per_m2": float(r[0])} for r in rows if r[0] is not None]
    if len(lots) < MIN_CANDIDATES:
        return None

    _filter_outliers, _percentile = _import_estimator()
    clean = _filter_outliers(lots)
    prices = sorted(lot["price_per_m2"] for lot in clean if lot["price_per_m2"])
    if len(prices) < MIN_CANDIDATES:
        return None
    return _percentile(prices, 0.5)


def _attach_correction(
    metrics: dict[str, Any],
    matched_rows: list[tuple[float, float, int]],
    matched_ids: list[int],
    *,
    holdout_split: bool,
) -> None:
    """Derive the per-rooms ratio, apply it, and attach the CORRECTED block.

    Mutates ``metrics`` in place, adding ``ratios`` (bucket→float),
    ``ratios_meta`` (diagnostics), and ``corrected`` (a full _compute_metrics
    dict for ``pred_sold = pred_ask * ratio``).

    Two modes:
      - default (in-sample): ratios derived on ALL matched rows, evaluated on
        ALL matched rows → near-zero bias by construction (mechanism proof).
      - ``holdout_split``: ratios derived on EVEN-id deals, evaluated on the
        ODD-id half → an honest out-of-sample number. Split is by deal-id
        parity (deterministic, reproducible — no RNG).

    Pure aside from reusing the no-DB helpers; safe on an empty sample.
    """
    if holdout_split:
        paired = list(zip(matched_rows, matched_ids, strict=True))
        fit_rows = [r for r, did in paired if did % 2 == 0]
        eval_rows = [r for r, did in paired if did % 2 == 1]
    else:
        fit_rows = matched_rows
        eval_rows = matched_rows

    ratios, ratios_meta = _derive_room_ratios(fit_rows)
    ratios_meta["holdout_split"] = holdout_split
    ratios_meta["n_fit"] = len(fit_rows)
    ratios_meta["n_eval"] = len(eval_rows)

    corrected_rows = _apply_ratios(eval_rows, ratios)
    corrected = _compute_metrics(corrected_rows)

    # JSON-friendly: stringify int bucket keys, round ratios for readability.
    metrics["ratios"] = ratios
    metrics["ratios_meta"] = ratios_meta
    metrics["corrected"] = corrected


def run_backtest(
    db: Session,
    *,
    sample: int,
    since: str,
    radius: int,
    rooms_tolerance: int,
    holdout_split: bool = False,
) -> dict[str, Any]:
    """Drive the full read-only backtest and return a metrics dict.

    Steps: load sample → predict per deal (reusing estimator funcs) → collect
    (pred, sold, rooms) rows + no-analog counts → `_compute_metrics` (ASKING
    block) → `_derive_room_ratios` + `_apply_ratios` → `_compute_metrics`
    again (CORRECTED block) + a city-wide headline spread. No writes.

    The CORRECTED block multiplies each asking-median prediction by a per-rooms
    asking→sold ratio derived from the SAME sample (issue #648 Stage 1). With
    ``holdout_split=False`` (default) that ratio is fit and evaluated in-sample,
    so its bias is near-zero by construction — it proves the MECHANISM, not
    out-of-sample accuracy (see _derive_room_ratios). Pass ``holdout_split=True``
    to fit on even-id deals and evaluate on the odd-id half for an honest number.
    """
    deals = _load_sample(db, sample=sample, since=since)
    logger.info("loaded sample: %d ДКП deals (since=%s)", len(deals), since)

    matched_rows: list[tuple[float, float, int]] = []
    matched_ids: list[int] = []
    n_no_analogs = 0
    per_rooms_no_analogs: dict[int, int] = {b: 0 for b in ROOM_BUCKETS}

    # For the city-wide headline: median of all sampled SOLD ppm², and median
    # of all per-deal predicted ASKING ppm² (matched deals only).
    sold_ppm2_all: list[float] = [d.sold_ppm2 for d in deals]
    pred_ppm2_all: list[float] = []

    for i, deal in enumerate(deals, start=1):
        pred = _predict_for_deal(
            db, deal, radius=radius, rooms_tolerance=rooms_tolerance
        )
        if pred is None:
            n_no_analogs += 1
            per_rooms_no_analogs[_bucketize_rooms(deal.rooms)] += 1
        else:
            matched_rows.append((pred, deal.sold_ppm2, deal.rooms))
            matched_ids.append(deal.id)
            pred_ppm2_all.append(pred)

        if i % 50 == 0:
            logger.info(
                "progress %d/%d (matched=%d, no_analogs=%d)",
                i,
                len(deals),
                len(matched_rows),
                n_no_analogs,
            )

    metrics = _compute_metrics(
        matched_rows,
        n_no_analogs=n_no_analogs,
        per_rooms_no_analogs=per_rooms_no_analogs,
    )

    _attach_correction(
        metrics, matched_rows, matched_ids, holdout_split=holdout_split
    )

    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,
    }
    metrics["params"] = {
        "sample_requested": sample,
        "sample_loaded": len(deals),
        "since": since,
        "radius_m": radius,
        "rooms_tolerance": rooms_tolerance,
        "n_matched": len(matched_rows),
        "n_no_analogs": n_no_analogs,
        "holdout_split": holdout_split,
    }
    return metrics


# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    """argparse setup, factored out for testability."""
    p = argparse.ArgumentParser(
        description=(
            "READ-ONLY backtest of the estimator's asking-median accuracy "
            "against rosreestr ДКП sold prices (issue #648)."
        ),
    )
    p.add_argument(
        "--sample",
        type=int,
        default=300,
        help="Held-out ДКП deals to test (default 300). Large samples are slow "
        "— one PostGIS subquery runs per deal.",
    )
    p.add_argument(
        "--since",
        default="2025-06-01",
        help="Only deals with deal_date >= this ISO date (default 2025-06-01).",
    )
    p.add_argument(
        "--radius",
        type=int,
        default=1000,
        help="Analog search radius in metres (default 1000).",
    )
    p.add_argument(
        "--rooms-tolerance",
        type=int,
        default=0,
        help="± room count tolerance for analogs (default 0 = exact match).",
    )
    p.add_argument(
        "--holdout-split",
        action="store_true",
        help="Fit the per-rooms asking→sold ratio on EVEN-id deals and evaluate "
        "the CORRECTED block on the ODD-id half (deterministic out-of-sample "
        "split, no RNG). Default off → ratio is fit AND evaluated in-sample, so "
        "the corrected bias is near-zero by construction (mechanism proof only).",
    )
    p.add_argument(
        "--json",
        action="store_true",
        help="Emit machine-readable JSON instead of the text table.",
    )
    return p.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    """CLI entry point. Returns the count of matched (predicted) deals."""
    args = _parse_args(argv)
    logger.info(
        "backtest start: sample=%d since=%s radius=%dm rooms_tolerance=%d "
        "holdout_split=%s",
        args.sample,
        args.since,
        args.radius,
        args.rooms_tolerance,
        args.holdout_split,
    )

    db = _session()
    try:
        metrics = run_backtest(
            db,
            sample=args.sample,
            since=args.since,
            radius=args.radius,
            rooms_tolerance=args.rooms_tolerance,
            holdout_split=args.holdout_split,
        )
    finally:
        db.close()

    if args.json:
        print(json.dumps(metrics, ensure_ascii=False, indent=2, default=str))
    else:
        print(_render_table(metrics, metrics["headline"]))

    return int(metrics["params"]["n_matched"])


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(0 if main() >= 0 else 1)
