All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-backend (push) Successful in 53s
Deploy Trade-In / deploy (push) Successful in 48s
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 1m24s
Full-spine backtest harness via _price_from_inputs + range-coverage/calibration/per-segment MAPE. code-reviewer APPROVE. Real prod numbers: overall MAPE 18.4%/bias -1.2%; segments эконом +16% / бизнес -23% / элит -38%; range-coverage 56.6%. Read-only; full suite 2488 passed. Refs #1966.
1578 lines
61 KiB
Python
1578 lines
61 KiB
Python
"""Backtest harness — measures the estimator's accuracy vs real ДКП sold prices.
|
||
|
||
Forgejo issues #648 (asking-core) + #1966 (full spine). **STRICTLY READ-ONLY**:
|
||
this script issues only SELECT queries against prod. It never INSERTs, UPDATEs,
|
||
or runs DDL.
|
||
|
||
TWO ENGINES (``--engine``)
|
||
--------------------------
|
||
* ``full`` (DEFAULT, #1966) — runs the **full deterministic pricing spine**
|
||
via ``estimator._price_from_inputs``: the same analog tier ladder
|
||
(Tier 0 cohort → Tier A 1 km → wide 2 km → widearea ±25 %), same-building
|
||
anchor comps, ДКП corridor clamp/floor, house Avito-IMV anchor, quarter
|
||
price index, and the asking→sold ratio that yields ``expected_sold``. The
|
||
PRODUCT-relevant prediction is ``expected_sold_per_m2`` (we also keep the
|
||
asking headline ``median_ppm2`` so both are reported).
|
||
* ``asking-core`` (#648) — the legacy asking-median + Tukey-IQR CORE only,
|
||
plus the in-/out-of-sample per-rooms asking→sold correction block. Kept for
|
||
comparison against the full spine.
|
||
|
||
WHAT IT MEASURES (full spine)
|
||
-----------------------------
|
||
Ground truth is `deals` — registered rosreestr ДКП sales (source='rosreestr')
|
||
that carry geom + price_per_m2 + rooms + area_m2 + floor/total_floors/year/type.
|
||
For a held-out sample of ДКП deals we, per deal:
|
||
|
||
1. Replicate ``estimate_quality``'s analog tier ladder with ``_fetch_analogs``
|
||
(same constants: DEFAULT_RADIUS_M, FALLBACK_RADIUS_M, MIN_ANALOGS_TIER_0,
|
||
the <5/<3 widen thresholds) and pre-fetch the spine inputs
|
||
(``_fetch_anchor_comps``, ``_fetch_dkp_corridor``,
|
||
``_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.
|
||
3. Score ``expected_sold_per_m2`` vs the realised SOLD ppm².
|
||
|
||
METRICS (full spine)
|
||
--------------------
|
||
* EXPECTED_SOLD signed error: median bias % + MAPE (median |%|), OVERALL +
|
||
per-rooms + per-price-segment (эконом/комфорт/бизнес/элит/премиум).
|
||
* RANGE COVERAGE: fraction of deals whose actual sold TOTAL (sold_ppm2 ×
|
||
area) falls inside the predicted expected_sold RUB range — OVERALL +
|
||
per-confidence bucket.
|
||
* CONFIDENCE CALIBRATION: n / coverage / MAPE per confidence bucket
|
||
(high should be tighter & more accurate — surfaces R2 risk).
|
||
* SHARPNESS: median relative range width (high-low)/point — guards against
|
||
gaming coverage with very wide ranges.
|
||
* City-wide deal_median_ppm2 vs ask_median_ppm2 headline spread.
|
||
|
||
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 judged against listings active today, so asking prices
|
||
drift away from the historical sold price as the market moves. Treat the
|
||
output as a REGRESSION BASELINE (relative change between runs / engines),
|
||
NOT absolute truth. A faithful point-in-time backtest needs
|
||
`listing_source_snapshots` (#570).
|
||
(b) NETWORK LAYERS EXCLUDED — the spine runs offline, so the on-demand
|
||
network valuation sources (Avito-IMV on-demand eval, Yandex valuation,
|
||
Cian valuation) are NOT exercised (``imv_eval=None``,
|
||
``yandex/cian_val_present=False``). The populated `house_imv_evaluations`
|
||
anchor IS injected, but it keys on house_id which the harness does not
|
||
resolve (target_house_id=None) → in practice no IMV anchor fires. Real
|
||
estimate accuracy with the network layers 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
|
||
-----------
|
||
The full spine issues several spatial SELECTs per sampled deal (the tier ladder
|
||
+ anchor/corridor/imv pre-fetches), so runtime scales with `--sample`. The
|
||
default (300) is fine; large samples (thousands) are slow.
|
||
|
||
USAGE
|
||
-----
|
||
DATABASE_URL=postgresql+psycopg://... \
|
||
python -m scripts.backtest_estimator --sample 300 --since 2025-06-01
|
||
|
||
# legacy asking-median core + correction block:
|
||
python -m scripts.backtest_estimator --engine asking-core
|
||
|
||
# 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 types import SimpleNamespace
|
||
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 _import_estimator_full() -> SimpleNamespace:
|
||
"""Lazy import of the FULL pricing spine + the helpers it needs (#1966).
|
||
|
||
Returns a namespace bundling the estimator module (``m``), its ``settings``
|
||
singleton, and the ``GeocodeResult`` dataclass. Deferred for the same reason
|
||
as _import_estimator — importing app.services.estimator pulls
|
||
app.core.config.Settings, which fail-fasts when DATABASE_URL is unset, so we
|
||
keep it out of `--help` / the pure-metric unit tests.
|
||
|
||
Everything the full-spine prediction path calls lives on ``ns.m``:
|
||
_price_from_inputs, _fetch_analogs, _fetch_anchor_comps, _fetch_dkp_corridor,
|
||
_fetch_house_imv_anchor, _get_asking_sold_ratio, _lookup_quarter_index(es),
|
||
_target_cohort_range, and the DEFAULT_RADIUS_M / FALLBACK_RADIUS_M /
|
||
MIN_ANALOGS_TIER_0 constants.
|
||
"""
|
||
try:
|
||
from app.services import estimator as est_mod # 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.services import estimator as est_mod
|
||
# settings + GeocodeResult are re-exported as module attributes of estimator
|
||
# (imported there at module level), so we read them off the same object.
|
||
return SimpleNamespace(
|
||
m=est_mod,
|
||
settings=est_mod.settings,
|
||
GeocodeResult=est_mod.GeocodeResult,
|
||
)
|
||
|
||
|
||
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
|
||
|
||
# Confidence buckets the spine emits (estimator._compute_confidence /
|
||
# anchor / coarse-geo gates only ever return one of these three). Used by the
|
||
# range-coverage + calibration breakdowns. Any unexpected value → "other".
|
||
CONFIDENCE_BUCKETS: tuple[str, ...] = ("high", "medium", "low")
|
||
|
||
# Price-segment bands by ₽/m² (EKB вторичка). The estimator has NO reusable
|
||
# price-tier constant — `listing_segment` is categorical (vtorichka/novostroyki)
|
||
# and DEAL_MIN/MAX_PPM2 are sanity bounds, not class bands — so these are
|
||
# defined here. TUNABLE: rough EKB market tiers (эконом < комфорт < бизнес <
|
||
# элит < премиум). Each entry is (label, upper_bound_exclusive); a value lands
|
||
# in the first band whose upper bound it is below; the last band catches the
|
||
# tail (+inf). Verified against config notes: p99.9 deals ≈ 500k, премиум ~680k.
|
||
PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
|
||
("эконом", 120_000.0),
|
||
("комфорт", 160_000.0),
|
||
("бизнес", 220_000.0),
|
||
("элит", 300_000.0),
|
||
("премиум", float("inf")),
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Data carriers
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
@dataclass
|
||
class DealSample:
|
||
"""One held-out ДКП deal to backtest against.
|
||
|
||
The full spine (#1966) needs the extra unit/house fields (area, address,
|
||
floor, total_floors, year_built, house_type) to replicate ``_fetch_analogs``
|
||
+ the anchor/corridor pre-fetches; the asking-core engine ignores them.
|
||
"""
|
||
|
||
id: int
|
||
lon: float
|
||
lat: float
|
||
rooms: int
|
||
sold_ppm2: float
|
||
deal_date: Any # datetime.date | None — carried through for reporting only
|
||
area_m2: float = 0.0
|
||
address: str | None = None
|
||
floor: int | None = None
|
||
total_floors: int | None = None
|
||
year_built: int | None = None
|
||
house_type: str | None = None
|
||
|
||
|
||
@dataclass
|
||
class Prediction:
|
||
"""One full-spine prediction for a deal that DID get priced (#1966).
|
||
|
||
Carries both the asking headline (``median_ppm2``) and the product-relevant
|
||
``expected_sold_*`` outputs. Range fields are RUB TOTALS (the spine emits
|
||
expected_sold_range_low/high as totals, not ₽/m²). ``expected_sold_*`` may be
|
||
None when the spine produced a headline but no asking→sold ratio resolved.
|
||
"""
|
||
|
||
deal_id: int
|
||
rooms: int
|
||
area_m2: float
|
||
sold_ppm2: float
|
||
median_ppm2: float # asking headline ₽/m²
|
||
confidence: str
|
||
anchor_tier: str | None
|
||
expected_sold_ppm2: float | None
|
||
expected_sold_price: float | None # RUB total point
|
||
range_low: float | None # expected_sold_range_low — RUB total
|
||
range_high: float | None # expected_sold_range_high — RUB total
|
||
|
||
@property
|
||
def sold_total(self) -> float:
|
||
"""Realised sold price as a RUB total (sold ₽/m² × area)."""
|
||
return self.sold_ppm2 * self.area_m2
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Full-spine metric helpers (#1966) — PURE, no DB. Unit-tested.
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _bucketize_confidence(confidence: str) -> str:
|
||
"""Map a confidence string to a calibration bucket (unknown → 'other')."""
|
||
return confidence if confidence in CONFIDENCE_BUCKETS else "other"
|
||
|
||
|
||
def _segment_label(ppm2: float) -> str:
|
||
"""Price-segment label for a ₽/m² value (see PRICE_SEGMENTS_PPM2 bands)."""
|
||
for label, upper in PRICE_SEGMENTS_PPM2:
|
||
if ppm2 < upper:
|
||
return label
|
||
return PRICE_SEGMENTS_PPM2[-1][0] # +inf tail — unreachable, defensive
|
||
|
||
|
||
def _segment_metrics(rows: list[tuple[float, float]]) -> dict[str, dict[str, Any]]:
|
||
"""Per-price-segment signed-error summary, bucketed by the SOLD ₽/m².
|
||
|
||
Each input row is ``(pred_ppm2, sold_ppm2)``. We bucket by the segment of the
|
||
SOLD price (ground truth), compute signed_error_pct = 100*(pred-sold)/sold,
|
||
and run `_errors_summary` per band. Rows with sold<=0 are dropped (can't
|
||
divide). Every band in PRICE_SEGMENTS_PPM2 is present (n=0 when empty) so the
|
||
report renders a stable table. Pure: no DB.
|
||
"""
|
||
by_seg: dict[str, list[float]] = {label: [] for label, _ in PRICE_SEGMENTS_PPM2}
|
||
for pred, sold in rows:
|
||
if sold <= 0:
|
||
continue
|
||
by_seg[_segment_label(sold)].append(100.0 * (pred - sold) / sold)
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for label, _ in PRICE_SEGMENTS_PPM2:
|
||
out[label] = _errors_summary(by_seg[label])
|
||
return out
|
||
|
||
|
||
def _range_coverage(rows: list[tuple[float, float, float]]) -> dict[str, Any]:
|
||
"""Coverage = fraction of (actual, low, high) rows with low<=actual<=high.
|
||
|
||
Boundary INCLUSIVE (a deal sitting exactly on the range edge counts as
|
||
covered). Empty input → coverage_pct None. Pure: no DB.
|
||
"""
|
||
n = len(rows)
|
||
if n == 0:
|
||
return {"n": 0, "n_covered": 0, "coverage_pct": None}
|
||
covered = sum(1 for actual, lo, hi in rows if lo <= actual <= hi)
|
||
return {"n": n, "n_covered": covered, "coverage_pct": round(100.0 * covered / n, 2)}
|
||
|
||
|
||
def _sharpness(rows: list[tuple[float, float, float]]) -> dict[str, Any]:
|
||
"""Median relative range width = median((high-low)/point) over (point,lo,hi).
|
||
|
||
Guards against gaming coverage with arbitrarily wide ranges: a model can
|
||
always cover 100% by predicting an infinite range, so we report how WIDE the
|
||
ranges are relative to the point estimate. Rows with point<=0 are dropped.
|
||
Empty → median_rel_width None. Pure: no DB.
|
||
"""
|
||
widths = [(hi - lo) / point for point, lo, hi in rows if point > 0]
|
||
if not widths:
|
||
return {"n": 0, "median_rel_width": None}
|
||
_, _percentile = _import_estimator() # reuse estimator's interpolation percentile
|
||
return {"n": len(widths), "median_rel_width": round(_percentile(sorted(widths), 0.5), 4)}
|
||
|
||
|
||
def _calibration_metrics(
|
||
rows: list[tuple[str, float | None, bool | None]],
|
||
*,
|
||
bucket_order: list[str] | None = None,
|
||
) -> dict[str, dict[str, Any]]:
|
||
"""Per-confidence n / coverage% / MAPE% — surfaces the R2 risk.
|
||
|
||
Each input row is ``(confidence, signed_error_pct | None, covered | None)``:
|
||
- ``n`` = predictions in this confidence bucket (always counted).
|
||
- ``coverage_pct``= 100 * mean(covered) over rows where covered is not None.
|
||
- ``mape_pct`` = median(|signed_error_pct|) over rows where signed is not
|
||
None (the brief's median-abs-error MAPE definition).
|
||
|
||
A well-calibrated estimator shows HIGH confidence = higher coverage AND lower
|
||
MAPE than LOW. The canonical high/medium/low buckets are always present (n=0
|
||
when empty); an "other" bucket appears only if an off-enum confidence shows
|
||
up. Pure: no DB.
|
||
"""
|
||
order = list(bucket_order) if bucket_order is not None else list(CONFIDENCE_BUCKETS)
|
||
coll: dict[str, dict[str, Any]] = {}
|
||
for confidence, signed, covered in rows:
|
||
b = _bucketize_confidence(confidence)
|
||
slot = coll.setdefault(b, {"n": 0, "abs_errors": [], "covered": []})
|
||
slot["n"] += 1
|
||
if signed is not None:
|
||
slot["abs_errors"].append(abs(signed))
|
||
if covered is not None:
|
||
slot["covered"].append(covered)
|
||
if b not in order:
|
||
order.append(b)
|
||
|
||
out: dict[str, dict[str, Any]] = {}
|
||
for b in order:
|
||
slot = coll.get(b, {"n": 0, "abs_errors": [], "covered": []})
|
||
cov = slot["covered"]
|
||
abs_errors = slot["abs_errors"]
|
||
n_covered = sum(1 for c in cov if c)
|
||
out[b] = {
|
||
"n": slot["n"],
|
||
"n_covered": n_covered,
|
||
"coverage_pct": (round(100.0 * n_covered / len(cov), 2) if cov else None),
|
||
"mape_pct": (round(statistics.median(abs_errors), 2) if abs_errors else None),
|
||
}
|
||
return out
|
||
|
||
|
||
def _expected_sold_metrics(
|
||
rows: list[tuple[float, float, int]],
|
||
*,
|
||
n_no_prediction: int = 0,
|
||
per_rooms_no_prediction: dict[int, int] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""expected_sold error block: overall + per-rooms (via _compute_metrics) + per-segment.
|
||
|
||
Each input row is ``(expected_sold_ppm2, sold_ppm2, rooms)``. Reuses the
|
||
existing `_compute_metrics` for the overall/per-rooms split (so the no-data
|
||
counts ride along as ``n_no_analogs`` — here meaning "no prediction") and
|
||
attaches a per-price-segment breakdown keyed by the SOLD ₽/m². Pure: no DB.
|
||
"""
|
||
m = _compute_metrics(
|
||
rows,
|
||
n_no_analogs=n_no_prediction,
|
||
per_rooms_no_analogs=per_rooms_no_prediction,
|
||
)
|
||
m["per_segment"] = _segment_metrics([(pred, sold) for pred, sold, _ in rows])
|
||
return m
|
||
|
||
|
||
def _compute_full_metrics(
|
||
predictions: list[Prediction],
|
||
*,
|
||
n_no_prediction: int = 0,
|
||
per_rooms_no_prediction: dict[int, int] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Aggregate full-spine Prediction records into the complete metrics dict.
|
||
|
||
Blocks (all over deals that DID get an expected_sold, except where noted):
|
||
- ``expected_sold`` : signed bias / MAPE overall + per-rooms + per-segment.
|
||
- ``range_coverage`` : sold-total ∈ expected_sold range, overall +
|
||
per-confidence (counts ALL priced deals; a deal with
|
||
no expected_sold range contributes to n but not to
|
||
coverage).
|
||
- ``calibration`` : per-confidence n / coverage% / MAPE%.
|
||
- ``sharpness`` : median relative range width (high-low)/point.
|
||
|
||
Pure: no DB. Safe on an empty list (every block renders with None/0).
|
||
"""
|
||
# Confidence bucket order: canonical three, then any extras encountered.
|
||
conf_order = list(CONFIDENCE_BUCKETS)
|
||
for p in predictions:
|
||
b = _bucketize_confidence(p.confidence)
|
||
if b not in conf_order:
|
||
conf_order.append(b)
|
||
|
||
es_rows: list[tuple[float, float, int]] = [] # (expected_sold_ppm2, sold_ppm2, rooms)
|
||
cov_rows: list[tuple[float, float, float]] = [] # (sold_total, range_low, range_high)
|
||
cov_by_conf: dict[str, list[tuple[float, float, float]]] = {b: [] for b in conf_order}
|
||
sharp_rows: list[tuple[float, float, float]] = [] # (point, range_low, range_high)
|
||
calib_rows: list[tuple[str, float | None, bool | None]] = []
|
||
|
||
for p in predictions:
|
||
signed: float | None = None
|
||
if p.expected_sold_ppm2 is not None and p.sold_ppm2 > 0:
|
||
signed = 100.0 * (p.expected_sold_ppm2 - p.sold_ppm2) / p.sold_ppm2
|
||
es_rows.append((p.expected_sold_ppm2, p.sold_ppm2, p.rooms))
|
||
|
||
covered: bool | None = None
|
||
if p.range_low is not None and p.range_high is not None:
|
||
covered = p.range_low <= p.sold_total <= p.range_high
|
||
cov_rows.append((p.sold_total, p.range_low, p.range_high))
|
||
cov_by_conf[_bucketize_confidence(p.confidence)].append(
|
||
(p.sold_total, p.range_low, p.range_high)
|
||
)
|
||
if p.expected_sold_price is not None:
|
||
sharp_rows.append((p.expected_sold_price, p.range_low, p.range_high))
|
||
|
||
calib_rows.append((p.confidence, signed, covered))
|
||
|
||
return {
|
||
"expected_sold": _expected_sold_metrics(
|
||
es_rows,
|
||
n_no_prediction=n_no_prediction,
|
||
per_rooms_no_prediction=per_rooms_no_prediction,
|
||
),
|
||
"range_coverage": {
|
||
"overall": _range_coverage(cov_rows),
|
||
"per_confidence": {b: _range_coverage(cov_by_conf[b]) for b in conf_order},
|
||
},
|
||
"calibration": _calibration_metrics(calib_rows, bucket_order=conf_order),
|
||
"sharpness": _sharpness(sharp_rows),
|
||
"confidence_order": conf_order,
|
||
}
|
||
|
||
|
||
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], *, no_col: str = "no_analog"
|
||
) -> list[str]:
|
||
"""Render one OVERALL + per-rooms metrics table (shared by all blocks).
|
||
|
||
``no_col`` labels the skipped-deal column header — "no_analog" for the
|
||
asking-core block, "no_pred" for the full-spine expected_sold block (where it
|
||
counts deals the spine could not price).
|
||
"""
|
||
header = (
|
||
f" {'bucket':<8} {'n':>5} {no_col:>10} {'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(",", " ")
|
||
|
||
|
||
def _fmt_cov(v: float | None) -> str:
|
||
"""Coverage percent — non-signed (0..100), n/a for None."""
|
||
return "n/a" if v is None else f"{v:.1f}"
|
||
|
||
|
||
def _fmt_ratio(v: float | None) -> str:
|
||
"""Relative-width / ratio formatter (n/a for None)."""
|
||
return "n/a" if v is None else f"{v:.3f}"
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Full-spine renderer (#1966)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
|
||
def _render_full_table(metrics: dict[str, Any]) -> str:
|
||
"""Render the full-spine metrics dict (#1966) as a plain-text stdout report."""
|
||
headline = metrics.get("headline") or {}
|
||
lines: list[str] = []
|
||
lines.append("=" * 78)
|
||
lines.append("BACKTEST [full spine]: _price_from_inputs expected_sold vs ДКП sold")
|
||
lines.append("=" * 78)
|
||
|
||
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 head): {_fmt_ppm2(am)}")
|
||
lines.append(f" spread (asking vs deal): {_fmt_pct(spread)}")
|
||
|
||
es = metrics["expected_sold"]
|
||
lines.append("")
|
||
lines.append("EXPECTED_SOLD ERROR (signed = 100*(expected_sold-sold)/sold; +ve = over):")
|
||
lines.extend(_render_metrics_block("[EXPECTED_SOLD] per-rooms", es, no_col="no_pred"))
|
||
lines.append("")
|
||
lines.extend(_render_segment_block(es["per_segment"]))
|
||
|
||
conf_order = metrics.get("confidence_order") or list(CONFIDENCE_BUCKETS)
|
||
lines.append("")
|
||
lines.extend(_render_coverage_block(metrics["range_coverage"], conf_order))
|
||
lines.append("")
|
||
lines.extend(_render_calibration_block(metrics["calibration"], conf_order))
|
||
|
||
sh = metrics["sharpness"]
|
||
lines.append("")
|
||
lines.append(
|
||
f"SHARPNESS: median relative range width (high-low)/point = "
|
||
f"{_fmt_ratio(sh.get('median_rel_width'))} (n={sh.get('n', 0)})"
|
||
)
|
||
|
||
lines.append("")
|
||
lines.append("Caveats: CURRENT listings vs PAST deals (time-mismatched) →")
|
||
lines.append("regression baseline, not absolute truth; network valuation")
|
||
lines.append("layers (Avito-IMV on-demand / Yandex / Cian) excluded.")
|
||
lines.append("=" * 78)
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _render_segment_block(per_segment: dict[str, Any]) -> list[str]:
|
||
"""Render the per-price-segment expected_sold error table."""
|
||
header = f" {'segment':<10} {'n':>5} {'bias%':>8} {'MAPE%':>8} {'p25%':>8} {'p75%':>8}"
|
||
out: list[str] = [
|
||
"[EXPECTED_SOLD] per price-segment (band by SOLD ₽/m²):",
|
||
header,
|
||
" " + "-" * (len(header) - 2),
|
||
]
|
||
for label, _ in PRICE_SEGMENTS_PPM2:
|
||
m = per_segment[label]
|
||
out.append(
|
||
f" {label:<10} {m.get('n', 0):>5} "
|
||
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}"
|
||
)
|
||
return out
|
||
|
||
|
||
def _render_coverage_block(range_coverage: dict[str, Any], conf_order: list[str]) -> list[str]:
|
||
"""Render range-coverage: overall + per-confidence (sold_total ∈ range)."""
|
||
ov = range_coverage["overall"]
|
||
out: list[str] = ["RANGE COVERAGE (sold total within predicted expected_sold range):"]
|
||
out.append(
|
||
f" OVERALL n={ov['n']:>5} covered={ov['n_covered']:>5} "
|
||
f"coverage={_fmt_cov(ov['coverage_pct'])}%"
|
||
)
|
||
per_conf = range_coverage.get("per_confidence") or {}
|
||
for b in conf_order:
|
||
c = per_conf.get(b, {"n": 0, "n_covered": 0, "coverage_pct": None})
|
||
out.append(
|
||
f" {b:<10} n={c['n']:>5} covered={c['n_covered']:>5} "
|
||
f"coverage={_fmt_cov(c['coverage_pct'])}%"
|
||
)
|
||
return out
|
||
|
||
|
||
def _render_calibration_block(calibration: dict[str, Any], conf_order: list[str]) -> list[str]:
|
||
"""Render confidence-calibration: per bucket n / coverage% / MAPE%."""
|
||
header = f" {'conf':<8} {'n':>5} {'coverage%':>10} {'MAPE%':>8}"
|
||
out: list[str] = [
|
||
"CONFIDENCE CALIBRATION (high should be tighter & more accurate):",
|
||
header,
|
||
" " + "-" * (len(header) - 2),
|
||
]
|
||
for b in conf_order:
|
||
c = calibration.get(b, {"n": 0, "coverage_pct": None, "mape_pct": None})
|
||
out.append(
|
||
f" {b:<8} {c.get('n', 0):>5} "
|
||
f"{_fmt_cov(c.get('coverage_pct')):>10} {_fmt_pct(c.get('mape_pct')):>8}"
|
||
)
|
||
return out
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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,
|
||
area_m2,
|
||
address,
|
||
floor,
|
||
total_floors,
|
||
year_built,
|
||
house_type
|
||
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 area_m2 IS NOT NULL
|
||
AND area_m2 > 0
|
||
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
|
||
if r["area_m2"] is None or float(r["area_m2"]) <= 0:
|
||
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"],
|
||
area_m2=float(r["area_m2"]),
|
||
address=r["address"],
|
||
floor=(int(r["floor"]) if r["floor"] is not None else None),
|
||
total_floors=(int(r["total_floors"]) if r["total_floors"] is not None else None),
|
||
year_built=(int(r["year_built"]) if r["year_built"] is not None else None),
|
||
house_type=r["house_type"],
|
||
)
|
||
)
|
||
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 _select_analogs_full(
|
||
db: Session, deal: DealSample, est: SimpleNamespace
|
||
) -> tuple[list[dict[str, Any]], str, bool, bool]:
|
||
"""Replicate estimate_quality's analog tier ladder for one deal (#1966).
|
||
|
||
Mirrors the Tier0(cohort)→A(1 km)→wide(2 km)→widearea(±25 %) sequence
|
||
EXACTLY, including the MIN_ANALOGS_TIER_0 gate and the <5 / <3 widen
|
||
thresholds and the "only adopt the wider tier when it returns MORE lots"
|
||
guards. target_house_id is always None here (the harness does not resolve
|
||
canonical houses), so Tier S(canonical) never fires — same as a fresh
|
||
estimate without a house match.
|
||
|
||
Returns ``(listings, analog_tier, fallback_used, area_widened)``.
|
||
"""
|
||
m = est.m
|
||
cohort_range = m._target_cohort_range(deal.year_built)
|
||
if cohort_range is not None:
|
||
cy_min, cy_max = cohort_range
|
||
listings_tier0, _, analog_tier = m._fetch_analogs(
|
||
db,
|
||
lat=deal.lat,
|
||
lon=deal.lon,
|
||
rooms=deal.rooms,
|
||
area=deal.area_m2,
|
||
radius_m=m.DEFAULT_RADIUS_M,
|
||
full_address=deal.address,
|
||
target_house_id=None,
|
||
year_built=deal.year_built,
|
||
house_type=deal.house_type,
|
||
total_floors=deal.total_floors,
|
||
cohort_year_min=cy_min,
|
||
cohort_year_max=cy_max,
|
||
)
|
||
else:
|
||
listings_tier0 = []
|
||
analog_tier = "W"
|
||
|
||
if len(listings_tier0) >= m.MIN_ANALOGS_TIER_0:
|
||
listings = listings_tier0
|
||
fallback_used = False
|
||
else:
|
||
listings, fallback_used, analog_tier = m._fetch_analogs(
|
||
db,
|
||
lat=deal.lat,
|
||
lon=deal.lon,
|
||
rooms=deal.rooms,
|
||
area=deal.area_m2,
|
||
radius_m=m.DEFAULT_RADIUS_M,
|
||
full_address=deal.address,
|
||
target_house_id=None,
|
||
year_built=deal.year_built,
|
||
house_type=deal.house_type,
|
||
total_floors=deal.total_floors,
|
||
)
|
||
area_widened = False
|
||
|
||
if len(listings) < 5:
|
||
listings_wide, _, analog_tier_wide = m._fetch_analogs(
|
||
db,
|
||
lat=deal.lat,
|
||
lon=deal.lon,
|
||
rooms=deal.rooms,
|
||
area=deal.area_m2,
|
||
radius_m=m.FALLBACK_RADIUS_M,
|
||
full_address=deal.address,
|
||
target_house_id=None,
|
||
year_built=deal.year_built,
|
||
house_type=deal.house_type,
|
||
total_floors=deal.total_floors,
|
||
)
|
||
if len(listings_wide) > len(listings):
|
||
listings = listings_wide
|
||
fallback_used = True
|
||
analog_tier = analog_tier_wide
|
||
|
||
if len(listings) < 3:
|
||
listings_widearea, _, analog_tier_wa = m._fetch_analogs(
|
||
db,
|
||
lat=deal.lat,
|
||
lon=deal.lon,
|
||
rooms=deal.rooms,
|
||
area=deal.area_m2,
|
||
radius_m=m.FALLBACK_RADIUS_M,
|
||
area_tolerance=0.25,
|
||
full_address=deal.address,
|
||
target_house_id=None,
|
||
year_built=deal.year_built,
|
||
house_type=deal.house_type,
|
||
total_floors=deal.total_floors,
|
||
)
|
||
if len(listings_widearea) > len(listings):
|
||
listings = listings_widearea
|
||
fallback_used = True
|
||
area_widened = True
|
||
analog_tier = analog_tier_wa
|
||
|
||
return listings, analog_tier, fallback_used, area_widened
|
||
|
||
|
||
def _predict_full_spine(db: Session, deal: DealSample, est: SimpleNamespace) -> Prediction | None:
|
||
"""Predict one deal through the FULL deterministic spine (#1966).
|
||
|
||
Selects analogs via the replicated tier ladder, pre-fetches the spine inputs
|
||
(anchor comps, ДКП corridor, house IMV anchor) and injects the DB callables,
|
||
then calls ``estimator._price_from_inputs`` with parameters that mirror
|
||
``estimate_quality`` for a sold unit whose repair state is unknown and with
|
||
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.
|
||
"""
|
||
m = est.m
|
||
settings = est.settings
|
||
|
||
listings, analog_tier, fallback_used, area_widened = _select_analogs_full(db, deal, est)
|
||
|
||
# ── 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)
|
||
anchor_comps, anchor_tier = m._fetch_anchor_comps(
|
||
db,
|
||
address=deal.address,
|
||
target_house_id=None,
|
||
lat=deal.lat,
|
||
lon=deal.lon,
|
||
rooms=deal.rooms,
|
||
area=deal.area_m2,
|
||
)
|
||
imv_anchor = m._fetch_house_imv_anchor(
|
||
db, target_house_id=None, rooms=deal.rooms, area=deal.area_m2
|
||
)
|
||
|
||
# Synthetic geo = the deal's own coords + address, confidence "exact" — the
|
||
# faithful analog of estimate_quality's client-coords fast path. Real deal
|
||
# addresses carry a house number, so _geocode_is_coarse → False (no coarse
|
||
# downgrade); if an address lacks a number that deal degrades to "low",
|
||
# exactly as the production gate would.
|
||
geo = est.GeocodeResult(
|
||
lat=deal.lat,
|
||
lon=deal.lon,
|
||
full_address=deal.address or "",
|
||
provider="cache",
|
||
confidence="exact",
|
||
)
|
||
|
||
def _ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]:
|
||
return m._get_asking_sold_ratio(db, deal.rooms, anchor_ppm2=appm2)
|
||
|
||
def _qi_lookup(q: str) -> tuple[float, int] | None:
|
||
return m._lookup_quarter_index(
|
||
db,
|
||
quarter_cad_number=q,
|
||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
||
)
|
||
|
||
def _qis_lookup(qs: list[str]) -> dict[str, float]:
|
||
return m._lookup_quarter_indexes(
|
||
db,
|
||
quarter_cad_numbers=qs,
|
||
min_n_deals=settings.estimate_quarter_index_min_n_deals,
|
||
)
|
||
|
||
pr = m._price_from_inputs(
|
||
listings=listings,
|
||
area_m2=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=anchor_comps,
|
||
anchor_tier_fetched=anchor_tier,
|
||
dkp_raw=dkp_raw,
|
||
imv_anchor=imv_anchor,
|
||
imv_eval=None,
|
||
yandex_val_present=False,
|
||
cian_val_present=False,
|
||
ratio_resolver=_ratio_resolver,
|
||
quarter_index_lookup=_qi_lookup,
|
||
quarter_indexes_lookup=_qis_lookup,
|
||
target_house_cadnum=None,
|
||
dadata_coarse=False,
|
||
geo=geo,
|
||
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:
|
||
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
|
||
return Prediction(
|
||
deal_id=deal.id,
|
||
rooms=deal.rooms,
|
||
area_m2=deal.area_m2,
|
||
sold_ppm2=deal.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,
|
||
)
|
||
|
||
|
||
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"] = {
|
||
"engine": "asking-core",
|
||
"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
|
||
|
||
|
||
def run_backtest_full(db: Session, *, sample: int, since: str) -> 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.
|
||
|
||
``--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 asking→sold ratio).
|
||
"""
|
||
est = _import_estimator_full()
|
||
deals = _load_sample(db, sample=sample, since=since)
|
||
logger.info("loaded sample: %d ДКП deals (since=%s) [full spine]", len(deals), since)
|
||
|
||
predictions: list[Prediction] = []
|
||
n_no_prediction = 0
|
||
per_rooms_no_prediction: dict[int, int] = {b: 0 for b in ROOM_BUCKETS}
|
||
|
||
sold_ppm2_all: list[float] = [d.sold_ppm2 for d in deals]
|
||
pred_ppm2_all: list[float] = [] # asking headline median_ppm2 (priced deals)
|
||
|
||
for i, deal in enumerate(deals, start=1):
|
||
try:
|
||
pr = _predict_full_spine(db, deal, est)
|
||
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).
|
||
logger.warning("deal %s spine failed (graceful, skipped): %s", deal.id, exc)
|
||
db.rollback()
|
||
pr = None
|
||
|
||
if pr is None:
|
||
n_no_prediction += 1
|
||
per_rooms_no_prediction[_bucketize_rooms(deal.rooms)] += 1
|
||
else:
|
||
predictions.append(pr)
|
||
pred_ppm2_all.append(pr.median_ppm2)
|
||
|
||
if i % 50 == 0:
|
||
logger.info(
|
||
"progress %d/%d (priced=%d, no_pred=%d)",
|
||
i,
|
||
len(deals),
|
||
len(predictions),
|
||
n_no_prediction,
|
||
)
|
||
|
||
metrics = _compute_full_metrics(
|
||
predictions,
|
||
n_no_prediction=n_no_prediction,
|
||
per_rooms_no_prediction=per_rooms_no_prediction,
|
||
)
|
||
|
||
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"] = {
|
||
"engine": "full",
|
||
"sample_requested": sample,
|
||
"sample_loaded": len(deals),
|
||
"since": since,
|
||
"n_matched": len(predictions),
|
||
"n_no_prediction": n_no_prediction,
|
||
"price_segments_ppm2": [list(seg) for seg in PRICE_SEGMENTS_PPM2],
|
||
}
|
||
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 against rosreestr ДКП sold "
|
||
"prices. Default engine 'full' runs the deterministic pricing spine "
|
||
"(_price_from_inputs); 'asking-core' is the legacy median+IQR core "
|
||
"(issues #648 / #1966)."
|
||
),
|
||
)
|
||
p.add_argument(
|
||
"--engine",
|
||
choices=("full", "asking-core"),
|
||
default="full",
|
||
help="Prediction engine: 'full' (default) = the deterministic pricing "
|
||
"spine via _price_from_inputs (expected_sold + coverage/calibration/"
|
||
"segment metrics); 'asking-core' = legacy asking-median+IQR core with "
|
||
"the per-rooms asking→sold correction block.",
|
||
)
|
||
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: engine=%s sample=%d since=%s radius=%dm "
|
||
"rooms_tolerance=%d holdout_split=%s",
|
||
args.engine,
|
||
args.sample,
|
||
args.since,
|
||
args.radius,
|
||
args.rooms_tolerance,
|
||
args.holdout_split,
|
||
)
|
||
|
||
db = _session()
|
||
try:
|
||
if args.engine == "full":
|
||
metrics = run_backtest_full(db, sample=args.sample, since=args.since)
|
||
else:
|
||
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))
|
||
elif args.engine == "full":
|
||
print(_render_full_table(metrics))
|
||
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)
|